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
25 changes: 22 additions & 3 deletions custom_components/solisconnect/cloud/cloud_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@
from custom_components.solisconnect.data.solis_config import InverterConfig
from custom_components.solisconnect.helpers import cache_get, cache_save, notify_register_update
from custom_components.solisconnect.sensor_data.cloud_mapping import (
CLOUD_CID_MAP,
REGISTER_TO_CID,
TOU_SWITCH_CIDS_BY_BIT,
TOU_SWITCH_REGISTER,
TOU_TIME_PAIR_TO_CID,
V1_TOU_CID,
V1_TOU_REGISTERS,
CloudCidMapping,
cid_msg_from_words,
encode_cid_value,
)

_V1_TOU_REGISTERS_SET = frozenset(V1_TOU_REGISTERS)

_LOGGER = logging.getLogger(__name__)

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

async def async_write_holding_register(self, register, value):
if int(register) == TOU_SWITCH_REGISTER:
register = int(register)
if register == TOU_SWITCH_REGISTER:
await self._execute_tou_switch_write(int(value))
return
mapping = self._mapping_for_write([int(register)])
if register in _V1_TOU_REGISTERS_SET:
await self._execute_tou_time_write(CLOUD_CID_MAP[V1_TOU_CID], [register], [int(value)])
return
mapping = self._mapping_for_write([register])
await self._execute_cloud_write(mapping, [int(value)])

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

async def _execute_tou_time_write(self, mapping: CloudCidMapping, write_registers: list[int], write_words: list[int]):
"""Write one start/end HA time pair as the cloud API's full HH:MM-HH:MM slot string."""
"""Write a subset of a composite mapping's registers as its full CID value.

Generic against any mapping backed by several Modbus registers packed into one
control-API string: a V2 start/end time pair (4 registers -> "HH:MM-HH:MM"), or a V1
TOU write touching only the current or a single time pair out of its 26 registers
(-> the composite 18-value CID 103 string). Reads the mapping's current full state,
splices in the changed register(s), and writes the recomposed value back.
"""
current_words = await self._current_words_for_mapping(mapping)
if current_words is None:
raise HomeAssistantError(f"SolisCloud write of cid {mapping.cid} failed: current time slot value is unavailable")
Expand Down
30 changes: 30 additions & 0 deletions custom_components/solisconnect/cloud/cloud_retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
CLOUD_INPUT_MAP,
TOU_SWITCH_CIDS_BY_BIT,
TOU_SWITCH_REGISTER,
TOU_V2_ENABLEMENT_CID,
TOU_V2_REGISTERS,
TOU_V2_SUPPORTED_VALUE,
encode_cid_value,
encode_engineering_value,
registers_covered,
Expand Down Expand Up @@ -52,6 +55,7 @@ async def poll_controller(self, event=None):
self._unsub_start = None
if self._disable_unmapped:
self._disable_unmapped_sensors()
await self._disable_v2_if_unsupported()
await self._ensure_inverter_id()
await self._update_cycle()
self._unsub_timer = async_track_time_interval(self.hass, self._update_cycle, timedelta(seconds=self.controller.poll_interval))
Expand Down Expand Up @@ -203,6 +207,32 @@ def _disable_unmapped_sensors(self):
_LOGGER.info("Cloud-only mode: %s sensors have no SolisCloud mapping and will be unavailable", len(disabled))
mark_platform_entities_unavailable_for_base_sensors(self.hass, disabled)

async def _disable_v2_if_unsupported(self):
"""CID 6798 reports whether this inverter actually supports TOU V2; if it doesn't,
writes to the V2 entities would otherwise be silently rejected by the device with no
warning from the integration. Same treatment as _disable_unmapped_sensors: mark them
unavailable up front instead. A failed/undeterminable read leaves the sensors as-is —
never disable on a guess."""
try:
msg = await self.controller.api.async_at_read(self.controller.serial_number, TOU_V2_ENABLEMENT_CID)
except SolisCloudApiError as error:
_LOGGER.debug("CID %s (TOU V2 support) read failed; leaving TOU V2 sensors as configured: %s", TOU_V2_ENABLEMENT_CID, error)
return
if str(msg).strip() == TOU_V2_SUPPORTED_VALUE:
return

disabled = []
for group in self.controller.sensor_groups or []:
for sensor in group.sensors:
if sensor.name == "reserve" or not sensor.enabled:
continue
if any(register in TOU_V2_REGISTERS for register in sensor.registrars):
sensor.enabled = False
disabled.append(sensor)
if disabled:
_LOGGER.info("SolisCloud CID %s reports this inverter does not support TOU V2: disabling %s sensors", TOU_V2_ENABLEMENT_CID, len(disabled))
mark_platform_entities_unavailable_for_base_sensors(self.hass, disabled)

async def async_stop(self):
if self._unsub_start is not None:
self._unsub_start()
Expand Down
94 changes: 92 additions & 2 deletions custom_components/solisconnect/sensor_data/cloud_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,90 @@ def _tou_v2_mappings() -> list[CloudCidMapping]:
return mappings


