Skip to content

Commit 3464c0f

Browse files
author
Maksym Shtembuliak
committed
fix: preserve partial PI30 reads and reconcile cloud recovery
1 parent e5b4a2d commit 3464c0f

27 files changed

Lines changed: 1032 additions & 97 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,20 @@ the GitHub release body should be rendered from the matching version section her
8181

8282
### Fixed
8383

84+
- PI30 polling preserves successful replies when another command times out,
85+
returns NAK or has an invalid payload (#57). Required readings are carried
86+
for a bounded period and then withdrawn individually; their commands remain
87+
enabled. Diagnostics identify missed commands and sample age. Empty reads
88+
no longer advance runtime success time or confirm a recovered session.
89+
90+
- Collector connection recovery after cloud learning/proxy capture checks live
91+
endpoint and apply state before repeating any changes (#46). An already
92+
restored connection no longer needs another endpoint write. Recovery excludes
93+
background polling, checks uncertain write outcomes without resending them,
94+
and retains the recovery warning when application cannot be confirmed.
95+
Support diagnostics distinguish write stages and retain the last failure
96+
even after a successful verification read.
97+
8498
- Device learning keeps the original cloud API when the collector is temporarily
8599
redirected to Home Assistant. A local port or AT traffic cannot replace a
86100
known ValueCloud, SmartValue or SmartESS server with another provider (#46).

‎custom_components/eybond_local/collector/management.py‎

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def __init__(self, message: str, *, query_parameter: int | None = None) -> None:
111111
super().__init__(message)
112112
# Numeric request context only; never endpoint values or response text.
113113
self.query_parameter = query_parameter
114+
self.operation_phase = ""
114115

115116
@property
116117
def diagnostic_code(self) -> str:
@@ -120,27 +121,46 @@ def diagnostic_code(self) -> str:
120121
return type(self.__cause__).__name__
121122
# Directly constructed typed failures may already carry a class code.
122123
code = str(self)
123-
if code in {failure.__name__ for failure in _TRANSPORT_FAILURES}:
124+
if code in {
125+
"collector_management_session_changed",
126+
*(failure.__name__ for failure in _TRANSPORT_FAILURES),
127+
}:
124128
return code
125129
return type(self).__name__
126130

127131
@property
128132
def request_diagnostics(self) -> dict[str, object]:
129133
"""Project wire context here so runtime need not interpret parameters."""
130134

131-
if self.query_parameter is None:
132-
return {}
133-
return {
134-
"protocol": "eybond_framed",
135-
"function": 2,
136-
"parameter": self.query_parameter,
137-
}
135+
result: dict[str, object] = {}
136+
if self.query_parameter is not None:
137+
result.update(protocol="eybond_framed", function=2, parameter=self.query_parameter)
138+
if self.operation_phase:
139+
result["phase"] = self.operation_phase
140+
return result
138141

139142

140143
class CollectorManagementConfirmationError(CollectorManagementError):
141144
"""A management command was sent but the collector did not confirm it."""
142145

143146

147+
async def _endpoint_step(phase: str, request):
148+
"""Annotate compound-operation failures without leaking endpoint values."""
149+
150+
try:
151+
return await request
152+
except CollectorManagementTransportError as exc:
153+
exc.operation_phase = phase
154+
raise
155+
except CollectorManagementError:
156+
raise
157+
except Exception as exc:
158+
wrapped = _wrap_wire_call(exc)
159+
if isinstance(wrapped, CollectorManagementTransportError):
160+
wrapped.operation_phase = phase
161+
raise wrapped from exc
162+
163+
144164
# ---------------------------------------------------------------------------
145165
# Normalized models
146166
# ---------------------------------------------------------------------------
@@ -458,26 +478,29 @@ async def async_write_endpoint(
458478
self, endpoint: str, *, apply_changes: bool = True
459479
) -> CollectorEndpointWriteResult:
460480
session, _ = self._session()
461-
previous = await self._query_flag(session, SET_SERVER_ENDPOINT)
462-
try:
463-
set_response = await session.set_collector(SET_SERVER_ENDPOINT, endpoint)
464-
except CollectorManagementError:
465-
raise
466-
except Exception as exc: # noqa: BLE001
467-
raise _wrap_wire_call(exc) from exc
481+
previous = await _endpoint_step(
482+
"read_previous", self._query_flag(session, SET_SERVER_ENDPOINT),
483+
)
484+
set_response = await _endpoint_step(
485+
"write_endpoint", session.set_collector(SET_SERVER_ENDPOINT, endpoint),
486+
)
468487
if set_response.status != 0 or set_response.parameter != SET_SERVER_ENDPOINT:
469488
raise CollectorManagementConfirmationError(
470489
f"collector_set_unconfirmed:parameter={SET_SERVER_ENDPOINT}:"
471490
f"status={set_response.status}"
472491
)
473-
readback = await self._query_confirmed(session, SET_SERVER_ENDPOINT)
474-
reboot_required = await self._query_flag(session, QUERY_REBOOT_REQUIRED)
492+
readback = await _endpoint_step(
493+
"readback", self._query_confirmed(session, SET_SERVER_ENDPOINT),
494+
)
495+
reboot_required = await _endpoint_step(
496+
"read_apply_state", self._query_flag(session, QUERY_REBOOT_REQUIRED),
497+
)
475498
confirmation_source = _confirm_write(endpoint, readback, ack_source="set_ack")
476499

477500
apply_performed = False
478501
warnings: tuple[str, ...] = ()
479502
if apply_changes:
480-
await self._apply(session)
503+
await _endpoint_step("apply", self._apply(session))
481504
apply_performed = True
482505
warnings = (self._APPLY_WARNING,)
483506

@@ -741,20 +764,24 @@ async def async_write_endpoint(
741764
self, endpoint: str, *, apply_changes: bool = True
742765
) -> CollectorEndpointWriteResult:
743766
transport = self._resolve_transport(needs_write=True)
744-
previous = await self._query(transport, _AT_ENDPOINT_COMMAND)
767+
previous = await _endpoint_step(
768+
"read_previous", self._query(transport, _AT_ENDPOINT_COMMAND),
769+
)
745770
# CLDSRVHOST1 write ack: a W000 status OR a documented exact endpoint echo.
746771
# A Wxxx != W000 is NOT an ack (readback must then confirm).
747-
write_value = await self._send_write(transport, _AT_ENDPOINT_COMMAND, endpoint)
772+
write_value = await _endpoint_step(
773+
"write_endpoint", self._send_write(transport, _AT_ENDPOINT_COMMAND, endpoint),
774+
)
748775
write_ack = write_value == _AT_SUCCESS_STATUS or _endpoints_match(write_value, endpoint)
749-
readback = await self._query(transport, _AT_ENDPOINT_COMMAND)
776+
readback = await _endpoint_step("readback", self._query(transport, _AT_ENDPOINT_COMMAND))
750777
confirmation_source = _confirm_at_write(endpoint, readback, write_ack=write_ack)
751778

752779
warnings: tuple[str, ...] = ()
753780
extra: dict[str, object] = {}
754781
apply_performed = False
755782
if apply_changes:
756783
# An unconfirmed apply raises (never swallowed into a warning).
757-
extra["at_apply_response"] = await self._apply(transport)
784+
extra["at_apply_response"] = await _endpoint_step("apply", self._apply(transport))
758785
apply_performed = True
759786
warnings = (self._APPLY_WARNING,)
760787

‎custom_components/eybond_local/drivers/pi30.py‎

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
parse_qt_clock,
4141
parse_serial_number,
4242
q1_output_keys,
43+
qpigs_output_keys,
4344
qpiws_output_keys,
4445
)
4546
from ..poll_policy import PollPolicy
@@ -101,6 +102,12 @@ class Pi30CommandSpec:
101102
Pi30CommandSpec(command="Q1", parser=parse_q1, optional=True),
102103
)
103104

105+
# A transient miss can carry a prior runtime sample for at most two minutes,
106+
# or three configured poll intervals for deliberately slow polling. This is
107+
# runtime-only freshness bookkeeping, not a second measurement cache.
108+
_REQUIRED_SAMPLE_MAX_AGE_SECONDS = 120.0
109+
_REQUIRED_LAST_SUCCESS_KEY = "pi30_required_last_success"
110+
104111
# A minimal subset read only during onboarding to enrich the confirmation UI --
105112
# not a runtime cadence.
106113
_ONBOARDING_RUNTIME_COMMAND_SPECS: tuple[Pi30CommandSpec, ...] = (
@@ -612,13 +619,15 @@ def _pi30_command_output_keys() -> dict[str, frozenset[str]]:
612619
"""Command (stable cache key) -> the runtime value keys it owns.
613620
614621
Ownership lives here next to the specs/parsers (never in the hub): it maps
615-
each optional command to the direct parser keys AND any value derived from
616-
them, so a command that reaches a final unsupported verdict can invalidate
617-
exactly the keys it produced. Q1/QPIWS key sets are derived from the payload
622+
each command to the direct parser keys AND any value derived from them, so
623+
an expired sample or final unsupported verdict can invalidate exactly the
624+
keys it produced. QPIGS/Q1/QPIWS key sets are derived from the payload
618625
layouts so they cannot drift from the parsers.
619626
"""
620627

621628
return {
629+
"QPIGS": qpigs_output_keys(),
630+
"QMOD": frozenset({"operating_mode_code", "operating_mode"}),
622631
"QPIWS": qpiws_output_keys() | frozenset({"alarm_status"}),
623632
"Q1": q1_output_keys() | frozenset({"inverter_charge_state"}),
624633
"QET": frozenset({"pv_generation_sum"}),
@@ -719,6 +728,7 @@ async def _async_collect_values(
719728
runtime_state: dict[str, Any] | None = None,
720729
now_monotonic: float | None = None,
721730
command_timings: list[tuple[str, str, int, str]] | None = None,
731+
allow_partial: bool = False,
722732
) -> dict[str, Any]:
723733
values: dict[str, Any] = {}
724734

@@ -737,19 +747,61 @@ async def _async_collect_values(
737747
_record_pi30_command_stat(
738748
runtime_state, spec.command, spec.command, duration_ms, _command_outcome(exc), command_timings
739749
)
740-
if not spec.optional:
750+
if not spec.optional and not allow_partial:
741751
raise
742-
_record_command_failure(runtime_state, spec.command)
752+
if spec.optional:
753+
_record_command_failure(runtime_state, spec.command)
743754
else:
744755
duration_ms = int(round((time.monotonic() - started) * 1000.0))
745756
_record_pi30_command_stat(
746757
runtime_state, spec.command, spec.command, duration_ms, "ok", command_timings
747758
)
748759
_record_command_success(runtime_state, spec.command)
760+
if allow_partial and not spec.optional and runtime_state is not None:
761+
runtime_state.setdefault(_REQUIRED_LAST_SUCCESS_KEY, {})[spec.command] = (
762+
time.monotonic() if now_monotonic is None else now_monotonic
763+
)
749764

750765
return values
751766

752767

768+
def _required_sample_freshness(
769+
*,
770+
runtime_state: dict[str, Any] | None,
771+
command_timings: list[tuple[str, str, int, str]],
772+
now: float,
773+
poll_interval: float | None,
774+
) -> tuple[frozenset[str], dict[str, Any]]:
775+
"""Bound carried core values without disabling their required commands."""
776+
777+
max_age = max(_REQUIRED_SAMPLE_MAX_AGE_SECONDS, 3.0 * (poll_interval or 0.0))
778+
successful = {key for key, _wire, _ms, outcome in command_timings if outcome == "ok"}
779+
last_success = (runtime_state or {}).get(_REQUIRED_LAST_SUCCESS_KEY, {})
780+
output_keys = _pi30_command_output_keys()
781+
removed: set[str] = set()
782+
stale: list[str] = []
783+
expired: list[str] = []
784+
diagnostics: dict[str, Any] = {"pi30_required_value_max_age_seconds": max_age}
785+
for spec in _RUNTIME_COMMAND_SPECS:
786+
if spec.optional:
787+
continue
788+
sampled_at = last_success.get(spec.command)
789+
age = max(0.0, now - sampled_at) if sampled_at is not None else None
790+
if age is not None:
791+
diagnostics[f"pi30_{spec.command.lower()}_age_seconds"] = round(age, 3)
792+
if spec.command in successful:
793+
continue
794+
stale.append(spec.command)
795+
# No timestamp means this session has never confirmed that command.
796+
# Remove detection/previous-session values instead of granting a new TTL.
797+
if age is None or age >= max_age:
798+
expired.append(spec.command)
799+
removed.update(output_keys[spec.command])
800+
diagnostics["pi30_required_stale_commands"] = ", ".join(stale)
801+
diagnostics["pi30_required_expired_commands"] = ", ".join(expired)
802+
return frozenset(removed), diagnostics
803+
804+
753805
def _pi30_unreachable_commands(unsupported: frozenset[str]) -> frozenset[str]:
754806
"""Return the commands made unreachable by a finally-unsupported prerequisite.
755807
@@ -801,6 +853,9 @@ def _build_pi30_read_meta(
801853
"pi30_poll_succeeded": sum(1 for _k, _w, _ms, o in attempted if o == "ok"),
802854
"pi30_poll_timeout": sum(1 for _k, _w, _ms, o in attempted if o == "timeout"),
803855
"pi30_poll_error": sum(1 for _k, _w, _ms, o in attempted if o == "error"),
856+
"pi30_poll_failed_commands": ", ".join(
857+
key for key, _wire, _ms, outcome in attempted if outcome in {"timeout", "error"}
858+
),
804859
}
805860

806861
if runtime_state is None:
@@ -863,19 +918,20 @@ async def _async_collect_runtime_values(
863918
There is no fast/medium/slow scheduling and no empty cycle: every poll
864919
executes QPIGS, QMOD, then the optional QPIWS / Q1 (auto-skipped when finally
865920
unsupported), then the reachable energy chain (existing order + early-exit),
866-
back to back with no artificial pause. The result is always a DELTA (see
867-
:meth:`Pi30Driver.async_read_values`): a transient failure keeps last-good and
868-
a final unsupported verdict invalidates the command's values via
869-
``removed_keys``. ``poll_interval`` / ``now_monotonic`` are accepted for the
870-
driver contract but no longer gate anything -- the whole-cycle wall-clock is
871-
the honest cost the neutral auto poll policy consumes.
921+
back to back with no artificial pause. Per-command payload errors do not
922+
discard other successful replies. Required commands are never disabled;
923+
their last-good values expire through ``removed_keys``. Optional commands
924+
retain the existing unsupported-command policy. Poll interval and monotonic
925+
time bound freshness only; they do not gate command scheduling.
872926
"""
873927

874928
command_timings: list[tuple[str, str, int, str]] = []
875929
cycle_started = time.monotonic()
876930

877931
values = await _async_collect_values(
878-
session, _RUNTIME_COMMAND_SPECS, runtime_state=runtime_state, command_timings=command_timings
932+
session, _RUNTIME_COMMAND_SPECS, runtime_state=runtime_state,
933+
command_timings=command_timings, allow_partial=True,
934+
now_monotonic=now_monotonic,
879935
)
880936
values.update(
881937
await _async_collect_energy_values(
@@ -885,13 +941,18 @@ async def _async_collect_runtime_values(
885941

886942
cycle_wall_ms = int(round((time.monotonic() - cycle_started) * 1000.0))
887943

888-
removed_keys: frozenset[str] = frozenset()
944+
removed_keys, freshness = _required_sample_freshness(
945+
runtime_state=runtime_state,
946+
command_timings=command_timings,
947+
now=time.monotonic() if now_monotonic is None else now_monotonic,
948+
poll_interval=poll_interval,
949+
)
889950
if runtime_state is not None:
890951
_commit_cycle_failures(runtime_state)
891952
# Invalidate values owned by any command that reached a FINAL unsupported
892953
# verdict (incl. energy-chain reachability). Never remove a value read
893954
# THIS cycle; unsupported-command reporting is a separate diagnostic.
894-
removed_keys = frozenset(
955+
removed_keys |= frozenset(
895956
_pi30_removed_keys_for_unsupported(
896957
frozenset(_unsupported_commands(runtime_state))
897958
)
@@ -904,6 +965,7 @@ async def _async_collect_runtime_values(
904965
cycle_wall_ms=cycle_wall_ms,
905966
)
906967
diagnostics.update(_unsupported_command_diagnostics(runtime_state))
968+
diagnostics.update(freshness)
907969
return values, removed_keys, diagnostics
908970

909971

‎custom_components/eybond_local/payload/pi30.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,15 @@ def _decode_value(raw: str, kind: str) -> Any:
532532
(None, "int"),
533533
)
534534

535+
def qpigs_output_keys() -> frozenset[str]:
536+
"""Return all QPIGS fields, including locally derived parser values."""
537+
538+
return frozenset(name for name, _kind in _QPIGS_LAYOUT_24) | frozenset({
539+
"qpigs_field_count", "pv_input_power", "battery_power_balance_current",
540+
"status_bits_raw",
541+
})
542+
543+
535544
def qpiws_output_keys() -> frozenset[str]:
536545
"""Return the runtime value keys produced by :func:`parse_qpiws`."""
537546

0 commit comments

Comments
 (0)