Skip to content

Commit 979d950

Browse files
authored
Merge pull request #27 from fboundy/patch
Implement V1 TOU cloud support (CID 103) and enforce CID 6798 V2 gate
2 parents b59fa11 + c11164b commit 979d950

8 files changed

Lines changed: 345 additions & 19 deletions

File tree

‎custom_components/solisconnect/cloud/cloud_controller.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@
1111
from custom_components.solisconnect.data.solis_config import InverterConfig
1212
from custom_components.solisconnect.helpers import cache_get, cache_save, notify_register_update
1313
from custom_components.solisconnect.sensor_data.cloud_mapping import (
14+
CLOUD_CID_MAP,
1415
REGISTER_TO_CID,
1516
TOU_SWITCH_CIDS_BY_BIT,
1617
TOU_SWITCH_REGISTER,
1718
TOU_TIME_PAIR_TO_CID,
19+
V1_TOU_CID,
20+
V1_TOU_REGISTERS,
1821
CloudCidMapping,
1922
cid_msg_from_words,
2023
encode_cid_value,
2124
)
2225

26+
_V1_TOU_REGISTERS_SET = frozenset(V1_TOU_REGISTERS)
27+
2328
_LOGGER = logging.getLogger(__name__)
2429

2530
# A controller is "connected" while its last successful poll is within this many intervals.
@@ -104,14 +109,21 @@ def _mapping_for_write(self, registers: list[int]) -> CloudCidMapping:
104109
return mapping
105110

106111
async def async_write_holding_register(self, register, value):
107-
if int(register) == TOU_SWITCH_REGISTER:
112+
register = int(register)
113+
if register == TOU_SWITCH_REGISTER:
108114
await self._execute_tou_switch_write(int(value))
109115
return
110-
mapping = self._mapping_for_write([int(register)])
116+
if register in _V1_TOU_REGISTERS_SET:
117+
await self._execute_tou_time_write(CLOUD_CID_MAP[V1_TOU_CID], [register], [int(value)])
118+
return
119+
mapping = self._mapping_for_write([register])
111120
await self._execute_cloud_write(mapping, [int(value)])
112121

113122
async def async_write_holding_registers(self, start_register, values):
114123
registers = [int(start_register) + i for i in range(len(values))]
124+
if all(register in _V1_TOU_REGISTERS_SET for register in registers):
125+
await self._execute_tou_time_write(CLOUD_CID_MAP[V1_TOU_CID], registers, [int(v) for v in values])
126+
return
115127
time_mapping = TOU_TIME_PAIR_TO_CID.get(tuple(registers))
116128
if time_mapping is not None:
117129
await self._execute_tou_time_write(time_mapping, registers, [int(v) for v in values])
@@ -146,7 +158,14 @@ async def _execute_cloud_write(self, mapping: CloudCidMapping, words: list[int])
146158
self._start_verify_task(self._verify_write(mapping, words))
147159

148160
async def _execute_tou_time_write(self, mapping: CloudCidMapping, write_registers: list[int], write_words: list[int]):
149-
"""Write one start/end HA time pair as the cloud API's full HH:MM-HH:MM slot string."""
161+
"""Write a subset of a composite mapping's registers as its full CID value.
162+
163+
Generic against any mapping backed by several Modbus registers packed into one
164+
control-API string: a V2 start/end time pair (4 registers -> "HH:MM-HH:MM"), or a V1
165+
TOU write touching only the current or a single time pair out of its 26 registers
166+
(-> the composite 18-value CID 103 string). Reads the mapping's current full state,
167+
splices in the changed register(s), and writes the recomposed value back.
168+
"""
150169
current_words = await self._current_words_for_mapping(mapping)
151170
if current_words is None:
152171
raise HomeAssistantError(f"SolisCloud write of cid {mapping.cid} failed: current time slot value is unavailable")

