Skip to content

Commit df7335e

Browse files
committed
Polish SolisCloud integration stage 7
1 parent 63c2b34 commit df7335e

17 files changed

Lines changed: 756 additions & 22 deletions

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ Whilst the solis inverters do provide total sensors for today, yesterday, month
4242
**Connection Type**:
4343
- **TCP (WiFi Dongle)**: Use for Data Logging Sticks (DLS) or WiFi dongles. Requires IP and Port (Default 502).
4444
- **Serial (RS485)**: Use for direct USB-RS485 connection. Requires Serial Port path.
45+
- **SolisCloud API**: Uses SolisCloud API key credentials. Polling is slower than local Modbus, but works when local Modbus is unavailable.
46+
- **Modbus + SolisCloud**: Creates one shared device/entity set with either automatic failover or a manual active-protocol selector.
47+
48+
### Protocol Support
49+
| Mode | Sensor polling | Control writes | Notes |
50+
| --- | --- | --- | --- |
51+
| TCP/Serial Modbus | Full local register set | Full mapped register writes | Fastest and preferred where local access is reliable. |
52+
| SolisCloud API | Mapped cloud detail fields and verified control CIDs | Verified CIDs only: storage mode, backup SOC, over-discharge SOC, force-charge SOC, feed-in limit | Cloud data updates every few minutes and unmapped cloud-only sensors are marked unavailable. |
53+
| Modbus + SolisCloud failover | One protocol polls at a time; automatic switch on health loss | Active protocol first, then healthy backup where possible | Primary can be Modbus or cloud. |
54+
| Modbus + SolisCloud manual | One protocol polls at a time, selected by the Active Protocol entity | Active protocol first, then healthy backup where possible | Useful when maintenance or network conditions make one transport preferable. |
55+
56+
Cloud setup derives the closest supported inverter model from SolisCloud metadata where possible. If SolisCloud identifies the model, that value is used to avoid choosing a sensor map that does not match the inverter.
4557

4658
**Inverter Serial**: (Required)
4759
- Enter your inverter's serial number. This is now **mandatory** for generating unique entity IDs and ensuring configuration stability.

