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
85 changes: 70 additions & 15 deletions custom_components/solisconnect/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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)}")
Expand Down
23 changes: 23 additions & 0 deletions custom_components/solisconnect/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/source/tou.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
80 changes: 71 additions & 9 deletions tests/test_cloud_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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):
Expand All @@ -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"
Expand All @@ -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})
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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
Loading
Loading