‎custom_components/solisconnect/cloud/cloud_retrieval.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
CLOUD_INPUT_MAP,
1919
TOU_SWITCH_CIDS_BY_BIT,
2020
TOU_SWITCH_REGISTER,
21+
TOU_V2_ENABLEMENT_CID,
22+
TOU_V2_REGISTERS,
23+
TOU_V2_SUPPORTED_VALUE,
2124
encode_cid_value,
2225
encode_engineering_value,
2326
registers_covered,
@@ -52,6 +55,7 @@ async def poll_controller(self, event=None):
5255
self._unsub_start = None
5356
if self._disable_unmapped:
5457
self._disable_unmapped_sensors()
58+
await self._disable_v2_if_unsupported()
5559
await self._ensure_inverter_id()
5660
await self._update_cycle()
5761
self._unsub_timer = async_track_time_interval(self.hass, self._update_cycle, timedelta(seconds=self.controller.poll_interval))
@@ -203,6 +207,32 @@ def _disable_unmapped_sensors(self):
203207
_LOGGER.info("Cloud-only mode: %s sensors have no SolisCloud mapping and will be unavailable", len(disabled))
204208
mark_platform_entities_unavailable_for_base_sensors(self.hass, disabled)
205209

210+
async def _disable_v2_if_unsupported(self):
211+
"""CID 6798 reports whether this inverter actually supports TOU V2; if it doesn't,
212+
writes to the V2 entities would otherwise be silently rejected by the device with no
213+
warning from the integration. Same treatment as _disable_unmapped_sensors: mark them
214+
unavailable up front instead. A failed/undeterminable read leaves the sensors as-is —
215+
never disable on a guess."""
216+
try:
217+
msg = await self.controller.api.async_at_read(self.controller.serial_number, TOU_V2_ENABLEMENT_CID)
218+
except SolisCloudApiError as error:
219+
_LOGGER.debug("CID %s (TOU V2 support) read failed; leaving TOU V2 sensors as configured: %s", TOU_V2_ENABLEMENT_CID, error)
220+
return
221+
if str(msg).strip() == TOU_V2_SUPPORTED_VALUE:
222+
return
223+
224+
disabled = []
225+
for group in self.controller.sensor_groups or []:
226+
for sensor in group.sensors:
227+
if sensor.name == "reserve" or not sensor.enabled:
228+
continue
229+
if any(register in TOU_V2_REGISTERS for register in sensor.registrars):
230+
sensor.enabled = False
231+
disabled.append(sensor)
232+
if disabled:
233+
_LOGGER.info("SolisCloud CID %s reports this inverter does not support TOU V2: disabling %s sensors", TOU_V2_ENABLEMENT_CID, len(disabled))
234+
mark_platform_entities_unavailable_for_base_sensors(self.hass, disabled)
235+
206236
async def async_stop(self):
207237
if self._unsub_start is not None:
208238
self._unsub_start()

‎custom_components/solisconnect/sensor_data/cloud_mapping.py‎

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,90 @@ def _tou_v2_mappings() -> list[CloudCidMapping]:
337337
return mappings
338338

339339

