Skip to content

Commit 260a90a

Browse files
authored
Merge pull request #24 from fboundy/patch
Auto-detect TOU V1/V2 support from HMI firmware version (#20)
2 parents 334bd45 + a63e7d7 commit 260a90a

6 files changed

Lines changed: 288 additions & 24 deletions

File tree

‎custom_components/solisconnect/config_flow.py‎

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +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
4849

4950
_LOGGER = logging.getLogger(__name__)
5051

@@ -270,7 +271,7 @@ async def async_step_cloud(self, user_input=None):
270271
if self._both_mode:
271272
# The dual entry is keyed by the Modbus serial; the cloud plant must contain it
272273
user_input = {**user_input, CONF_INVERTER_SERIAL: self._modbus_data[CONF_INVERTER_SERIAL]}
273-
serial, inverter_id, detected_model, product_model, error = await self._validate_cloud_config(user_input)
274+
serial, inverter_id, detected_model, product_model, detected_has_v2, error = await self._validate_cloud_config(user_input)
274275
if error:
275276
errors["base"] = error
276277
elif self._both_mode:
@@ -282,6 +283,14 @@ async def async_step_cloud(self, user_input=None):
282283
serial,
283284
)
284285
self._modbus_data["model"] = detected_model
286+
if detected_has_v2 is not None and self._modbus_data.get("has_v2") != detected_has_v2:
287+
_LOGGER.info(
288+
"HMI firmware version identifies TOU V2 support=%s; overriding has_v2=%s for serial %s",
289+
detected_has_v2,
290+
self._modbus_data.get("has_v2"),
291+
serial,
292+
)
293+
self._modbus_data["has_v2"] = detected_has_v2
285294
self._cloud_data = user_input
286295
self._resolved_serial = serial
287296
self._resolved_cloud_id = inverter_id
@@ -298,9 +307,17 @@ async def async_step_cloud(self, user_input=None):
298307
user_input.get("model"),
299308
serial,
300309
)
310+
if detected_has_v2 is not None and user_input.get("has_v2") != detected_has_v2:
311+
_LOGGER.info(
312+
"HMI firmware version identifies TOU V2 support=%s; overriding has_v2=%s for serial %s",
313+
detected_has_v2,
314+
user_input.get("has_v2"),
315+
serial,
316+
)
301317
data = {
302318
**user_input,
303319
"model": detected_model or user_input.get("model"),
320+
"has_v2": detected_has_v2 if detected_has_v2 is not None else user_input.get("has_v2"),
304321
CONF_INVERTER_SERIAL: serial,
305322
CONF_CLOUD_INVERTER_ID: inverter_id,
306323
CONF_CLOUD_PRODUCT_MODEL: product_model,
@@ -361,10 +378,10 @@ async def async_step_dual_mode(self, user_input=None):
361378
data_schema=vol.Schema({vol.Required("dual_mode", default="failover_modbus"): vol.In(DUAL_MODE_OPTIONS)}),
362379
)
363380

364-
async def _validate_cloud_config(self, user_input) -> tuple[str | None, str | None, str | None, str | None, str | None]:
381+
async def _validate_cloud_config(self, user_input) -> tuple[str | None, str | None, str | None, str | None, bool | None, str | None]:
365382
"""Check credentials against the SolisCloud API and resolve the inverter serial + cloud id.
366383
367-
Returns (serial, inverter_id, detected_model, product_model, None) on success.
384+
Returns (serial, inverter_id, detected_model, product_model, detected_has_v2, None) on success.
368385
The cloud id is required alongside the serial for inverterDetail reads.
369386
"""
370387
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
384401
records = await client.async_inverter_list(user_input[CONF_CLOUD_PLANT_ID].strip())
385402
except SolisCloudApiError as error:
386403
_LOGGER.warning("SolisCloud validation failed: %s", error)
387-
return None, None, None, None, "Cannot reach the SolisCloud API with these credentials. Check the key id/secret and plant id."
404+
return None, None, None, None, None, "Cannot reach the SolisCloud API with these credentials. Check the key id/secret and plant id."
388405

