Skip to content

Commit 6c46c32

Browse files
author
Maksym Shtembuliak
committed
fix: harden delayed write confirmation
1 parent eb54249 commit 6c46c32

10 files changed

Lines changed: 955 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ the GitHub release body should be rendered from the matching version section her
3333

3434
### Fixed
3535

36+
- Renamed the four optional PI30 Q1 temperature channels to neutral numbered
37+
names. Clone manufacturers can wire the same protocol fields to different
38+
physical components, so the integration no longer presents an inferred
39+
tracker, inverter, battery, or transformer location as a confirmed fact.
40+
- Fixed SMG-family setting confirmation when firmware applies a write after a
41+
short delay. The integration now records the exact-register read-back and
42+
subsequent full-poll convergence, never exposes the requested value as if it
43+
had already been observed, and includes the bounded trace in Support
44+
Archives for field diagnosis.
3645
- Fixed stale generic-SMG projections that could survive a stronger runtime
3746
fingerprint and hide the correct model-specific telemetry surface.
3847
- Added typed per-route probe evidence so support diagnostics can distinguish

‎custom_components/eybond_local/drivers/smg.py‎

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@
5656
find_capability as _find_capability,
5757
merge_capability_register_word,
5858
)
59+
from .write_confirmation import (
60+
load_write_confirmation,
61+
start_write_confirmation,
62+
store_write_confirmation,
63+
write_confirmation_diagnostics,
64+
)
5965
from .catalog_identity import (
6066
CatalogIdentityProbe,
6167
InverterIdentityNoDataError,
@@ -557,8 +563,18 @@ async def async_read_values(
557563
extra_blocks = await _read_out_of_block_capability_registers(
558564
session, inverter.capabilities, polled_blocks, already_read=aux_registers
559565
)
560-
_apply_capability_read_back(values, inverter.capabilities, polled_blocks + extra_blocks)
561-
return DriverReadResult(values=values, mode=DriverReadMode.FULL)
566+
all_polled_blocks = polled_blocks + extra_blocks
567+
_apply_capability_read_back(values, inverter.capabilities, all_polled_blocks)
568+
diagnostics = _observe_capability_write_full_poll(
569+
runtime_state,
570+
inverter.capabilities,
571+
all_polled_blocks,
572+
)
573+
return DriverReadResult(
574+
values=values,
575+
mode=DriverReadMode.FULL,
576+
diagnostics=diagnostics,
577+
)
562578

563579
async def async_write_capability(
564580
self,
@@ -571,6 +587,7 @@ async def async_write_capability(
571587
) -> Any:
572588
capability = _find_capability(capability_key, inverter.capabilities or self.write_capabilities)
573589
raw_words = _encode_capability_words(capability, value)
590+
native_value = _decode_capability_value(capability, raw_words)
574591

575592
session = self._session(transport, inverter.probe_target)
576593
if capability.bitmask:
@@ -602,8 +619,49 @@ async def async_write_capability(
602619
else:
603620
await session.write_holding(capability.register, raw_words)
604621

605-
native_value = _decode_capability_value(capability, raw_words)
606-
inverter.details[capability.key] = native_value
622+
# A Modbus write acknowledgement proves only that the request completed
623+
# on the wire. For setting-like capabilities, observe the exact target
624+
# register immediately as a separate fact. A mismatch is retained for
625+
# diagnostics but is not declared final here: some SMG firmwares apply
626+
# settings asynchronously, and the hub's mandatory full refresh remains
627+
# the user-visible confirmation authority.
628+
if capability.value_kind != "action":
629+
trace = start_write_confirmation(
630+
runtime_state,
631+
capability_key=capability.key,
632+
value_key=capability.value_key,
633+
requested_value=value,
634+
expected_value=native_value,
635+
requested_words=tuple(int(word) for word in raw_words),
636+
)
637+
try:
638+
observed_words = tuple(
639+
int(word)
640+
for word in await session.read_holding(
641+
capability.register,
642+
capability.word_count,
643+
)
644+
)
645+
comparable_words = _comparable_capability_words(
646+
capability,
647+
observed_words,
648+
)
649+
observed_value = _decode_capability_value(
650+
capability,
651+
list(comparable_words),
652+
)
653+
trace = trace.with_immediate_observation(
654+
value=observed_value,
655+
words=comparable_words,
656+
matched=(comparable_words == trace.requested_words),
657+
)
658+
except Exception as exc: # the mandatory full poll may still confirm
659+
trace = trace.with_immediate_unavailable(error=exc)
660+
store_write_confirmation(runtime_state, trace)
661+
662+
# Never project a requested value through DetectedInverter.details.
663+
# That mapping is detection evidence, not runtime state. The immediate
664+
# exact read and subsequent full poll above are the only observations.
607665
return native_value
608666

609667
async def async_capture_support_evidence(
@@ -835,6 +893,93 @@ def _apply_capability_read_back(
835893
values[value_key] = raw_value
836894

837895

896+
def _observe_capability_write_full_poll(
897+
runtime_state: dict[str, Any] | None,
898+
capabilities,
899+
register_blocks: tuple[tuple[int, list[int]], ...],
900+
) -> dict[str, Any]:
901+
"""Fold one real SMG full poll into the retained write trace."""
902+
903+
trace = load_write_confirmation(runtime_state)
904+
if trace is None:
905+
return {}
906+
capability = next(
907+
(
908+
candidate
909+
for candidate in capabilities
910+
if getattr(candidate, "key", "") == trace.capability_key
911+
),
912+
None,
913+
)
914+
if capability is None:
915+
trace = trace.with_poll_observation(value=None, matched=None)
916+
store_write_confirmation(runtime_state, trace)
917+
return write_confirmation_diagnostics(runtime_state)
918+
919+
try:
920+
observed_words = _capability_words_from_blocks(
921+
capability,
922+
register_blocks,
923+
)
924+
comparable_words = _comparable_capability_words(
925+
capability,
926+
observed_words,
927+
)
928+
observed_value = _decode_capability_value(
929+
capability,
930+
list(comparable_words),
931+
)
932+
except Exception:
933+
trace = trace.with_poll_observation(value=None, matched=None)
934+
else:
935+
trace = trace.with_poll_observation(
936+
value=observed_value,
937+
matched=(comparable_words == trace.requested_words),
938+
)
939+
store_write_confirmation(runtime_state, trace)
940+
return write_confirmation_diagnostics(runtime_state)
941+
942+
943+
def _capability_words_from_blocks(
944+
capability: WriteCapability,
945+
register_blocks: tuple[tuple[int, list[int]], ...],
946+
) -> tuple[int, ...]:
947+
"""Read one capability's exact raw words from already-polled blocks."""
948+
949+
register_map: dict[int, int] = {}
950+
for start, block in register_blocks:
951+
for index, raw in enumerate(block):
952+
register_map[start + index] = int(raw)
953+
words: list[int] = []
954+
for offset in range(capability.word_count):
955+
register = capability.register + offset
956+
if register not in register_map:
957+
raise ValueError(
958+
f"capability_full_poll_readback_missing:{capability.key}:{register}"
959+
)
960+
words.append(register_map[register])
961+
return tuple(words)
962+
963+
964+
def _comparable_capability_words(
965+
capability: WriteCapability,
966+
observed_words: tuple[int, ...],
967+
) -> tuple[int, ...]:
968+
"""Return raw words in the same shape used by the capability encoder."""
969+
970+
if len(observed_words) != capability.word_count:
971+
raise ValueError(
972+
f"capability_post_write_read_length:{capability.key}:"
973+
f"expected={capability.word_count}:observed={len(observed_words)}"
974+
)
975+
if capability.bitmask:
976+
observed_field = (
977+
int(observed_words[0]) & capability.bitmask
978+
) >> capability.bitmask_shift
979+
return (observed_field,)
980+
return tuple(int(word) for word in observed_words)
981+
982+
838983
def _decode_block(
839984
start_register: int,
840985
values: list[int],

0 commit comments

Comments
 (0)