340+
# --- V1 TOU (Time-Charging), control CID 103 --------------------------------------------------
341+
# SolisCloud represents the whole 3-slot Time-Charging schedule as a single comma-separated
342+
# string of 18 values (3 slots x 6 fields: ChargeCurrent, DischargeCurrent, ChargeStart(HH:mm),
343+
# ChargeEnd(HH:mm), DischargeStart(HH:mm), DischargeEnd(HH:mm)) via one control CID, unlike V2's
344+
# one-CID-per-field model. SolisCloud models charge/discharge current *per slot*; Modbus only
345+
# has one global register for each (43141/43142) shared by all 3 slots — decode takes slot 1's
346+
# current as the representative Modbus value, and encode broadcasts the single Modbus value to
347+
# all 3 cloud slots. Source: SolisCloud_control_api_command_list workbook, "Hybrid Inverter"
348+
# sheet, row 6 (see WORKING_NOTES.md, "Plan: V1 TOU (Time-Charging) Dual-Protocol Support").
349+
V1_TOU_CID = 103
350+
_V1_TOU_SLOT_BASES = (43143, 43153, 43163)
351+
V1_TOU_REGISTERS: tuple[int, ...] = (43141, 43142) + tuple(base + offset for base in _V1_TOU_SLOT_BASES for offset in range(8))
352+
353+
_HH_MM_RE = re.compile(r"^(\d{1,2}):(\d{1,2})$")
354+
355+
356+
def _decode_hh_mm(text) -> tuple[int, int] | None:
357+
match = _HH_MM_RE.match(str(text).strip())
358+
if not match:
359+
return None
360+
hour, minute = int(match.group(1)), int(match.group(2))
361+
if hour > 23 or minute > 59:
362+
return None
363+
return hour, minute
364+
365+
366+
def _decode_v1_tou(msg: str) -> dict[int, int] | None:
367+
parts = [p.strip() for p in str(msg).split(",")]
368+
if len(parts) != 18:
369+
return None
370+
371+
try:
372+
charge_current = round(float(parts[0]))
373+
discharge_current = round(float(parts[1]))
374+
except ValueError:
375+
return None
376+
377+
words: dict[int, int] = {43141: charge_current, 43142: discharge_current}
378+
for slot, base in enumerate(_V1_TOU_SLOT_BASES):
379+
time_fields = parts[slot * 6 + 2 : slot * 6 + 6]
380+
for pair_index, text in enumerate(time_fields):
381+
decoded = _decode_hh_mm(text)
382+
if decoded is None:
383+
return None
384+
words[base + pair_index * 2] = decoded[0]
385+
words[base + pair_index * 2 + 1] = decoded[1]
386+
return words
387+
388+
389+
def _encode_v1_tou(words: list[int]) -> str | None:
390+
if len(words) != len(V1_TOU_REGISTERS):
391+
return None
392+
try:
393+
charge_current, discharge_current = int(words[0]), int(words[1])
394+
slot_words = [int(w) for w in words[2:]]
395+
except (TypeError, ValueError):
396+
return None
397+
398+
parts: list[str] = []
399+
for slot in range(3):
400+
hm = slot_words[slot * 8 : slot * 8 + 8]
401+
if len(hm) != 8 or any(not (0 <= hm[i] <= (23 if i % 2 == 0 else 59)) for i in range(8)):
402+
return None
403+
charge_start, charge_end, discharge_start, discharge_end = (f"{hm[i]:02d}:{hm[i + 1]:02d}" for i in (0, 2, 4, 6))
404+
parts.extend([str(charge_current), str(discharge_current), charge_start, charge_end, discharge_start, discharge_end])
405+
return ",".join(parts)
406+
407+
408+
_V1_TOU_MAPPING = CloudCidMapping(
409+
registers=V1_TOU_REGISTERS,
410+
cid=V1_TOU_CID,
411+
to_registers=_decode_v1_tou,
412+
to_cid_value=_encode_v1_tou,
413+
)
414+
415+
340416
# --- control CID <-> holding registers -------------------------------------------------------
341417
_CLOUD_CID_MAPPINGS = [
342418
CloudCidMapping(registers=(43110,), cid=636), # storage mode bitfield (== Modbus 43110)
343419
CloudCidMapping(registers=(43024,), cid=157), # reserved / backup SOC %
344420
CloudCidMapping(registers=(43011,), cid=158), # over-discharge SOC %
345421
CloudCidMapping(registers=(43018,), cid=160), # force-charge SOC %
346422
CloudCidMapping(registers=(43074,), cid=696, multiplier=100), # feed-in power limit (raw = W/100, cid msg = W)
423+
_V1_TOU_MAPPING,
347424
] + _tou_v2_mappings()
348425

