Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down
22 changes: 21 additions & 1 deletion custom_components/solisconnect/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)}")
Expand Down
10 changes: 9 additions & 1 deletion tests/test_cloud_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]),
Expand Down
97 changes: 97 additions & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
Loading