‎custom_components/solisconnect/cloud/api_client.py‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,12 +244,41 @@ async def async_at_read_batch(self, inverter_sn: str, cids: list[int]) -> dict[i
244244
_LOGGER.warning("SolisCloud atReadBatch returned no parseable records; raw payload: %s", data)
245245
return results
246246

247+
@staticmethod
248+
def _control_failure(payload) -> str | None:
249+
"""Return an embedded control failure message from a successful HTTP/API payload."""
250+
if isinstance(payload, list):
251+
for item in payload:
252+
failure = SolisCloudApiClient._control_failure(item)
253+
if failure:
254+
return failure
255+
return None
256+
if not isinstance(payload, dict):
257+
return None
258+
259+
code = payload.get("code") or payload.get("resultCode") or payload.get("errorCode")
260+
if code not in (None, "", "0", 0):
261+
return str(payload.get("msg") or payload.get("message") or f"code={code}")
262+
263+
for key in ("success", "result", "isSuccess"):
264+
if payload.get(key) is False:
265+
return str(payload.get("msg") or payload.get("message") or f"{key}=false")
266+
267+
for key in ("data", "result", "response", "responses", "records", "list"):
268+
if key in payload:
269+
failure = SolisCloudApiClient._control_failure(payload[key])
270+
if failure:
271+
return failure
272+
return None
273+
247274
async def async_control(self, inverter_sn: str, cid: int, value: str, old_value: str | None = None) -> bool:
248275
"""Write a control register (CID) value; returns True on success."""
249276
await self.async_ensure_token()
250277
params = {"inverterSn": str(inverter_sn), "cid": str(cid), "value": str(value)}
251278
if old_value is not None:
252279
params["yuanzhi"] = str(old_value)
253-
# _post raises on any non-zero response code, so reaching here means success.
254-
await self._post(RESOURCE_CONTROL, params, with_token=True)
280+
data = await self._post(RESOURCE_CONTROL, params, with_token=True)
281+
failure = self._control_failure(data)
282+
if failure:
283+
raise SolisCloudApiError(f"SolisCloud control for cid {cid} failed: {failure}")
255284
return True
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Helpers for deriving a SolisConnect model from SolisCloud metadata."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
from custom_components.solisconnect.data.solis_config import SOLIS_INVERTERS
8+
9+
MODEL_NAMES = tuple(sorted((inverter.model for inverter in SOLIS_INVERTERS), key=len, reverse=True))
10+
11+
# Verified live: SolisCloud product code 3102 reports an S5-EH1P inverter.
12+
PRODUCT_CODE_TO_MODEL = {
13+
"3102": "S5-EH1P",
14+
}
15+
16+
MODEL_FIELDS = (
17+
"model",
18+
"productModel",
19+
"product_model",
20+
"inverterType",
21+
"inverter_type",
22+
"machine",
23+
"productName",
24+
"product_name",
25+
"typeName",
26+
"type_name",
27+
)
28+
29+
30+
def cloud_model_metadata(record: dict | None) -> dict[str, str]:
31+
"""Return useful cloud model-identifying fields as strings."""
32+
if not isinstance(record, dict):
33+
return {}
34+
metadata = {}
35+
for key in MODEL_FIELDS:
36+
value = record.get(key)
37+
if value not in (None, ""):
38+
metadata[key] = str(value)
39+
return metadata
40+
41+
42+
def infer_model_from_cloud_record(record: dict | None) -> str | None:
43+
"""Infer the closest configured model from SolisCloud inverter metadata."""
44+
metadata = cloud_model_metadata(record)
45+
if not metadata:
46+
return None
47+
48+
haystack = " ".join(metadata.values()).upper()
49+
for model in MODEL_NAMES:
50+
if model.upper() in haystack:
51+
return model
52+
53+
for value in metadata.values():
54+
normalized = re.sub(r"\D", "", value)
55+
if normalized in PRODUCT_CODE_TO_MODEL:
56+
return PRODUCT_CODE_TO_MODEL[normalized]
57+
58+
return None

‎custom_components/solisconnect/config_flow.py‎

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
from .const import (
1111
CONF_BAUDRATE,
1212
CONF_BYTESIZE,
13+
CONF_CLOUD_DETECTED_MODEL,
1314
CONF_CLOUD_INVERTER_ID,
1415
CONF_CLOUD_KEY_ID,
1516
CONF_CLOUD_KEY_SECRET,
1617
CONF_CLOUD_PASSWORD,
1718
CONF_CLOUD_PLANT_ID,
1819
CONF_CLOUD_POLL_INTERVAL,
20+
CONF_CLOUD_PRODUCT_MODEL,
1921
CONF_CLOUD_USERNAME,
2022
CONF_CONNECTION_TYPE,
2123
CONF_FAILOVER_PRIMARY,
@@ -185,6 +187,8 @@ def __init__(self):
185187
self._cloud_data = None
186188
self._resolved_serial = None
187189
self._resolved_cloud_id = None
190+
self._cloud_product_model = None
191+
self._cloud_detected_model = None
188192

189193
async def async_step_user(self, user_input=None):
190194
"""Handle initial step - ask for connection type."""
@@ -266,21 +270,41 @@ async def async_step_cloud(self, user_input=None):
266270
if self._both_mode:
267271
# The dual entry is keyed by the Modbus serial; the cloud plant must contain it
268272
user_input = {**user_input, CONF_INVERTER_SERIAL: self._modbus_data[CONF_INVERTER_SERIAL]}
269-
serial, inverter_id, error = await self._validate_cloud_config(user_input)
273+
serial, inverter_id, detected_model, product_model, error = await self._validate_cloud_config(user_input)
270274
if error:
271275
errors["base"] = error
272276
elif self._both_mode:
277+
if detected_model and self._modbus_data.get("model") != detected_model:
278+
_LOGGER.info(
279+
"SolisCloud model metadata identifies %s; overriding selected model %s for serial %s",
280+
detected_model,
281+
self._modbus_data.get("model"),
282+
serial,
283+
)
284+
self._modbus_data["model"] = detected_model
273285
self._cloud_data = user_input
274286
self._resolved_serial = serial
275287
self._resolved_cloud_id = inverter_id
288+
self._cloud_product_model = product_model
289+
self._cloud_detected_model = detected_model
276290
return await self.async_step_dual_mode()
277291
else:
278292
await self.async_set_unique_id(serial)
279293
self._abort_if_unique_id_configured()
294+
if detected_model and user_input.get("model") != detected_model:
295+
_LOGGER.info(
296+
"SolisCloud model metadata identifies %s; overriding selected model %s for serial %s",
297+
detected_model,
298+
user_input.get("model"),
299+
serial,
300+
)
280301
data = {
281302
**user_input,
303+
"model": detected_model or user_input.get("model"),
282304
CONF_INVERTER_SERIAL: serial,
283305
CONF_CLOUD_INVERTER_ID: inverter_id,
306+
CONF_CLOUD_PRODUCT_MODEL: product_model,
307+
CONF_CLOUD_DETECTED_MODEL: detected_model,
284308
CONF_CONNECTION_TYPE: CONN_TYPE_CLOUD,
285309
CONF_PROTOCOL_MODE: PROTO_CLOUD_ONLY,
286310
}
@@ -325,6 +349,8 @@ async def async_step_dual_mode(self, user_input=None):
325349
**self._cloud_data,
326350
CONF_INVERTER_SERIAL: serial,
327351
CONF_CLOUD_INVERTER_ID: self._resolved_cloud_id,
352+
CONF_CLOUD_PRODUCT_MODEL: self._cloud_product_model,
353+
CONF_CLOUD_DETECTED_MODEL: self._cloud_detected_model,
328354
CONF_PROTOCOL_MODE: protocol_mode,
329355
CONF_FAILOVER_PRIMARY: primary,
330356
}
@@ -335,15 +361,16 @@ async def async_step_dual_mode(self, user_input=None):
335361
data_schema=vol.Schema({vol.Required("dual_mode", default="failover_modbus"): vol.In(DUAL_MODE_OPTIONS)}),
336362
)
337363

