4040 parse_qt_clock ,
4141 parse_serial_number ,
4242 q1_output_keys ,
43+ qpigs_output_keys ,
4344 qpiws_output_keys ,
4445)
4546from ..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+
753805def _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
0 commit comments