Skip to content

Commit 77606de

Browse files
authored
Merge pull request #26 from fboundy/patch
Auto-detect Modbus inverter serial number from device registers
2 parents f06e745 + 8cf66e7 commit 77606de

4 files changed

Lines changed: 128 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Home Assistant only allows one `iot_class` value in the manifest. SolisConnect i
120120
**Inverter Serial**: (Required)
121121

122122
- Enter your inverter's serial number. This is now **mandatory** for generating unique entity IDs and ensuring configuration stability.
123+
- On Modbus hybrid inverters, once connectivity is confirmed the integration reads the serial directly from the device and silently corrects a mistyped value (grid/string models don't expose this register, so the typed value is used as-is). On SolisCloud, the serial is optional and auto-resolved when your plant has exactly one inverter.
123124

124125
**Poll Interval**:
125126

‎custom_components/solisconnect/config_flow.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
)
4646
from .data.enums import InverterType
4747
from .data.solis_config import CONNECTION_METHOD, SOLIS_INVERTERS, InverterConfig, inverter_options_from_config
48-
from .helpers import hmi_version_supports_v2
48+
from .helpers import extract_serial_number, hmi_version_supports_v2
4949

5050
_LOGGER = logging.getLogger(__name__)
5151

@@ -616,6 +616,26 @@ async def _validate_config(self, user_input):
616616
)
617617
user_input["has_v2"] = detected_has_v2
618618

619+
# Auto-detect the inverter serial from the device (registers 33004-33019,
620+
# ASCII) rather than trusting only what the installer typed. Grid/string
621+
# models don't expose this range, so skip there. A failed/garbage read must
622+
# never block setup; the user's typed serial survives untouched.
623+
if inverter_config.type not in [InverterType.GRID, InverterType.STRING]:
624+
try:
625+
serial_registers = await modbus_controller.async_read_input_register(33004, 16)
626+
except Exception as serial_error:
627+
_LOGGER.debug("Serial number read failed during setup; leaving serial as configured: %s", serial_error)
628+
else:
629+
if serial_registers:
630+
detected_serial = extract_serial_number(serial_registers).strip().upper()
631+
if detected_serial and detected_serial != (user_input.get(CONF_INVERTER_SERIAL) or "").strip().upper():
632+
_LOGGER.info(
633+
"Detected inverter serial %s from device; overriding configured serial %s",
634+
detected_serial,
635+
user_input.get(CONF_INVERTER_SERIAL),
636+
)
637+
user_input[CONF_INVERTER_SERIAL] = detected_serial
638+
619639
return True, None
620640
except Exception as e:
621641
_LOGGER.warning(f"Connection failed attempt {attempt + 1}/5: {str(e)}")