338-
async def _validate_cloud_config(self, user_input) -> tuple[str | None, str | None, str | None]:
364+
async def _validate_cloud_config(self, user_input) -> tuple[str | None, str | None, str | None, str | None, str | None]:
339365
"""Check credentials against the SolisCloud API and resolve the inverter serial + cloud id.
340366
341-
Returns (serial, inverter_id, None) on success or (None, None, error_message) on failure.
367+
Returns (serial, inverter_id, detected_model, product_model, None) on success.
342368
The cloud id is required alongside the serial for inverterDetail reads.
343369
"""
344370
from homeassistant.helpers.aiohttp_client import async_get_clientsession
345371

346372
from .cloud.api_client import SolisCloudApiClient, SolisCloudApiError
373+
from .cloud.model_detection import cloud_model_metadata, infer_model_from_cloud_record
347374

348375
client = SolisCloudApiClient(
349376
session=async_get_clientsession(self.hass),
@@ -357,21 +384,26 @@ async def _validate_cloud_config(self, user_input) -> tuple[str | None, str | No
357384
records = await client.async_inverter_list(user_input[CONF_CLOUD_PLANT_ID].strip())
358385
except SolisCloudApiError as error:
359386
_LOGGER.warning("SolisCloud validation failed: %s", error)
360-
return None, None, "Cannot reach the SolisCloud API with these credentials. Check the key id/secret and plant id."
387+
return None, None, None, None, "Cannot reach the SolisCloud API with these credentials. Check the key id/secret and plant id."
361388

362-
by_serial = {str(rec["sn"]).upper(): str(rec.get("id") or "") for rec in records if rec.get("sn")}
389+
by_serial = {str(rec["sn"]).upper(): rec for rec in records if rec.get("sn")}
363390
if not by_serial:
364-
return None, None, "No inverters found for this plant id."
391+
return None, None, None, None, "No inverters found for this plant id."
365392

366393
wanted = (user_input.get(CONF_INVERTER_SERIAL) or "").strip().upper()
367394
if wanted:
368395
if wanted not in by_serial:
369-
return None, None, f"Inverter serial {wanted} not found in this plant (found: {', '.join(by_serial)})."
370-
return wanted, by_serial[wanted] or None, None
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
371401
if len(by_serial) == 1:
372-
serial, inverter_id = next(iter(by_serial.items()))
373-
return serial, inverter_id or None, None
374-
return None, None, f"This plant has multiple inverters ({', '.join(by_serial)}); please enter the serial of the one to add."
402+
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."
375407

376408
async def async_step_reconfigure(self, user_input=None):
377409
"""Handle reconfiguration."""
@@ -419,17 +451,27 @@ async def async_step_reconfigure_cloud(self, user_input=None):
419451
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
420452

421453
if user_input is not None:
422-
serial, inverter_id, error = await self._validate_cloud_config(user_input)
454+
serial, inverter_id, detected_model, product_model, error = await self._validate_cloud_config(user_input)
423455
if error:
424456
errors["base"] = error
425457
elif serial != entry.unique_id:
426458
errors["base"] = f"Serial {serial} does not match this entry ({entry.unique_id}). Add a new entry for a different inverter."
427459
else:
460+
if detected_model and user_input.get("model") != detected_model:
461+
_LOGGER.info(
462+
"SolisCloud model metadata identifies %s; overriding selected model %s for serial %s",
463+
detected_model,
464+
user_input.get("model"),
465+
serial,
466+
)
428467
data = {
429468
**entry.data,
430469
**user_input,
470+
"model": detected_model or user_input.get("model"),
431471
CONF_INVERTER_SERIAL: serial,
432472
CONF_CLOUD_INVERTER_ID: inverter_id,
473+
CONF_CLOUD_PRODUCT_MODEL: product_model,
474+
CONF_CLOUD_DETECTED_MODEL: detected_model,
433475
}
434476
return self.async_update_reload_and_abort(entry, data=data)
435477

‎custom_components/solisconnect/const.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
CONF_CLOUD_KEY_SECRET = "cloud_key_secret"
4040
CONF_CLOUD_PLANT_ID = "cloud_plant_id"
4141
CONF_CLOUD_INVERTER_ID = "cloud_inverter_id"
42+
CONF_CLOUD_PRODUCT_MODEL = "cloud_product_model"
43+
CONF_CLOUD_DETECTED_MODEL = "cloud_detected_model"
4244
CONF_CLOUD_POLL_INTERVAL = "cloud_poll_interval"
4345
DEFAULT_CLOUD_POLL_INTERVAL = 300
4446
MIN_CLOUD_POLL_INTERVAL = 60
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Diagnostics support for SolisConnect."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.config_entries import ConfigEntry
6+
from homeassistant.core import HomeAssistant
7+
8+
from custom_components.solisconnect.const import CONTROLLER, DOMAIN
9+
from custom_components.solisconnect.helpers import redact_config
10+
from custom_components.solisconnect.sensor_data.cloud_mapping import CLOUD_CID_MAP, CLOUD_INPUT_MAP, registers_covered
11+
12+
13+
def _controller_diagnostics(controller) -> dict:
14+
data = {
15+
"model": getattr(controller, "model", None),
16+
"serial_number": getattr(controller, "serial_number", None) or getattr(controller, "device_serial_number", None),
17+
"cache_scope": getattr(controller, "cache_scope", None),
18+
"connected": controller.connected() if hasattr(controller, "connected") else None,
19+
}
20+
21+
active_protocol = getattr(controller, "active_protocol", None)
22+
if isinstance(active_protocol, str):
23+
data.update(
24+
{
25+
"mode": getattr(controller, "mode", None),
26+
"active_protocol": active_protocol,
27+
"failover_primary": getattr(controller, "failover_primary", None),
28+
"has_modbus": getattr(controller, "modbus", None) is not None,
29+
"has_cloud": getattr(controller, "cloud", None) is not None,
30+
}
31+
)
32+
if getattr(controller, "cloud", None) is not None:
33+
data["cloud"] = _controller_diagnostics(controller.cloud)
34+
if getattr(controller, "modbus", None) is not None:
35+
data["modbus"] = _controller_diagnostics(controller.modbus)
36+
return data
37+
38+
poll_interval = getattr(controller, "poll_interval", None)
39+
if isinstance(poll_interval, int):
40+
data.update(
41+
{
42+
"plant_id": getattr(controller, "plant_id", None),
43+
"inverter_id_present": bool(getattr(controller, "inverter_id", None)),
44+
"last_cloud_success": str(getattr(controller, "last_cloud_success", None)),
45+
"poll_interval": poll_interval,
46+
}
47+
)
48+
else:
49+
data.update(
50+
{
51+
"host": getattr(controller, "host", None),
52+
"connection_type": getattr(controller, "connection_type", None),
53+
"connection_id": getattr(controller, "connection_id", None),
54+
"last_modbus_success": str(getattr(controller, "last_modbus_success", None)),
55+
}
56+
)
57+
return data
58+
59+
60+
async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: ConfigEntry) -> dict:
61+
"""Return diagnostics for a config entry."""
62+
controller = hass.data.get(DOMAIN, {}).get(CONTROLLER, {}).get(entry.entry_id)
63+
covered = registers_covered()
64+
65+
return {
66+
"entry": {
67+
"entry_id": entry.entry_id,
68+
"version": entry.version,
69+
"domain": entry.domain,
70+
"title": entry.title,
71+
"unique_id_present": bool(entry.unique_id),
72+
"data": redact_config(dict(entry.data)),
73+
"options": redact_config(dict(entry.options)),
74+
},
75+
"controller": _controller_diagnostics(controller) if controller is not None else None,
76+
"cloud_mapping": {
77+
"detail_fields": len(CLOUD_INPUT_MAP),
78+
"control_cids": len(CLOUD_CID_MAP),
79+
"covered_registers": len(covered),
80+
},
81+
}

0 commit comments

Comments
 (0)