349426
CLOUD_CID_MAP: dict[int, CloudCidMapping] = {m.cid: m for m in _CLOUD_CID_MAPPINGS}
@@ -352,9 +429,15 @@ def _tou_v2_mappings() -> list[CloudCidMapping]:
352429
# excluded here: all twelve of them share register 43707, so a plain dict comprehension would
353430
# silently collapse to whichever mapping iterates last. Writes to 43707 are handled by the
354431
# dedicated TOU_SWITCH_REGISTER special-case instead; any future caller that looks up 43707
355-
# here directly should get a clear miss rather than a wrong CID.
432+
# here directly should get a clear miss rather than a wrong CID. The V1 TOU composite mapping
433+
# (cid 103) is excluded the same way: its 26 registers share one CID, and individual writes
434+
# (one register for current, two for a time pair) are routed to _execute_tou_time_write via an
435+
# explicit V1_TOU_REGISTERS membership check instead of an exact-registers-tuple match.
356436
REGISTER_TO_CID: dict[int, CloudCidMapping] = {
357-
register: mapping for mapping in CLOUD_CID_MAP.values() if mapping.merge_register_bit is None for register in mapping.registers
437+
register: mapping
438+
for mapping in CLOUD_CID_MAP.values()
439+
if mapping.merge_register_bit is None and mapping.cid != V1_TOU_CID
440+
for register in mapping.registers
358441
}
359442
TOU_SWITCH_REGISTER = 43707
360443
TOU_SWITCH_CIDS_BY_BIT: dict[int, int] = {
@@ -366,6 +449,13 @@ def _tou_v2_mappings() -> list[CloudCidMapping]:
366449
TOU_TIME_PAIR_TO_CID[(mapping.registers[0], mapping.registers[1])] = mapping
367450
TOU_TIME_PAIR_TO_CID[(mapping.registers[2], mapping.registers[3])] = mapping
368451

452+
# CID 6798 reports whether the connected inverter actually supports TOU V2; "43605" (0xAA55)
453+
# means supported. Register range covered by the V2 switch bitfield + all 6 charge/discharge
454+
# slot blocks (43707-43791 inclusive).
455+
TOU_V2_ENABLEMENT_CID = 6798
456+
TOU_V2_SUPPORTED_VALUE = "43605"
457+
TOU_V2_REGISTERS: frozenset[int] = frozenset(range(43707, 43792))
458+
369459

370460
def cid_msg_from_words(mapping: CloudCidMapping, words: list[int]) -> str | None:
371461
"""Convert raw register word(s) into the control-API value string (inverse of encode_cid_value)."""

‎docs/source/protocols.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ Current cloud write support is limited to verified CIDs and CID families:
3030
| `5928-5933`, `5965`, `5969`, `5973`, `5977`, `5981`, `5984` | `43708`, `43715`, ... / `43750`, `43757`, ... | TOU V2 slot SOC values |
3131
| `5947-5963`, `5966-5986` | `43709-43710`, ... / `43751-43752`, ... | TOU V2 slot current/voltage values |
3232
| `5946`, `5949`, `5952`, `5955`, `5958`, `5961`, `5964`, `5968`, `5972`, `5976`, `5980`, `5987` | `43711-43714`, ... / `43753-43756`, ... | TOU V2 `HH:MM-HH:MM` time slots |
33+
| `103` | `43141-43170` | V1 Time-Charging (18-value composite string; see `docs/source/tou.md`) |
3334

3435
Cloud writes read the previous CID value, send the control command, update the cache optimistically, and verify after SolisCloud catches up. If the cloud value does not match, SolisConnect restores the value reported by the device. While a write's verify is pending (up to ~15-30s), the regular poll cycle skips republishing that specific register so it can't overwrite the optimistic value with stale cloud-side data in the meantime.
3536

36-
The TOU V2 timed-slot CID family (`5916-5987`) is mapped into SolisConnect's local `43707-43791` register model. The remaining caveat is feature detection: SolisCloud CID `6798` reports whether TOU V2 is enabled (`43605` / `0xAA55`), but SolisConnect does not yet use that gate to hide or disable the TOU V2 cloud mappings automatically. As a related data point, TOU V2 hardware/firmware shipped from HMI version `FB00` onward (see below); this is not yet used as an alternate detection signal either.
37+
The TOU V2 timed-slot CID family (`5916-5987`) is mapped into SolisConnect's local `43707-43791` register model. Feature detection: SolisCloud CID `6798` reports whether TOU V2 is enabled (`43605` / `0xAA55`); in cloud-only mode, SolisConnect reads this once at first poll and disables the TOU V2 entities if the device doesn't support it. The confirmed HMI-version threshold for V2 support is `0x4B00` (an earlier, incorrect note in this project said `0xFB00`); the config flow now auto-detects `has_v2` from the HMI version on both protocols, independent of the CID `6798` gate.
3738

3839
SolisCloud also reports the inverter's `HMI Version` (register `33001` "DSP Version" and `33002` "HMI Version" are populated from the `inverterDetail` fields `dspmVersionAll`/`hmiVersionAll`) so firmware version sensors work the same in cloud-only mode as they do over Modbus.
3940

40-
**Not yet supported over cloud:** the older `Time-Charging` (TOU V1) schedule is Modbus-only. SolisCloud control CID `103` can drive all 3 of its time slots (with per-slot charge/discharge current, unlike Modbus's single global current), but that mapping has not been implemented yet — see the project's working notes for the full field layout and implementation plan.
41+
The older `Time-Charging` (TOU V1) schedule is now also mapped over cloud via control CID `103` — see `docs/source/tou.md` for the field layout, the Modbus/cloud current-value compromise, and the not-yet-live-verified caveat.
4142

4243
## Operational notes
4344

0 commit comments

Comments
 (0)