diff --git a/custom_components/solisconnect/config_flow.py b/custom_components/solisconnect/config_flow.py index 179e5d8..b42d714 100644 --- a/custom_components/solisconnect/config_flow.py +++ b/custom_components/solisconnect/config_flow.py @@ -45,6 +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 _LOGGER = logging.getLogger(__name__) @@ -270,7 +271,7 @@ async def async_step_cloud(self, user_input=None): if self._both_mode: # The dual entry is keyed by the Modbus serial; the cloud plant must contain it user_input = {**user_input, CONF_INVERTER_SERIAL: self._modbus_data[CONF_INVERTER_SERIAL]} - serial, inverter_id, detected_model, product_model, error = await self._validate_cloud_config(user_input) + serial, inverter_id, detected_model, product_model, detected_has_v2, error = await self._validate_cloud_config(user_input) if error: errors["base"] = error elif self._both_mode: @@ -282,6 +283,14 @@ async def async_step_cloud(self, user_input=None): serial, ) self._modbus_data["model"] = detected_model + if detected_has_v2 is not None and self._modbus_data.get("has_v2") != detected_has_v2: + _LOGGER.info( + "HMI firmware version identifies TOU V2 support=%s; overriding has_v2=%s for serial %s", + detected_has_v2, + self._modbus_data.get("has_v2"), + serial, + ) + self._modbus_data["has_v2"] = detected_has_v2 self._cloud_data = user_input self._resolved_serial = serial self._resolved_cloud_id = inverter_id @@ -298,9 +307,17 @@ async def async_step_cloud(self, user_input=None): user_input.get("model"), serial, ) + if detected_has_v2 is not None and user_input.get("has_v2") != detected_has_v2: + _LOGGER.info( + "HMI firmware version identifies TOU V2 support=%s; overriding has_v2=%s for serial %s", + detected_has_v2, + user_input.get("has_v2"), + serial, + ) data = { **user_input, "model": detected_model or user_input.get("model"), + "has_v2": detected_has_v2 if detected_has_v2 is not None else user_input.get("has_v2"), CONF_INVERTER_SERIAL: serial, CONF_CLOUD_INVERTER_ID: inverter_id, CONF_CLOUD_PRODUCT_MODEL: product_model, @@ -361,10 +378,10 @@ async def async_step_dual_mode(self, user_input=None): data_schema=vol.Schema({vol.Required("dual_mode", default="failover_modbus"): vol.In(DUAL_MODE_OPTIONS)}), ) - async def _validate_cloud_config(self, user_input) -> tuple[str | None, str | None, str | None, str | None, str | None]: + async def _validate_cloud_config(self, user_input) -> tuple[str | None, str | None, str | None, str | None, bool | None, str | None]: """Check credentials against the SolisCloud API and resolve the inverter serial + cloud id. - Returns (serial, inverter_id, detected_model, product_model, None) on success. + Returns (serial, inverter_id, detected_model, product_model, detected_has_v2, None) on success. The cloud id is required alongside the serial for inverterDetail reads. """ from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -384,26 +401,36 @@ async def _validate_cloud_config(self, user_input) -> tuple[str | None, str | No records = await client.async_inverter_list(user_input[CONF_CLOUD_PLANT_ID].strip()) except SolisCloudApiError as error: _LOGGER.warning("SolisCloud validation failed: %s", error) - return None, None, None, None, "Cannot reach the SolisCloud API with these credentials. Check the key id/secret and plant id." + return None, None, None, None, None, "Cannot reach the SolisCloud API with these credentials. Check the key id/secret and plant id." by_serial = {str(rec["sn"]).upper(): rec for rec in records if rec.get("sn")} if not by_serial: - return None, None, None, None, "No inverters found for this plant id." + return None, None, None, None, None, "No inverters found for this plant id." + + async def _resolve(serial: str, record: dict): + metadata = cloud_model_metadata(record) + product_model = metadata.get("productModel") or metadata.get("model") + inverter_id = str(record.get("id") or "") or None + detected_has_v2 = None + # inverterList doesn't include hmiVersionAll; that field is only in inverterDetail + # (the same call cloud_retrieval.py already makes every poll cycle). + try: + detail = await client.async_inverter_detail(serial, inverter_id) + except SolisCloudApiError as error: + _LOGGER.debug("HMI version lookup failed for serial %s during setup: %s", serial, error) + else: + detected_has_v2 = hmi_version_supports_v2(detail.get("hmiVersionAll")) + return serial, inverter_id, infer_model_from_cloud_record(record), product_model, detected_has_v2, None wanted = (user_input.get(CONF_INVERTER_SERIAL) or "").strip().upper() if wanted: if wanted not in by_serial: - return None, None, None, None, f"Inverter serial {wanted} not found in this plant (found: {', '.join(by_serial)})." - record = by_serial[wanted] - metadata = cloud_model_metadata(record) - product_model = metadata.get("productModel") or metadata.get("model") - return wanted, str(record.get("id") or "") or None, infer_model_from_cloud_record(record), product_model, None + return None, None, None, None, None, f"Inverter serial {wanted} not found in this plant (found: {', '.join(by_serial)})." + return await _resolve(wanted, by_serial[wanted]) if len(by_serial) == 1: serial, record = next(iter(by_serial.items())) - metadata = cloud_model_metadata(record) - product_model = metadata.get("productModel") or metadata.get("model") - return serial, str(record.get("id") or "") or None, infer_model_from_cloud_record(record), product_model, None - return None, None, None, None, f"This plant has multiple inverters ({', '.join(by_serial)}); please enter the serial of the one to add." + return await _resolve(serial, record) + return None, None, None, None, None, f"This plant has multiple inverters ({', '.join(by_serial)}); please enter the serial of the one to add." async def async_step_reconfigure(self, user_input=None): """Handle reconfiguration.""" @@ -451,7 +478,7 @@ async def async_step_reconfigure_cloud(self, user_input=None): entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) if user_input is not None: - serial, inverter_id, detected_model, product_model, error = await self._validate_cloud_config(user_input) + serial, inverter_id, detected_model, product_model, detected_has_v2, error = await self._validate_cloud_config(user_input) if error: errors["base"] = error elif serial != entry.unique_id: @@ -464,10 +491,18 @@ async def async_step_reconfigure_cloud(self, user_input=None): user_input.get("model"), serial, ) + if detected_has_v2 is not None and user_input.get("has_v2") != detected_has_v2: + _LOGGER.info( + "HMI firmware version identifies TOU V2 support=%s; overriding has_v2=%s for serial %s", + detected_has_v2, + user_input.get("has_v2"), + serial, + ) data = { **entry.data, **user_input, "model": detected_model or user_input.get("model"), + "has_v2": detected_has_v2 if detected_has_v2 is not None else user_input.get("has_v2"), CONF_INVERTER_SERIAL: serial, CONF_CLOUD_INVERTER_ID: inverter_id, CONF_CLOUD_PRODUCT_MODEL: product_model, @@ -561,6 +596,26 @@ async def _validate_config(self, user_input): else: await modbus_controller.async_read_input_register(35000, 1) + # Auto-detect TOU V2 support from HMI firmware version (register 33002) rather + # than relying solely on the form's manual toggle. A failed/unparseable read + # must never block setup; the user's chosen value survives untouched. + try: + hmi_registers = await modbus_controller.async_read_input_register(33002, 1) + except Exception as hmi_error: + _LOGGER.debug("HMI version read failed during setup; leaving has_v2 as configured: %s", hmi_error) + else: + if hmi_registers: + detected_has_v2 = hmi_version_supports_v2(hmi_registers[0]) + if detected_has_v2 is not None and user_input.get("has_v2") != detected_has_v2: + _LOGGER.info( + "HMI version %s identifies TOU V2 support=%s; overriding has_v2=%s for serial %s", + hmi_registers[0], + detected_has_v2, + user_input.get("has_v2"), + user_input.get(CONF_INVERTER_SERIAL), + ) + user_input["has_v2"] = detected_has_v2 + return True, None except Exception as e: _LOGGER.warning(f"Connection failed attempt {attempt + 1}/5: {str(e)}") diff --git a/custom_components/solisconnect/helpers.py b/custom_components/solisconnect/helpers.py index 31f8709..3571ac4 100644 --- a/custom_components/solisconnect/helpers.py +++ b/custom_components/solisconnect/helpers.py @@ -159,6 +159,29 @@ def decode_inverter_model(hex_value): return protocol_version, model_description +# HMI firmware version at or above this value supports TOU V2 (Grid Time of Use). Verified +# live: an inverter reporting hmiVersionAll/register 33002 = "4B00" is confirmed running V2, +# so 0x4B00 is the boundary-inclusive minimum, not an earlier (wrong) 0xFB00 guess. +V2_HMI_VERSION_THRESHOLD = 0x4B00 + + +def hmi_version_supports_v2(raw: int | str | None) -> bool | None: + """Whether an HMI firmware version indicates TOU V2 support. + + Accepts either the raw Modbus register integer (register 33002) or the SolisCloud + hex string (`inverterDetail` field `hmiVersionAll`, e.g. "4B00") — both encode the + same raw value. Returns None if undeterminable rather than raising, so callers can + fall back to whatever the user already configured. + """ + if raw is None: + return None + try: + value = int(raw, 16) if isinstance(raw, str) else int(raw) + except (TypeError, ValueError): + return None + return value >= V2_HMI_VERSION_THRESHOLD + + def controller_scope(controller) -> str: """Protocol-neutral scope shared by all controllers talking to one inverter. diff --git a/docs/source/tou.md b/docs/source/tou.md index 1786be5..af51735 100644 --- a/docs/source/tou.md +++ b/docs/source/tou.md @@ -2,6 +2,8 @@ Solis hybrid inverters expose two independent, non-interacting sets of scheduled charge/discharge controls. SolisConnect maps both, but they cover different register ranges, different slot counts, and different levels of Modbus/SolisCloud parity. +The config flow's "V2 support" toggle is auto-detected from the inverter's HMI firmware version (Modbus register `33002`, or SolisCloud `inverterDetail` field `hmiVersionAll`) whenever that read succeeds: an HMI version at or above `0x4B00` supports V2. Detection silently overrides whatever the installer picked in the form (same pattern as automatic model detection); if the read fails or is unparseable, the form's choice is left as-is rather than blocking setup. + ## Summary | | Time-Charging (V1) | Grid Time of Use V2 | diff --git a/tests/test_cloud_integration.py b/tests/test_cloud_integration.py index b5043b8..b9771fa 100644 --- a/tests/test_cloud_integration.py +++ b/tests/test_cloud_integration.py @@ -248,9 +248,15 @@ async def test_migration_v3_to_v4_sets_modbus_only(hass: HomeAssistant): async def test_config_flow_cloud_branch_creates_entry(hass: HomeAssistant): - with patch( - "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", - new=AsyncMock(return_value=[{"id": "1", "sn": "sn999", "productModel": "3102"}]), + with ( + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", + new=AsyncMock(return_value=[{"id": "1", "sn": "sn999", "productModel": "3102"}]), + ), + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail", + new=AsyncMock(return_value={"hmiVersionAll": "4B00"}), + ), ): result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) assert result["type"] == "form" and result["step_id"] == "user" @@ -277,6 +283,7 @@ async def test_config_flow_cloud_branch_creates_entry(hass: HomeAssistant): assert result["data"]["model"] == "S5-EH1P" assert result["data"][CONF_PROTOCOL_MODE] == PROTO_CLOUD_ONLY assert result["title"] == "Solis: SN999 (Cloud)" + assert result["data"]["has_v2"] is True # hmiVersionAll "4B00" meets the V2 threshold async def test_config_flow_cloud_bad_credentials_shows_error(hass: HomeAssistant): @@ -302,9 +309,15 @@ async def test_reconfigure_cloud_entry_uses_cloud_form(hass: HomeAssistant): entry = _cloud_entry() entry.add_to_hass(hass) - with patch( - "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", - new=AsyncMock(return_value=[{"id": "CLOUDID1", "sn": SERIAL}]), + with ( + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", + new=AsyncMock(return_value=[{"id": "CLOUDID1", "sn": SERIAL}]), + ), + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail", + new=AsyncMock(return_value={}), + ), ): result = await entry.start_reconfigure_flow(hass) assert result["type"] == "form" @@ -329,10 +342,52 @@ async def test_reconfigure_cloud_entry_uses_cloud_form(hass: HomeAssistant): assert entry.data[CONF_CLOUD_INVERTER_ID] == "CLOUDID1" +async def test_reconfigure_cloud_entry_hmi_version_overrides_has_v2(hass: HomeAssistant): + """Issue #20: reconfiguring a cloud-only entry also picks up HMI-based V2 detection.""" + entry = _cloud_entry() + entry.add_to_hass(hass) + + with ( + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", + new=AsyncMock(return_value=[{"id": "CLOUDID1", "sn": SERIAL}]), + ), + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail", + new=AsyncMock(return_value={"hmiVersionAll": "4B00"}), + ), + ): + result = await entry.start_reconfigure_flow(hass) + + with ( + patch("custom_components.solisconnect.async_setup_entry", return_value=True), + patch("custom_components.solisconnect.async_unload_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_KEY_ID: "kid2", + CONF_CLOUD_KEY_SECRET: "ksecret2", + CONF_CLOUD_PLANT_ID: "plant1", + CONF_INVERTER_SERIAL: SERIAL, + "model": "S6-EH1P", + "has_v2": False, # form choice; the detected "4B00" should override it + }, + ) + assert result["type"] == "abort" and result["reason"] == "reconfigure_successful" + assert entry.data["has_v2"] is True + + async def test_config_flow_cloud_multiple_inverters_requires_serial(hass: HomeAssistant): - with patch( - "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", - new=AsyncMock(return_value=[{"id": "1", "sn": "SNA"}, {"id": "2", "sn": "SNB"}]), + with ( + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", + new=AsyncMock(return_value=[{"id": "1", "sn": "SNA"}, {"id": "2", "sn": "SNB"}]), + ), + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail", + new=AsyncMock(return_value={}), + ), ): result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) result = await hass.config_entries.flow.async_configure(result["flow_id"], {CONF_CONNECTION_TYPE: CONN_TYPE_CLOUD}) @@ -370,6 +425,10 @@ async def test_dual_flow_cloud_model_overrides_wrong_modbus_model(hass: HomeAssi "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", new=AsyncMock(return_value=[{"id": "1", "sn": "sn999", "productModel": "3102"}]), ), + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail", + new=AsyncMock(return_value={}), + ), ): result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) result = await hass.config_entries.flow.async_configure(result["flow_id"], {CONF_CONNECTION_TYPE: CONN_TYPE_BOTH}) @@ -405,3 +464,6 @@ async def test_dual_flow_cloud_model_overrides_wrong_modbus_model(hass: HomeAssi assert result["data"]["model"] == "S5-EH1P" assert result["data"][CONF_CLOUD_DETECTED_MODEL] == "S5-EH1P" assert result["data"][CONF_PROTOCOL_MODE] == PROTO_BOTH_FAILOVER + # HMI read (register 33002) returned [1] via the blanket async_read_input_register + # patch, well below the V2 threshold -> overrides the user's has_v2=True to False. + assert result["data"]["has_v2"] is False diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3cf8a1e..59aa1c2 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -74,6 +74,88 @@ async def test_flow_user_success(hass: HomeAssistant): mock_setup_entry.assert_called_once() +@pytest.mark.asyncio +async def test_flow_user_hmi_version_overrides_has_v2(hass: HomeAssistant): + """Issue #20: a determinate HMI version (register 33002) overrides the form's has_v2 choice.""" + + async def fake_read_input_register(register, count): + if register == 33002: + return [0x4AFF] # below the V2 threshold + 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, # user picked True; the detected HMI version should override it + "has_pv": True, + "has_ac_coupling": False, + "has_parallel": False, + "has_battery": True, + "has_hv_battery": False, + "has_generator": False, + "inverter_serial": "sn124", + } + 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"]["has_v2"] is False + + +@pytest.mark.asyncio +async def test_flow_user_hmi_read_failure_keeps_user_choice(hass: HomeAssistant): + """Issue #20: an HMI version read failure must never block setup or override the user's choice.""" + + async def fake_read_input_register(register, count): + if register == 33002: + raise TimeoutError("no response") + 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": False, + "has_pv": True, + "has_ac_coupling": False, + "has_parallel": False, + "has_battery": True, + "has_hv_battery": False, + "has_generator": False, + "inverter_serial": "sn125", + } + 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"]["has_v2"] is False + + @pytest.mark.asyncio async def test_flow_user_connection_error(hass: HomeAssistant): """Test user initialized flow with connection error.""" diff --git a/tests/test_hmi_version_detection.py b/tests/test_hmi_version_detection.py new file mode 100644 index 0000000..76c7075 --- /dev/null +++ b/tests/test_hmi_version_detection.py @@ -0,0 +1,40 @@ +"""Unit tests for the shared HMI-version-to-V2-support helper (issue #20).""" + +from custom_components.solisconnect.helpers import V2_HMI_VERSION_THRESHOLD, hmi_version_supports_v2 + + +def test_threshold_is_0x4b00(): + assert V2_HMI_VERSION_THRESHOLD == 0x4B00 + + +def test_at_threshold_supports_v2(): + assert hmi_version_supports_v2(0x4B00) is True + + +def test_above_threshold_supports_v2(): + assert hmi_version_supports_v2(0x5000) is True + + +def test_below_threshold_does_not_support_v2(): + assert hmi_version_supports_v2(0x4AFF) is False + + +def test_accepts_cloud_hex_string(): + assert hmi_version_supports_v2("4B00") is True + assert hmi_version_supports_v2("4AFF") is False + + +def test_accepts_modbus_int(): + assert hmi_version_supports_v2(19200) is True # 0x4B00 decimal + + +def test_none_is_undeterminable(): + assert hmi_version_supports_v2(None) is None + + +def test_unparseable_string_is_undeterminable(): + assert hmi_version_supports_v2("not-hex") is None + + +def test_unparseable_type_is_undeterminable(): + assert hmi_version_supports_v2(object()) is None