# --- V1 TOU (Time-Charging), control CID 103 --------------------------------------------------
# SolisCloud represents the whole 3-slot Time-Charging schedule as a single comma-separated
# string of 18 values (3 slots x 6 fields: ChargeCurrent, DischargeCurrent, ChargeStart(HH:mm),
# ChargeEnd(HH:mm), DischargeStart(HH:mm), DischargeEnd(HH:mm)) via one control CID, unlike V2's
# one-CID-per-field model. SolisCloud models charge/discharge current *per slot*; Modbus only
# has one global register for each (43141/43142) shared by all 3 slots — decode takes slot 1's
# current as the representative Modbus value, and encode broadcasts the single Modbus value to
# all 3 cloud slots. Source: SolisCloud_control_api_command_list workbook, "Hybrid Inverter"
# sheet, row 6 (see WORKING_NOTES.md, "Plan: V1 TOU (Time-Charging) Dual-Protocol Support").
V1_TOU_CID = 103
_V1_TOU_SLOT_BASES = (43143, 43153, 43163)
V1_TOU_REGISTERS: tuple[int, ...] = (43141, 43142) + tuple(base + offset for base in _V1_TOU_SLOT_BASES for offset in range(8))

_HH_MM_RE = re.compile(r"^(\d{1,2}):(\d{1,2})$")


def _decode_hh_mm(text) -> tuple[int, int] | None:
match = _HH_MM_RE.match(str(text).strip())
if not match:
return None
hour, minute = int(match.group(1)), int(match.group(2))
if hour > 23 or minute > 59:
return None
return hour, minute


def _decode_v1_tou(msg: str) -> dict[int, int] | None:
parts = [p.strip() for p in str(msg).split(",")]
if len(parts) != 18:
return None

try:
charge_current = round(float(parts[0]))
discharge_current = round(float(parts[1]))
except ValueError:
return None

words: dict[int, int] = {43141: charge_current, 43142: discharge_current}
for slot, base in enumerate(_V1_TOU_SLOT_BASES):
time_fields = parts[slot * 6 + 2 : slot * 6 + 6]
for pair_index, text in enumerate(time_fields):
decoded = _decode_hh_mm(text)
if decoded is None:
return None
words[base + pair_index * 2] = decoded[0]
words[base + pair_index * 2 + 1] = decoded[1]
return words


def _encode_v1_tou(words: list[int]) -> str | None:
if len(words) != len(V1_TOU_REGISTERS):
return None
try:
charge_current, discharge_current = int(words[0]), int(words[1])
slot_words = [int(w) for w in words[2:]]
except (TypeError, ValueError):
return None

parts: list[str] = []
for slot in range(3):
hm = slot_words[slot * 8 : slot * 8 + 8]
if len(hm) != 8 or any(not (0 <= hm[i] <= (23 if i % 2 == 0 else 59)) for i in range(8)):
return None
charge_start, charge_end, discharge_start, discharge_end = (f"{hm[i]:02d}:{hm[i + 1]:02d}" for i in (0, 2, 4, 6))
parts.extend([str(charge_current), str(discharge_current), charge_start, charge_end, discharge_start, discharge_end])
return ",".join(parts)


_V1_TOU_MAPPING = CloudCidMapping(
registers=V1_TOU_REGISTERS,
cid=V1_TOU_CID,
to_registers=_decode_v1_tou,
to_cid_value=_encode_v1_tou,
)


# --- control CID <-> holding registers -------------------------------------------------------
_CLOUD_CID_MAPPINGS = [
CloudCidMapping(registers=(43110,), cid=636), # storage mode bitfield (== Modbus 43110)
CloudCidMapping(registers=(43024,), cid=157), # reserved / backup SOC %
CloudCidMapping(registers=(43011,), cid=158), # over-discharge SOC %
CloudCidMapping(registers=(43018,), cid=160), # force-charge SOC %
CloudCidMapping(registers=(43074,), cid=696, multiplier=100), # feed-in power limit (raw = W/100, cid msg = W)
_V1_TOU_MAPPING,
] + _tou_v2_mappings()

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

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


def cid_msg_from_words(mapping: CloudCidMapping, words: list[int]) -> str | None:
"""Convert raw register word(s) into the control-API value string (inverse of encode_cid_value)."""
Expand Down
5 changes: 3 additions & 2 deletions docs/source/protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ Current cloud write support is limited to verified CIDs and CID families:
| `5928-5933`, `5965`, `5969`, `5973`, `5977`, `5981`, `5984` | `43708`, `43715`, ... / `43750`, `43757`, ... | TOU V2 slot SOC values |
| `5947-5963`, `5966-5986` | `43709-43710`, ... / `43751-43752`, ... | TOU V2 slot current/voltage values |
| `5946`, `5949`, `5952`, `5955`, `5958`, `5961`, `5964`, `5968`, `5972`, `5976`, `5980`, `5987` | `43711-43714`, ... / `43753-43756`, ... | TOU V2 `HH:MM-HH:MM` time slots |
| `103` | `43141-43170` | V1 Time-Charging (18-value composite string; see `docs/source/tou.md`) |

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.

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

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.

**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.
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.

## Operational notes

Expand Down
Loading
Loading