‎tests/test_cloud_integration.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,9 +418,17 @@ async def test_dual_flow_cloud_model_overrides_wrong_modbus_model(hass: HomeAssi
418418
from custom_components.solisconnect.config_flow import CONN_TYPE_BOTH
419419
from custom_components.solisconnect.const import CONN_TYPE_TCP, PROTO_BOTH_FAILOVER
420420

421+
async def fake_read_input_register(register, count):
422+
if register == 33004:
423+
return None # no serial data; the Modbus serial stays "sn999" as typed
424+
return [1]
425+
421426
with (
422427
patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True),
423-
patch("custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register", return_value=[1]),
428+
patch(
429+
"custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register",
430+
side_effect=fake_read_input_register,
431+
),
424432
patch(
425433
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
426434
new=AsyncMock(return_value=[{"id": "1", "sn": "sn999", "productModel": "3102"}]),

‎tests/test_config_flow.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
from custom_components.solisconnect.const import CONN_TYPE_TCP, DOMAIN
99

1010

11+
def _encode_serial_registers(serial: str) -> list[int]:
12+
"""Inverse of helpers.extract_serial_number: pack an ASCII serial into 16-bit words."""
13+
padded = serial if len(serial) % 2 == 0 else serial + "\x00"
14+
return [(ord(padded[i]) << 8) | ord(padded[i + 1]) for i in range(0, len(padded), 2)]
15+
16+
1117
@pytest.fixture(autouse=True)
1218
def auto_enable_custom_integrations(enable_custom_integrations):
1319
yield
@@ -81,6 +87,8 @@ async def test_flow_user_hmi_version_overrides_has_v2(hass: HomeAssistant):
8187
async def fake_read_input_register(register, count):
8288
if register == 33002:
8389
return [0x4AFF] # below the V2 threshold
90+
if register == 33004:
91+
return None # no serial data available; must not corrupt the typed serial
8492
return [1]
8593

8694
with (
@@ -113,6 +121,7 @@ async def fake_read_input_register(register, count):
113121

114122
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
115123
assert result["data"]["has_v2"] is False
124+
assert result["data"]["inverter_serial"] == "SN124" # no serial detected; typed value survives
116125

117126

118127
@pytest.mark.asyncio
@@ -122,6 +131,8 @@ async def test_flow_user_hmi_read_failure_keeps_user_choice(hass: HomeAssistant)
122131
async def fake_read_input_register(register, count):
123132
if register == 33002:
124133
raise TimeoutError("no response")
134+
if register == 33004:
135+
return None
125136
return [1]
126137

127138
with (
@@ -154,6 +165,92 @@ async def fake_read_input_register(register, count):
154165

155166
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
156167
assert result["data"]["has_v2"] is False
168+
assert result["data"]["inverter_serial"] == "SN125"
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_flow_user_device_serial_overrides_typed_serial(hass: HomeAssistant):
173+
"""A serial read from device registers 33004-33019 overrides a mistyped form value."""
174+
device_serial_registers = _encode_serial_registers("1234567890123456")
175+
176+
async def fake_read_input_register(register, count):
177+
if register == 33004:
178+
return device_serial_registers
179+
return [1]
180+
181+
with (
182+
patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True),
183+
patch(
184+
"custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register",
185+
side_effect=fake_read_input_register,
186+
),
187+
patch("custom_components.solisconnect.async_setup_entry", return_value=True),
188+
):
189+
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER})
190+
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={"connection_type": CONN_TYPE_TCP})
191+
192+
config_input = {
193+
"host": "1.2.3.4",
194+
"port": 502,
195+
"slave": 1,
196+
"model": "S6-EH1P",
197+
"connection": "S2_WL_ST",
198+
"has_v2": True,
199+
"has_pv": True,
200+
"has_ac_coupling": False,
201+
"has_parallel": False,
202+
"has_battery": True,
203+
"has_hv_battery": False,
204+
"has_generator": False,
205+
"inverter_serial": "wrongserial",
206+
}
207+
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input=config_input)
208+
209+
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
210+
assert result["data"]["inverter_serial"] == "1234567890123456"
211+
212+
213+
@pytest.mark.asyncio
214+
async def test_flow_user_grid_model_skips_serial_detection(hass: HomeAssistant):
215+
"""Grid/string inverters don't expose the serial-number register range; detection must be skipped."""
216+
registers_read = []
217+
218+
async def fake_read_input_register(register, count):
219+
registers_read.append(register)
220+
return [1]
221+
222+
with (
223+
patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True),
224+
patch(
225+
"custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register",
226+
side_effect=fake_read_input_register,
227+
),
228+
patch("custom_components.solisconnect.async_setup_entry", return_value=True),
229+
):
230+
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER})
231+
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={"connection_type": CONN_TYPE_TCP})
232+
233+
config_input = {
234+
"host": "1.2.3.4",
235+
"port": 502,
236+
"slave": 1,
237+
"model": "S6-GR1P",
238+
"connection": "S2_WL_ST",
239+
"has_v2": True,
240+
"has_pv": True,
241+
"has_ac_coupling": False,
242+
"has_parallel": False,
243+
"has_battery": True,
244+
"has_hv_battery": False,
245+
"has_generator": False,
246+
"inverter_serial": "sn126",
247+
}
248+
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input=config_input)
249+
250+
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
251+
assert result["data"]["inverter_serial"] == "SN126"
252+
assert 33004 not in registers_read # serial detection is hybrid-only; grid/string lacks this register range
253+
assert 33002 in registers_read # HMI-version detection is not gated by inverter type
157254

158255

159256
@pytest.mark.asyncio

0 commit comments

Comments
 (0)