diff --git a/README.md b/README.md index 42adb26..1fdf446 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Home Assistant only allows one `iot_class` value in the manifest. SolisConnect i **Inverter Serial**: (Required) - Enter your inverter's serial number. This is now **mandatory** for generating unique entity IDs and ensuring configuration stability. +- 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. **Poll Interval**: diff --git a/custom_components/solisconnect/config_flow.py b/custom_components/solisconnect/config_flow.py index b42d714..c843994 100644 --- a/custom_components/solisconnect/config_flow.py +++ b/custom_components/solisconnect/config_flow.py @@ -45,7 +45,7 @@ ) from .data.enums import InverterType from .data.solis_config import CONNECTION_METHOD, SOLIS_INVERTERS, InverterConfig, inverter_options_from_config -from .helpers import hmi_version_supports_v2 +from .helpers import extract_serial_number, hmi_version_supports_v2 _LOGGER = logging.getLogger(__name__) @@ -616,6 +616,26 @@ async def _validate_config(self, user_input): ) user_input["has_v2"] = detected_has_v2 + # Auto-detect the inverter serial from the device (registers 33004-33019, + # ASCII) rather than trusting only what the installer typed. Grid/string + # models don't expose this range, so skip there. A failed/garbage read must + # never block setup; the user's typed serial survives untouched. + if inverter_config.type not in [InverterType.GRID, InverterType.STRING]: + try: + serial_registers = await modbus_controller.async_read_input_register(33004, 16) + except Exception as serial_error: + _LOGGER.debug("Serial number read failed during setup; leaving serial as configured: %s", serial_error) + else: + if serial_registers: + detected_serial = extract_serial_number(serial_registers).strip().upper() + if detected_serial and detected_serial != (user_input.get(CONF_INVERTER_SERIAL) or "").strip().upper(): + _LOGGER.info( + "Detected inverter serial %s from device; overriding configured serial %s", + detected_serial, + user_input.get(CONF_INVERTER_SERIAL), + ) + user_input[CONF_INVERTER_SERIAL] = detected_serial + return True, None except Exception as e: _LOGGER.warning(f"Connection failed attempt {attempt + 1}/5: {str(e)}") diff --git a/tests/test_cloud_integration.py b/tests/test_cloud_integration.py index b9771fa..3fbc2ae 100644 --- a/tests/test_cloud_integration.py +++ b/tests/test_cloud_integration.py @@ -418,9 +418,17 @@ async def test_dual_flow_cloud_model_overrides_wrong_modbus_model(hass: HomeAssi from custom_components.solisconnect.config_flow import CONN_TYPE_BOTH from custom_components.solisconnect.const import CONN_TYPE_TCP, PROTO_BOTH_FAILOVER + async def fake_read_input_register(register, count): + if register == 33004: + return None # no serial data; the Modbus serial stays "sn999" as typed + return [1] + with ( patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True), - patch("custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register", return_value=[1]), + patch( + "custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register", + side_effect=fake_read_input_register, + ), patch( "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", new=AsyncMock(return_value=[{"id": "1", "sn": "sn999", "productModel": "3102"}]), diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 59aa1c2..1124c46 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -8,6 +8,12 @@ from custom_components.solisconnect.const import CONN_TYPE_TCP, DOMAIN +def _encode_serial_registers(serial: str) -> list[int]: + """Inverse of helpers.extract_serial_number: pack an ASCII serial into 16-bit words.""" + padded = serial if len(serial) % 2 == 0 else serial + "\x00" + return [(ord(padded[i]) << 8) | ord(padded[i + 1]) for i in range(0, len(padded), 2)] + + @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): yield @@ -81,6 +87,8 @@ async def test_flow_user_hmi_version_overrides_has_v2(hass: HomeAssistant): async def fake_read_input_register(register, count): if register == 33002: return [0x4AFF] # below the V2 threshold + if register == 33004: + return None # no serial data available; must not corrupt the typed serial return [1] with ( @@ -113,6 +121,7 @@ async def fake_read_input_register(register, count): assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"]["has_v2"] is False + assert result["data"]["inverter_serial"] == "SN124" # no serial detected; typed value survives @pytest.mark.asyncio @@ -122,6 +131,8 @@ async def test_flow_user_hmi_read_failure_keeps_user_choice(hass: HomeAssistant) async def fake_read_input_register(register, count): if register == 33002: raise TimeoutError("no response") + if register == 33004: + return None return [1] with ( @@ -154,6 +165,92 @@ async def fake_read_input_register(register, count): assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"]["has_v2"] is False + assert result["data"]["inverter_serial"] == "SN125" + + +@pytest.mark.asyncio +async def test_flow_user_device_serial_overrides_typed_serial(hass: HomeAssistant): + """A serial read from device registers 33004-33019 overrides a mistyped form value.""" + device_serial_registers = _encode_serial_registers("1234567890123456") + + async def fake_read_input_register(register, count): + if register == 33004: + return device_serial_registers + return [1] + + with ( + patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True), + patch( + "custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register", + side_effect=fake_read_input_register, + ), + patch("custom_components.solisconnect.async_setup_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={"connection_type": CONN_TYPE_TCP}) + + config_input = { + "host": "1.2.3.4", + "port": 502, + "slave": 1, + "model": "S6-EH1P", + "connection": "S2_WL_ST", + "has_v2": True, + "has_pv": True, + "has_ac_coupling": False, + "has_parallel": False, + "has_battery": True, + "has_hv_battery": False, + "has_generator": False, + "inverter_serial": "wrongserial", + } + result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input=config_input) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"]["inverter_serial"] == "1234567890123456" + + +@pytest.mark.asyncio +async def test_flow_user_grid_model_skips_serial_detection(hass: HomeAssistant): + """Grid/string inverters don't expose the serial-number register range; detection must be skipped.""" + registers_read = [] + + async def fake_read_input_register(register, count): + registers_read.append(register) + return [1] + + with ( + patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True), + patch( + "custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register", + side_effect=fake_read_input_register, + ), + patch("custom_components.solisconnect.async_setup_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={"connection_type": CONN_TYPE_TCP}) + + config_input = { + "host": "1.2.3.4", + "port": 502, + "slave": 1, + "model": "S6-GR1P", + "connection": "S2_WL_ST", + "has_v2": True, + "has_pv": True, + "has_ac_coupling": False, + "has_parallel": False, + "has_battery": True, + "has_hv_battery": False, + "has_generator": False, + "inverter_serial": "sn126", + } + result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input=config_input) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"]["inverter_serial"] == "SN126" + assert 33004 not in registers_read # serial detection is hybrid-only; grid/string lacks this register range + assert 33002 in registers_read # HMI-version detection is not gated by inverter type @pytest.mark.asyncio