Skip to content

Commit f5cf189

Browse files
committed
Fix cloud poll flip-flopping TOU values during the write-verify window (#11)
The regular poll cycle republished every mapped CID register on every cycle, including ones with an in-flight optimistic write awaiting its delayed verify. A poll landing inside that window overwrote the just-written value with stale cloud-side data, and the verify task could then flip it back again. SolisCloudController now tracks which registers have a pending write and CloudDataRetrieval skips republishing them until the verify completes. Fixes #11
1 parent d606919 commit f5cf189

4 files changed

Lines changed: 122 additions & 47 deletions

File tree

‎custom_components/solisconnect/cloud/cloud_controller.py‎

Lines changed: 61 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ def __init__(
6767
# Delayed write-verify tasks; cancelled on close_connection so they cannot
6868
# touch the cache after the entry is unloaded.
6969
self._verify_tasks: set[asyncio.Task] = set()
70+
# Registers with an optimistic write awaiting verify; CloudDataRetrieval skips
71+
# republishing these from its regular poll so a poll landing inside the verify
72+
# window can't overwrite the just-written value with stale cloud-side data.
73+
self._pending_write_registers: set[int] = set()
7074

7175
@property
7276
def host(self) -> str:
@@ -89,6 +93,10 @@ def last_modbus_success(self) -> datetime | None:
8993
"""Name kept for parity with ModbusController consumers (pseudo-register 90006)."""
9094
return self.last_cloud_success
9195

96+
def is_write_pending(self, register: int) -> bool:
97+
"""True while an optimistic write to this register is still awaiting its delayed verify."""
98+
return register in self._pending_write_registers
99+
92100
def _mapping_for_write(self, registers: list[int]) -> CloudCidMapping:
93101
mapping = REGISTER_TO_CID.get(registers[0])
94102
if mapping is None or list(mapping.registers) != registers:
@@ -134,6 +142,7 @@ async def _execute_cloud_write(self, mapping: CloudCidMapping, words: list[int])
134142
cache_save(self.hass, self, register, word)
135143
notify_register_update(self.hass, self, register, word)
136144

145+
self._pending_write_registers.update(mapping.registers)
137146
self._start_verify_task(self._verify_write(mapping, words))
138147

139148
async def _execute_tou_time_write(self, mapping: CloudCidMapping, write_registers: list[int], write_words: list[int]):
@@ -197,6 +206,7 @@ async def _execute_tou_switch_write(self, new_value: int):
197206
_LOGGER.info("SolisCloud write: TOU switch register %s = %s (was %s)", TOU_SWITCH_REGISTER, new_value, old_value)
198207
cache_save(self.hass, self, TOU_SWITCH_REGISTER, new_value)
199208
notify_register_update(self.hass, self, TOU_SWITCH_REGISTER, new_value)
209+
self._pending_write_registers.add(TOU_SWITCH_REGISTER)
200210
self._start_verify_task(self._verify_tou_switch_write(new_value))
201211

202212
async def _read_tou_switch_bitfield(self) -> int:
@@ -212,54 +222,60 @@ async def _read_tou_switch_bitfield(self) -> int:
212222

213223
async def _verify_write(self, mapping: CloudCidMapping, expected_words: list[int]):
214224
"""Re-read the CID after the cloud catches up; never leave an optimistic lie in the cache."""
215-
actual_words = None
216-
for _attempt in range(2):
217-
await asyncio.sleep(_VERIFY_DELAY_SECONDS)
218-
try:
219-
msg = await self.api.async_at_read(self.serial_number, mapping.cid)
220-
except SolisCloudApiError as error:
221-
_LOGGER.warning("Verify read of cid %s failed: %s", mapping.cid, error)
222-
continue
223-
decoded = encode_cid_value(mapping, msg)
224-
actual_words = [decoded[r] for r in mapping.registers] if decoded else None
225-
if actual_words == expected_words:
226-
_LOGGER.debug("SolisCloud write of cid %s verified", mapping.cid)
227-
return
228-
229-
if actual_words is not None and actual_words != expected_words:
230-
_LOGGER.warning(
231-
"SolisCloud write of cid %s did not stick: wrote %s, device reports %s — restoring device value",
232-
mapping.cid,
233-
expected_words,
234-
actual_words,
235-
)
236-
for register, word in zip(mapping.registers, actual_words):
237-
cache_save(self.hass, self, register, word)
238-
notify_register_update(self.hass, self, register, word)
225+
try:
226+
actual_words = None
227+
for _attempt in range(2):
228+
await asyncio.sleep(_VERIFY_DELAY_SECONDS)
229+
try:
230+
msg = await self.api.async_at_read(self.serial_number, mapping.cid)
231+
except SolisCloudApiError as error:
232+
_LOGGER.warning("Verify read of cid %s failed: %s", mapping.cid, error)
233+
continue
234+
decoded = encode_cid_value(mapping, msg)
235+
actual_words = [decoded[r] for r in mapping.registers] if decoded else None
236+
if actual_words == expected_words:
237+
_LOGGER.debug("SolisCloud write of cid %s verified", mapping.cid)
238+
return
239+
240+
if actual_words is not None and actual_words != expected_words:
241+
_LOGGER.warning(
242+
"SolisCloud write of cid %s did not stick: wrote %s, device reports %s — restoring device value",
243+
mapping.cid,
244+
expected_words,
245+
actual_words,
246+
)
247+
for register, word in zip(mapping.registers, actual_words):
248+
cache_save(self.hass, self, register, word)
249+
notify_register_update(self.hass, self, register, word)
250+
finally:
251+
self._pending_write_registers.difference_update(mapping.registers)
239252

240253
async def _verify_tou_switch_write(self, expected_value: int):
241254
"""Verify the composed TOU switch bitfield by reading the twelve switch CIDs."""
242-
actual_value = None
243-
for _attempt in range(2):
244-
await asyncio.sleep(_VERIFY_DELAY_SECONDS)
245-
try:
246-
actual_value = await self._read_tou_switch_bitfield()
247-
except SolisCloudApiError as error:
248-
_LOGGER.warning("Verify read of TOU switch CIDs failed: %s", error)
249-
continue
250-
if actual_value == expected_value:
251-
_LOGGER.debug("SolisCloud write of TOU switch register %s verified", TOU_SWITCH_REGISTER)
252-
return
253-
254-
if actual_value is not None and actual_value != expected_value:
255-
_LOGGER.warning(
256-
"SolisCloud write of TOU switch register %s did not stick: wrote %s, device reports %s — restoring device value",
257-
TOU_SWITCH_REGISTER,
258-
expected_value,
259-
actual_value,
260-
)
261-
cache_save(self.hass, self, TOU_SWITCH_REGISTER, actual_value)
262-
notify_register_update(self.hass, self, TOU_SWITCH_REGISTER, actual_value)
255+
try:
256+
actual_value = None
257+
for _attempt in range(2):
258+
await asyncio.sleep(_VERIFY_DELAY_SECONDS)
259+
try:
260+
actual_value = await self._read_tou_switch_bitfield()
261+
except SolisCloudApiError as error:
262+
_LOGGER.warning("Verify read of TOU switch CIDs failed: %s", error)
263+
continue
264+
if actual_value == expected_value:
265+
_LOGGER.debug("SolisCloud write of TOU switch register %s verified", TOU_SWITCH_REGISTER)
266+
return
267+
268+
if actual_value is not None and actual_value != expected_value:
269+
_LOGGER.warning(
270+
"SolisCloud write of TOU switch register %s did not stick: wrote %s, device reports %s — restoring device value",
271+
TOU_SWITCH_REGISTER,
272+
expected_value,
273+
actual_value,
274+
)
275+
cache_save(self.hass, self, TOU_SWITCH_REGISTER, actual_value)
276+
notify_register_update(self.hass, self, TOU_SWITCH_REGISTER, actual_value)
277+
finally:
278+
self._pending_write_registers.discard(TOU_SWITCH_REGISTER)
263279

264280
async def async_read_holding_register(self, register, count=1):
265281
"""Read from the cloud-backed cache, with a live TOU switch read for cold-cache bit toggles."""

‎custom_components/solisconnect/cloud/cloud_retrieval.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,14 @@ async def _update_cycle(self, now=None):
104104
continue # the API returns extra related CIDs beyond those requested
105105
if mapping.merge_register_bit is not None:
106106
continue # TOU V2 switch bits are merged together below, not per-CID
107+
if any(controller.is_write_pending(register) for register in mapping.registers):
108+
continue # an optimistic write is still awaiting its delayed verify; don't stomp on it
107109
words = encode_cid_value(mapping, msg)
108110
if words:
109111
self._publish_words(words)
110112
cid_words += len(words)
111113

112-
switch_words = self._merge_switch_bits(batch)
114+
switch_words = None if controller.is_write_pending(TOU_SWITCH_REGISTER) else self._merge_switch_bits(batch)
113115
if switch_words:
114116
self._publish_words(switch_words)
115117
cid_words += len(switch_words)

‎docs/source/protocols.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Current cloud write support is limited to verified CIDs and CID families:
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 |
3333

34-
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.
34+
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.
3535

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

‎tests/test_cloud_writes.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,34 @@ async def test_verify_mismatch_restores_device_truth(hass, controller):
9494
assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43110"] == 35 # device truth restored
9595

9696

97+
async def test_write_marks_pending_until_verify_completes(hass, controller):
98+
"""Bug #11: a register must stay marked pending for the whole verify window, then clear."""
99+
with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task):
100+
await controller.async_write_holding_register(43110, 33)
101+
102+
assert controller.is_write_pending(43110) is True # verify task not run yet (closed instead)
103+
104+
controller.api.async_at_read = AsyncMock(return_value="33")
105+
with patch("custom_components.solisconnect.cloud.cloud_controller.asyncio.sleep", new=AsyncMock()):
106+
await controller._verify_write(CLOUD_CID_MAP[636], [33])
107+
108+
assert controller.is_write_pending(43110) is False
109+
110+
111+
async def test_tou_switch_write_marks_pending_until_verify_completes(hass, controller):
112+
controller.api.async_at_read_batch = AsyncMock(return_value={5916: "0", 5917: "0"}) # cold-cache bitfield read
113+
with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task):
114+
await controller.async_write_holding_register(43707, 0b0101)
115+
116+
assert controller.is_write_pending(43707) is True
117+
118+
controller.api.async_at_read_batch = AsyncMock(return_value={5916: "1", 5918: "1"})
119+
with patch("custom_components.solisconnect.cloud.cloud_controller.asyncio.sleep", new=AsyncMock()):
120+
await controller._verify_tou_switch_write(0b0101)
121+
122+
assert controller.is_write_pending(43707) is False
123+
124+
97125
async def test_multi_register_write_without_mapping_raises(controller):
98126
with pytest.raises(HomeAssistantError, match="no control CID mapping"):
99127
await controller.async_write_holding_registers(43143, [10, 30])
@@ -209,3 +237,32 @@ def test_warm_cache_partial_batch_only_touches_observed_bits(self, hass, retriev
209237
hass.data[DOMAIN][VALUES][f"{SERIAL}|43707"] = 0b0110 # bits 1 and 2 set
210238
batch = {5916: "1"} # only bit 0 observed this cycle
211239
assert retrieval._merge_switch_bits(batch) == {43707: 0b0111}
240+
241+
242+
async def test_poll_does_not_overwrite_register_with_pending_write(hass, controller):
243+
"""Bug #11: a poll landing inside the write-verify window must not stomp the optimistic value."""
244+
hass.data[DOMAIN][VALUES][f"{SERIAL}|43110"] = 33 # optimistic write already applied
245+
controller._pending_write_registers.add(43110)
246+
controller.inverter_id = "id1"
247+
controller.api.async_inverter_detail = AsyncMock(return_value={})
248+
controller.api.async_at_read_batch = AsyncMock(return_value={636: "99"}) # stale cloud-side value
249+
250+
with patch.object(hass, "async_create_task", side_effect=_close_background_task):
251+
retrieval = CloudDataRetrieval(hass=hass, controller=controller)
252+
await retrieval._update_cycle()
253+
254+
assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43110"] == 33
255+
256+
257+
async def test_poll_does_not_overwrite_tou_switch_register_with_pending_write(hass, controller):
258+
hass.data[DOMAIN][VALUES][f"{SERIAL}|43707"] = 0b0101
259+
controller._pending_write_registers.add(43707)
260+
controller.inverter_id = "id1"
261+
controller.api.async_inverter_detail = AsyncMock(return_value={})
262+
controller.api.async_at_read_batch = AsyncMock(return_value={5916: "1", 5917: "1", 5918: "1"})
263+
264+
with patch.object(hass, "async_create_task", side_effect=_close_background_task):
265+
retrieval = CloudDataRetrieval(hass=hass, controller=controller)
266+
await retrieval._update_cycle()
267+
268+
assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43707"] == 0b0101

0 commit comments

Comments
 (0)