diff --git a/custom_components/solisconnect/cloud/cloud_controller.py b/custom_components/solisconnect/cloud/cloud_controller.py index 01e0540..98f6508 100644 --- a/custom_components/solisconnect/cloud/cloud_controller.py +++ b/custom_components/solisconnect/cloud/cloud_controller.py @@ -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. @@ -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]) @@ -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") diff --git a/custom_components/solisconnect/cloud/cloud_retrieval.py b/custom_components/solisconnect/cloud/cloud_retrieval.py index b43f3b6..801252d 100644 --- a/custom_components/solisconnect/cloud/cloud_retrieval.py +++ b/custom_components/solisconnect/cloud/cloud_retrieval.py @@ -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, @@ -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)) @@ -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() diff --git a/custom_components/solisconnect/sensor_data/cloud_mapping.py b/custom_components/solisconnect/sensor_data/cloud_mapping.py index 87458e5..573939c 100644 --- a/custom_components/solisconnect/sensor_data/cloud_mapping.py +++ b/custom_components/solisconnect/sensor_data/cloud_mapping.py @@ -337,6 +337,82 @@ 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) @@ -344,6 +420,7 @@ def _tou_v2_mappings() -> list[CloudCidMapping]: 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} @@ -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] = { @@ -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).""" diff --git a/docs/source/protocols.md b/docs/source/protocols.md index 9dfa098..edc2be0 100644 --- a/docs/source/protocols.md +++ b/docs/source/protocols.md @@ -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 diff --git a/docs/source/tou.md b/docs/source/tou.md index af51735..6bf633a 100644 --- a/docs/source/tou.md +++ b/docs/source/tou.md @@ -15,8 +15,8 @@ The config flow's "V2 support" toggle is auto-detected from the inverter's HMI f | Target SOC | Not available | Per-slot (register offset `+0` in each slot block) | | Cut-off voltage | Not available | Per-slot (register offset `+2` in each slot block) | | Modbus registers | `43141-43170` | `43707-43791` | -| SolisCloud support | **Planned, not yet implemented** — control CID `103` exists and covers exactly the 3 vendor-documented slots (see below) | Mapped: control CIDs `5916-5987` | -| SolisCloud enablement gate | N/A | CID `6798` (`0xAA55`) reports whether the inverter supports V2; SolisConnect does not yet enforce this gate (see caveat below) | +| SolisCloud support | Mapped: control CID `103` (see below) | Mapped: control CIDs `5916-5987` | +| SolisCloud enablement gate | N/A | CID `6798` (`0xAA55`) reports whether the inverter supports V2; SolisConnect reads this once at first poll in cloud-only mode and disables the V2 entities if unsupported (see caveat below) | ## Time-Charging (V1) @@ -28,7 +28,9 @@ The older scheduling mechanism. One shared charge current and one shared dischar - Charge Start / Charge End - Discharge Start / Discharge End - Register layout: slot 1 starts at `43143`, slot 2 at `43153`, slot 3 at `43163` (each slot block is 8 registers of data plus a 2-register reserved gap). -- **SolisCloud mapping is planned but not yet implemented.** SolisCloud control CID `103` ("Charge and discharge Settings") can read/write V1 TOU: a single comma-separated string of 18 values covering all 3 slots, with **per-slot** charge/discharge current — unlike Modbus, where current is one pair shared by all slots. Field order per slot: `ChargeCurrent, DischargeCurrent, ChargeStart(HH:mm), ChargeEnd(HH:mm), DischargeStart(HH:mm), DischargeEnd(HH:mm)`. Source: `SolisCloud_control_api_command_list` workbook, "Hybrid Inverter" sheet. See the project's working notes for the full implementation plan. +- **SolisCloud mapping** (`sensor_data/cloud_mapping.py`, control CID `103`, "Charge and discharge Settings"): a single comma-separated string of 18 values covering all 3 slots, with **per-slot** charge/discharge current — unlike Modbus, where current is one pair shared by all slots. Field order per slot: `ChargeCurrent, DischargeCurrent, ChargeStart(HH:mm), ChargeEnd(HH:mm), DischargeStart(HH:mm), DischargeEnd(HH:mm)`. Source: `SolisCloud_control_api_command_list` workbook, "Hybrid Inverter" sheet. + - Since Modbus has no per-slot current, decoding a cloud read takes **slot 1's** current values as the representative Modbus value, and a Modbus-side current write is broadcast uniformly to all 3 cloud slots. A single-register (current) or paired-register (one time field) write reads the full current 26-register state (from cache, or `atRead(cid=103)` if the cache is incomplete), splices in the change, and writes the whole recomposed string back — the same pattern already used for TOU V2's time-pair writes (`_execute_tou_time_write`, reused here rather than duplicated). + - **Not yet live-verified on a real inverter.** The mapping and write path are implemented and unit-tested against the worked example from the CID `103` spec, but several assumptions remain unconfirmed on real hardware: whether CID `103` is supported on non-"Hybrid Inverter" product lines, whether a partial write silently resets untouched fields, and whether broadcasting one Modbus current value to all 3 cloud slots is accepted by the device if it already has genuinely different per-slot currents set. Use the established safe live-write pattern (read original → write test value → verify → revert) before relying on this in production. - The official Solis RS485_MODBUS Hybrid Inverter protocol document only documents slot 1 (`43141-43150`); slots 2-3 (`43153`, `43163`) are undocumented in that particular revision but independently confirmed by SolisCloud control CID `103`, which covers exactly these 3 slots and no more. (An earlier version of SolisConnect also defined a "Slot 4" and "Slot 5" at `43173-43190`; these fell entirely inside the vendor's documented `Reserved` range with no confirmation from any source and have been removed.) ## Grid Time of Use V2 @@ -47,10 +49,10 @@ The newer, richer scheduling mechanism with independent settings per slot. - Battery current -> CIDs `5948-5963` family (charge), `5967-5986` family (discharge). - Cut-off voltage -> the paired CID in the same current/voltage CID families. - Start/end time pairs -> CIDs `5946, 5949, 5952, 5955, 5958, 5961` (charge), `5964, 5968, 5972, 5976, 5980, 5987` (discharge); the cloud API represents each pair as a single `HH:MM-HH:MM` string, which SolisConnect composes from/decomposes into the paired hour/minute registers. -- **Known caveat:** SolisCloud CID `6798` reports whether the connected inverter actually supports TOU V2 (value `43605` / `0xAA55` = supported). SolisConnect does not yet read this gate, so cloud-only or dual-protocol entries on a V2-unsupported inverter may have TOU V2 writes rejected by the device with no advance warning from the integration. +- **Enablement gate:** SolisCloud CID `6798` reports whether the connected inverter actually supports TOU V2 (value `43605` / `0xAA55` = supported). In cloud-only mode, `CloudDataRetrieval` reads this once at first poll and — if the value is anything else — disables every sensor covering registers `43707-43791`, the same treatment already given to registers with no cloud mapping at all (`_disable_unmapped_sensors`). A failed or undeterminable read never disables anything (no guessing). This check does not run in dual-protocol modes, since Modbus can still serve these registers there regardless of what the cloud gate reports. ## Practical implications - V1 and V2 are independent schedules on the inverter itself; enabling one does not disable the other, and having both enabled is a device-level conflict outside SolisConnect's control — check your inverter's own firmware behavior before enabling both. - If you only have local Modbus, either scheme is fully readable and writable. -- If you are on SolisCloud-only or a dual-protocol setup where cloud may become active, only V2 is safe to rely on for control; V1 entities will read/write successfully over Modbus but will not fail over to cloud (there is no cloud mapping to fall back to) and will simply stop updating if cloud becomes the active protocol. +- If you are on SolisCloud-only or a dual-protocol setup where cloud may become active, both V1 and V2 now have cloud mappings; V1's cloud write path is implemented but not yet live-verified (see above) — prefer V2 for cloud-active setups until V1's cloud path has had a live test pass. diff --git a/tests/test_cloud_integration.py b/tests/test_cloud_integration.py index 3fbc2ae..aaf2fda 100644 --- a/tests/test_cloud_integration.py +++ b/tests/test_cloud_integration.py @@ -115,6 +115,12 @@ def _patched_api(): "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_inverter_list", new=AsyncMock(return_value=[{"id": "CLOUDID1", "sn": SERIAL}]), ), + # CID 6798 (TOU V2 support gate): report "supported" so existing tests' TOU V2 + # sensors aren't disabled by the new issue #16 gate check. + patch( + "custom_components.solisconnect.cloud.api_client.SolisCloudApiClient.async_at_read", + new=AsyncMock(return_value="43605"), + ), ) @@ -122,8 +128,8 @@ async def test_cloud_only_setup_populates_cache_with_raw_words(hass: HomeAssista entry = _cloud_entry() entry.add_to_hass(hass) - detail_patch, batch_patch, list_patch = _patched_api() - with detail_patch, batch_patch, list_patch: + detail_patch, batch_patch, list_patch, at_read_patch = _patched_api() + with detail_patch, batch_patch, list_patch, at_read_patch: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -168,8 +174,8 @@ async def test_cloud_only_entities_update_through_unchanged_classes(hass: HomeAs entry = _cloud_entry() entry.add_to_hass(hass) - detail_patch, batch_patch, list_patch = _patched_api() - with detail_patch, batch_patch, list_patch: + detail_patch, batch_patch, list_patch, at_read_patch = _patched_api() + with detail_patch, batch_patch, list_patch, at_read_patch: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -196,8 +202,8 @@ async def test_cloud_write_unmapped_register_raises(hass: HomeAssistant): entry = _cloud_entry() entry.add_to_hass(hass) - detail_patch, batch_patch, list_patch = _patched_api() - with detail_patch, batch_patch, list_patch: + detail_patch, batch_patch, list_patch, at_read_patch = _patched_api() + with detail_patch, batch_patch, list_patch, at_read_patch: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/test_cloud_mapping.py b/tests/test_cloud_mapping.py index 426a2fa..c0d492f 100644 --- a/tests/test_cloud_mapping.py +++ b/tests/test_cloud_mapping.py @@ -16,6 +16,9 @@ CLOUD_INPUT_MAP, TOU_SWITCH_CIDS_BY_BIT, TOU_TIME_PAIR_TO_CID, + TOU_V2_REGISTERS, + V1_TOU_CID, + V1_TOU_REGISTERS, CloudFieldMapping, cid_msg_from_words, encode_cid_value, @@ -275,4 +278,61 @@ def test_registers_covered_includes_inputs_and_cids(): assert 43707 in covered # TOU V2 switch bitfield assert 43711 in covered # TOU V2 time slot registers assert 43791 in covered # TOU V2 final discharge end minute + assert 43141 in covered # V1 TOU global charge current + assert 43170 in covered # V1 TOU slot 3 discharge end minute assert 33022 not in covered # RTC deliberately unmapped + + +def test_v1_tou_registers_span_26_registers(): + assert len(V1_TOU_REGISTERS) == 26 + assert 43141 in V1_TOU_REGISTERS and 43142 in V1_TOU_REGISTERS + assert 43143 in V1_TOU_REGISTERS and 43170 in V1_TOU_REGISTERS + assert 43151 not in V1_TOU_REGISTERS # reserved gap between slot 1 and slot 2 + assert TOU_V2_REGISTERS.isdisjoint(V1_TOU_REGISTERS) + + +def test_v1_tou_decodes_worked_example_from_issue(): + """Worked example from issue #19: slot 3's charge current (99) differs from slots 1-2 + (100), confirming per-slot current in the cloud model; Modbus only has one global + register for each, so slot 1's values are taken as the representative Modbus value.""" + msg = "100,100,09:44,09:44,09:44,09:44,100,100,09:44,09:44,09:44,09:44,99,100,09:44,09:44,09:44,09:44" + words = encode_cid_value(CLOUD_CID_MAP[V1_TOU_CID], msg) + assert words[43141] == 100 # charge current from slot 1 + assert words[43142] == 100 # discharge current from slot 1 + for base in (43143, 43153, 43163): + assert words[base] == 9 and words[base + 1] == 44 # charge start + assert words[base + 2] == 9 and words[base + 3] == 44 # charge end + assert words[base + 4] == 9 and words[base + 5] == 44 # discharge start + assert words[base + 6] == 9 and words[base + 7] == 44 # discharge end + + +def test_v1_tou_encodes_registers_broadcasting_current_to_all_slots(): + """Modbus has no per-slot current, so the single value is broadcast to all 3 cloud slots.""" + mapping = CLOUD_CID_MAP[V1_TOU_CID] + words = [0] * len(mapping.registers) + words[0], words[1] = 100, 50 # charge, discharge current + for i in range(2, 26): + words[i] = 9 if (i - 2) % 2 == 0 else 44 + + msg = cid_msg_from_words(mapping, words) + parts = msg.split(",") + assert len(parts) == 18 + assert parts[0] == parts[6] == parts[12] == "100" + assert parts[1] == parts[7] == parts[13] == "50" + assert parts[2] == "09:44" + + +def test_v1_tou_rejects_wrong_value_count(): + assert encode_cid_value(CLOUD_CID_MAP[V1_TOU_CID], "1,2,3") is None + + +def test_v1_tou_rejects_invalid_time_field(): + parts = ["100", "100", "25:00", "09:44", "09:44", "09:44"] + ["100", "100", "09:44", "09:44", "09:44", "09:44"] * 2 + assert encode_cid_value(CLOUD_CID_MAP[V1_TOU_CID], ",".join(parts)) is None + + +def test_v1_tou_encode_rejects_out_of_range_hour(): + mapping = CLOUD_CID_MAP[V1_TOU_CID] + words = [0] * len(mapping.registers) + words[2] = 24 # slot 1 charge-start hour out of range + assert cid_msg_from_words(mapping, words) is None diff --git a/tests/test_cloud_writes.py b/tests/test_cloud_writes.py index 67b47e4..80cc823 100644 --- a/tests/test_cloud_writes.py +++ b/tests/test_cloud_writes.py @@ -123,8 +123,9 @@ async def test_tou_switch_write_marks_pending_until_verify_completes(hass, contr async def test_multi_register_write_without_mapping_raises(controller): + """43143/43144 are now mapped (V1 TOU, issue #19); use a genuinely unmapped register pair.""" with pytest.raises(HomeAssistantError, match="no control CID mapping"): - await controller.async_write_holding_registers(43143, [10, 30]) + await controller.async_write_holding_registers(43200, [10, 30]) async def test_tou_time_pair_write_composes_full_slot_from_cache(hass, controller): @@ -153,6 +154,66 @@ async def test_tou_time_pair_write_composes_full_slot_from_cid_read(hass, contro controller.api.async_control.assert_awaited_once_with(SERIAL, 5946, "09:00-11:45", old_value="09:00-10:30") +def _seed_v1_tou_cache(hass, charge_current=50, discharge_current=40, hour=6, minute=0): + from custom_components.solisconnect.sensor_data.cloud_mapping import V1_TOU_REGISTERS + + values = {43141: charge_current, 43142: discharge_current} + for base in (43143, 43153, 43163): + for offset in range(0, 8, 2): + values[base + offset] = hour + values[base + offset + 1] = minute + for register in V1_TOU_REGISTERS: + hass.data[DOMAIN][VALUES][f"{SERIAL}|{register}"] = values[register] + + +async def test_v1_tou_current_write_broadcasts_to_all_slots(hass, controller): + """A single global-current write must compose a CID 103 value with that current applied + uniformly across all 3 cloud slots (Modbus has no per-slot current), issue #19.""" + _seed_v1_tou_cache(hass) + + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): + await controller.async_write_holding_register(43141, 100) + + args, _kwargs = controller.api.async_control.await_args + assert args[1] == 103 + parts = args[2].split(",") + assert len(parts) == 18 + assert parts[0] == parts[6] == parts[12] == "100" # new charge current, all slots + assert parts[1] == parts[7] == parts[13] == "40" # discharge current unchanged + assert parts[2] == "06:00" # times preserved + assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43141"] == 100 + + +async def test_v1_tou_time_field_write_preserves_other_registers(hass, controller): + _seed_v1_tou_cache(hass) + + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): + await controller.async_write_holding_registers(43143, [7, 30]) # slot 1 charge-start -> 07:30 + + args, _kwargs = controller.api.async_control.await_args + parts = args[2].split(",") + assert parts[0] == "50" and parts[1] == "40" # currents unchanged + assert parts[2] == "07:30" # slot 1 charge start updated + assert parts[3] == "06:00" # slot 1 charge end unchanged + assert parts[8] == "06:00" # slot 2 charge start unchanged + + +async def test_v1_tou_write_composes_from_cid_read_when_cache_incomplete(hass, controller): + full_msg = "50,40,06:00,06:00,06:00,06:00,50,40,06:00,06:00,06:00,06:00,50,40,06:00,06:00,06:00,06:00" + controller.api.async_at_read = AsyncMock(return_value=full_msg) + + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): + await controller.async_write_holding_register(43142, 45) # discharge current only + + # async_at_read(cid=103) is called both to compose the current state and, separately, + # to fetch the pre-write old_value for yuanzhi -- so it's called more than once here. + controller.api.async_at_read.assert_any_await(SERIAL, 103) + args, _kwargs = controller.api.async_control.await_args + parts = args[2].split(",") + assert parts[1] == parts[7] == parts[13] == "45" + assert parts[0] == "50" + + async def test_tou_switch_write_sends_changed_cid_with_old_bitfield(hass, controller): hass.data[DOMAIN][VALUES][f"{SERIAL}|43707"] = 0b0011 @@ -266,3 +327,60 @@ async def test_poll_does_not_overwrite_tou_switch_register_with_pending_write(ha await retrieval._update_cycle() assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43707"] == 0b0101 + + +def _mock_group(sensors): + group = MagicMock() + group.sensors = sensors + return group + + +def _mock_sensor_entry(registrars, name="s", enabled=True): + m = MagicMock() + m.registrars = registrars + m.name = name + m.enabled = enabled + return m + + +class TestV2EnablementGate: + """CID 6798 TOU V2 support gate, issue #16.""" + + async def test_disables_v2_sensors_when_cid_reports_unsupported(self, hass, controller): + v2_sensor = _mock_sensor_entry([43708], name="v2_slot1_soc") + other_sensor = _mock_sensor_entry([43110], name="storage_mode") + controller._sensor_groups = [_mock_group([v2_sensor, other_sensor])] + controller.api.async_at_read = AsyncMock(return_value="0") # not the supported value + + with patch.object(hass, "async_create_task", side_effect=_close_background_task): + retrieval = CloudDataRetrieval(hass=hass, controller=controller, disable_unmapped=True) + with patch("custom_components.solisconnect.cloud.cloud_retrieval.mark_platform_entities_unavailable_for_base_sensors") as mock_mark: + await retrieval._disable_v2_if_unsupported() + + controller.api.async_at_read.assert_awaited_once_with(SERIAL, 6798) + assert v2_sensor.enabled is False + assert other_sensor.enabled is True # unrelated sensors are untouched + mock_mark.assert_called_once_with(hass, [v2_sensor]) + + async def test_leaves_v2_sensors_enabled_when_cid_reports_supported(self, hass, controller): + v2_sensor = _mock_sensor_entry([43708], name="v2_slot1_soc") + controller._sensor_groups = [_mock_group([v2_sensor])] + controller.api.async_at_read = AsyncMock(return_value="43605") + + with patch.object(hass, "async_create_task", side_effect=_close_background_task): + retrieval = CloudDataRetrieval(hass=hass, controller=controller, disable_unmapped=True) + await retrieval._disable_v2_if_unsupported() + + assert v2_sensor.enabled is True + + async def test_gate_read_failure_leaves_sensors_unchanged(self, hass, controller): + """A failed/undeterminable read must never disable sensors on a guess.""" + v2_sensor = _mock_sensor_entry([43708], name="v2_slot1_soc") + controller._sensor_groups = [_mock_group([v2_sensor])] + controller.api.async_at_read = AsyncMock(side_effect=SolisCloudApiError("boom")) + + with patch.object(hass, "async_create_task", side_effect=_close_background_task): + retrieval = CloudDataRetrieval(hass=hass, controller=controller, disable_unmapped=True) + await retrieval._disable_v2_if_unsupported() + + assert v2_sensor.enabled is True