389406
by_serial = {str(rec["sn"]).upper(): rec for rec in records if rec.get("sn")}
390407
if not by_serial:
391-
return None, None, None, None, "No inverters found for this plant id."
408+
return None, None, None, None, None, "No inverters found for this plant id."
409+
410+
async def _resolve(serial: str, record: dict):
411+
metadata = cloud_model_metadata(record)
412+
product_model = metadata.get("productModel") or metadata.get("model")
413+
inverter_id = str(record.get("id") or "") or None
414+
detected_has_v2 = None
415+
# inverterList doesn't include hmiVersionAll; that field is only in inverterDetail
416+
# (the same call cloud_retrieval.py already makes every poll cycle).
417+
try:
418+
detail = await client.async_inverter_detail(serial, inverter_id)
419+
except SolisCloudApiError as error:
420+
_LOGGER.debug("HMI version lookup failed for serial %s during setup: %s", serial, error)
421+
else:
422+
detected_has_v2 = hmi_version_supports_v2(detail.get("hmiVersionAll"))
423+
return serial, inverter_id, infer_model_from_cloud_record(record), product_model, detected_has_v2, None
392424

393425
wanted = (user_input.get(CONF_INVERTER_SERIAL) or "").strip().upper()
394426
if wanted:
395427
if wanted not in by_serial:
396-
return None, None, None, None, f"Inverter serial {wanted} not found in this plant (found: {', '.join(by_serial)})."
397-
record = by_serial[wanted]
398-
metadata = cloud_model_metadata(record)
399-
product_model = metadata.get("productModel") or metadata.get("model")
400-
return wanted, str(record.get("id") or "") or None, infer_model_from_cloud_record(record), product_model, None
428+
return None, None, None, None, None, f"Inverter serial {wanted} not found in this plant (found: {', '.join(by_serial)})."
429+
return await _resolve(wanted, by_serial[wanted])
401430
if len(by_serial) == 1:
402431
serial, record = next(iter(by_serial.items()))
403-
metadata = cloud_model_metadata(record)
404-
product_model = metadata.get("productModel") or metadata.get("model")
405-
return serial, str(record.get("id") or "") or None, infer_model_from_cloud_record(record), product_model, None
406-
return None, None, None, None, f"This plant has multiple inverters ({', '.join(by_serial)}); please enter the serial of the one to add."
432+
return await _resolve(serial, record)
433+
return None, None, None, None, None, f"This plant has multiple inverters ({', '.join(by_serial)}); please enter the serial of the one to add."
407434

408435
async def async_step_reconfigure(self, user_input=None):
409436
"""Handle reconfiguration."""
@@ -451,7 +478,7 @@ async def async_step_reconfigure_cloud(self, user_input=None):
451478
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
452479

453480
if user_input is not None:
454-
serial, inverter_id, detected_model, product_model, error = await self._validate_cloud_config(user_input)
481+
serial, inverter_id, detected_model, product_model, detected_has_v2, error = await self._validate_cloud_config(user_input)
455482
if error:
456483
errors["base"] = error
457484
elif serial != entry.unique_id:
@@ -464,10 +491,18 @@ async def async_step_reconfigure_cloud(self, user_input=None):
464491
user_input.get("model"),
465492
serial,
466493
)
494+
if detected_has_v2 is not None and user_input.get("has_v2") != detected_has_v2:
495+
_LOGGER.info(
496+
"HMI firmware version identifies TOU V2 support=%s; overriding has_v2=%s for serial %s",
497+
detected_has_v2,
498+
user_input.get("has_v2"),
499+
serial,
500+
)
467501
data = {
468502
**entry.data,
469503
**user_input,
470504
"model": detected_model or user_input.get("model"),
505+
"has_v2": detected_has_v2 if detected_has_v2 is not None else user_input.get("has_v2"),
471506
CONF_INVERTER_SERIAL: serial,
472507
CONF_CLOUD_INVERTER_ID: inverter_id,
473508
CONF_CLOUD_PRODUCT_MODEL: product_model,
@@ -561,6 +596,26 @@ async def _validate_config(self, user_input):
561596
else:
562597
await modbus_controller.async_read_input_register(35000, 1)
563598

599+
# Auto-detect TOU V2 support from HMI firmware version (register 33002) rather
600+
# than relying solely on the form's manual toggle. A failed/unparseable read
601+
# must never block setup; the user's chosen value survives untouched.
602+
try:
603+
hmi_registers = await modbus_controller.async_read_input_register(33002, 1)
604+
except Exception as hmi_error:
605+
_LOGGER.debug("HMI version read failed during setup; leaving has_v2 as configured: %s", hmi_error)
606+
else:
607+
if hmi_registers:
608+
detected_has_v2 = hmi_version_supports_v2(hmi_registers[0])
609+
if detected_has_v2 is not None and user_input.get("has_v2") != detected_has_v2:
610+
_LOGGER.info(
611+
"HMI version %s identifies TOU V2 support=%s; overriding has_v2=%s for serial %s",
612+
hmi_registers[0],
613+
detected_has_v2,
614+
user_input.get("has_v2"),
615+
user_input.get(CONF_INVERTER_SERIAL),
616+
)
617+
user_input["has_v2"] = detected_has_v2
618+
564619
return True, None
565620
except Exception as e:
566621
_LOGGER.warning(f"Connection failed attempt {attempt + 1}/5: {str(e)}")

‎custom_components/solisconnect/helpers.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,29 @@ def decode_inverter_model(hex_value):
159159
return protocol_version, model_description
160160

161161

162+
# HMI firmware version at or above this value supports TOU V2 (Grid Time of Use). Verified
163+
# live: an inverter reporting hmiVersionAll/register 33002 = "4B00" is confirmed running V2,
164+
# so 0x4B00 is the boundary-inclusive minimum, not an earlier (wrong) 0xFB00 guess.
165+
V2_HMI_VERSION_THRESHOLD = 0x4B00
166+
167+
168+
def hmi_version_supports_v2(raw: int | str | None) -> bool | None:
169+
"""Whether an HMI firmware version indicates TOU V2 support.
170+
171+
Accepts either the raw Modbus register integer (register 33002) or the SolisCloud
172+
hex string (`inverterDetail` field `hmiVersionAll`, e.g. "4B00") — both encode the
173+
same raw value. Returns None if undeterminable rather than raising, so callers can
174+
fall back to whatever the user already configured.
175+
"""
176+
if raw is None:
177+
return None
178+
try:
179+
value = int(raw, 16) if isinstance(raw, str) else int(raw)
180+
except (TypeError, ValueError):
181+
return None
182+
return value >= V2_HMI_VERSION_THRESHOLD
183+
184+
162185
def controller_scope(controller) -> str:
163186
"""Protocol-neutral scope shared by all controllers talking to one inverter.
164187

‎docs/source/tou.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
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.
44

5+
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.
6+
57
## Summary
68

79
| | Time-Charging (V1) | Grid Time of Use V2 |

‎tests/test_cloud_integration.py‎

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,15 @@ async def test_migration_v3_to_v4_sets_modbus_only(hass: HomeAssistant):
248248

249249

250250
async def test_config_flow_cloud_branch_creates_entry(hass: HomeAssistant):
251-
with patch(
252-
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
253-
new=AsyncMock(return_value=[{"id": "1", "sn": "sn999", "productModel": "3102"}]),
251+
with (
252+
patch(
253+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
254+
new=AsyncMock(return_value=[{"id": "1", "sn": "sn999", "productModel": "3102"}]),
255+
),
256+
patch(
257+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail",
258+
new=AsyncMock(return_value={"hmiVersionAll": "4B00"}),
259+
),
254260
):
255261
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
256262
assert result["type"] == "form" and result["step_id"] == "user"
@@ -277,6 +283,7 @@ async def test_config_flow_cloud_branch_creates_entry(hass: HomeAssistant):
277283
assert result["data"]["model"] == "S5-EH1P"
278284
assert result["data"][CONF_PROTOCOL_MODE] == PROTO_CLOUD_ONLY
279285
assert result["title"] == "Solis: SN999 (Cloud)"
286+
assert result["data"]["has_v2"] is True # hmiVersionAll "4B00" meets the V2 threshold
280287

281288

282289
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):
302309
entry = _cloud_entry()
303310
entry.add_to_hass(hass)
304311

305-
with patch(
306-
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
307-
new=AsyncMock(return_value=[{"id": "CLOUDID1", "sn": SERIAL}]),
312+
with (
313+
patch(
314+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
315+
new=AsyncMock(return_value=[{"id": "CLOUDID1", "sn": SERIAL}]),
316+
),
317+
patch(
318+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail",
319+
new=AsyncMock(return_value={}),
320+
),
308321
):
309322
result = await entry.start_reconfigure_flow(hass)
310323
assert result["type"] == "form"
@@ -329,10 +342,52 @@ async def test_reconfigure_cloud_entry_uses_cloud_form(hass: HomeAssistant):
329342
assert entry.data[CONF_CLOUD_INVERTER_ID] == "CLOUDID1"
330343

331344

345+
async def test_reconfigure_cloud_entry_hmi_version_overrides_has_v2(hass: HomeAssistant):
346+
"""Issue #20: reconfiguring a cloud-only entry also picks up HMI-based V2 detection."""
347+
entry = _cloud_entry()
348+
entry.add_to_hass(hass)
349+
350+
with (
351+
patch(
352+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
353+
new=AsyncMock(return_value=[{"id": "CLOUDID1", "sn": SERIAL}]),
354+
),
355+
patch(
356+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail",
357+
new=AsyncMock(return_value={"hmiVersionAll": "4B00"}),
358+
),
359+
):
360+
result = await entry.start_reconfigure_flow(hass)
361+
362+
with (
363+
patch("custom_components.solisconnect.async_setup_entry", return_value=True),
364+
patch("custom_components.solisconnect.async_unload_entry", return_value=True),
365+
):
366+
result = await hass.config_entries.flow.async_configure(
367+
result["flow_id"],
368+
{
369+
CONF_CLOUD_KEY_ID: "kid2",
370+
CONF_CLOUD_KEY_SECRET: "ksecret2",
371+
CONF_CLOUD_PLANT_ID: "plant1",
372+
CONF_INVERTER_SERIAL: SERIAL,
373+
"model": "S6-EH1P",
374+
"has_v2": False, # form choice; the detected "4B00" should override it
375+
},
376+
)
377+
assert result["type"] == "abort" and result["reason"] == "reconfigure_successful"
378+
assert entry.data["has_v2"] is True
379+
380+
332381
async def test_config_flow_cloud_multiple_inverters_requires_serial(hass: HomeAssistant):
333-
with patch(
334-
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
335-
new=AsyncMock(return_value=[{"id": "1", "sn": "SNA"}, {"id": "2", "sn": "SNB"}]),
382+
with (
383+
patch(
384+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
385+
new=AsyncMock(return_value=[{"id": "1", "sn": "SNA"}, {"id": "2", "sn": "SNB"}]),
386+
),
387+
patch(
388+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail",
389+
new=AsyncMock(return_value={}),
390+
),
336391
):
337392
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
338393
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
370425
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list",
371426
new=AsyncMock(return_value=[{"id": "1", "sn": "sn999", "productModel": "3102"}]),
372427
),
428+
patch(
429+
"custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_detail",
430+
new=AsyncMock(return_value={}),
431+
),
373432
):
374433
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
375434
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
405464
assert result["data"]["model"] == "S5-EH1P"
406465
assert result["data"][CONF_CLOUD_DETECTED_MODEL] == "S5-EH1P"
407466
assert result["data"][CONF_PROTOCOL_MODE] == PROTO_BOTH_FAILOVER
467+
# HMI read (register 33002) returned [1] via the blanket async_read_input_register
468+
# patch, well below the V2 threshold -> overrides the user's has_v2=True to False.
469+
assert result["data"]["has_v2"] is False

0 commit comments

Comments
 (0)