Skip to content

Commit 2789c4d

Browse files
author
CI
committed
Merge remote-tracking branch 'origin/main' into fix/day-of-week-future-day-seed-5168
2 parents 537e9b2 + 8b9fe02 commit 2789c4d

35 files changed

Lines changed: 12342 additions & 30 deletions

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ eauth
169169
echargingpile
170170
Eddi
171171
eddis
172+
efgh
172173
elif
173174
elinter
174175
eload
@@ -495,6 +496,10 @@ preseed
495496
preseeded
496497
prevs
497498
protobuf
499+
pseudonymisation
500+
pseudonymise
501+
pseudonymised
502+
pseudonymises
498503
psum
499504
pthread
500505
pvbat
@@ -700,6 +705,7 @@ varh
700705
venv
701706
Victron
702707
visibilitychange
708+
vvwwxxabcdefyyzzuu
703709
waf
704710
waitpid
705711
Wallbox
@@ -712,13 +718,15 @@ WNOHANG
712718
workmode
713719
writeonly
714720
wrongsha
721+
wwxxabcdefyyzz
715722
xaxis
716723
xaxistooltip
717724
xlabel
718725
xlim
719726
xload
720727
xrtkq
721728
xticks
729+
xxabcdefyy
722730
yaxis
723731
yaxistooltip
724732
yday

‎apps/predbat/component_base.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,18 @@ class ComponentBase(ABC):
5353
api_started: Flag indicating whether the component has successfully started
5454
api_stop: Flag to signal the component to stop
5555
last_success_timestamp: Timestamp of the last successful operation
56+
component_name: Registry key this component is filed under (e.g. "givtcp"), set by
57+
Components.initialize(); falls back to the class name for a component built outside
58+
the registry (the standalone CLI harnesses), so report_discovery() always has a name.
5659
"""
5760

61+
# Declared on the class, not only assigned in __init__, so they exist even on a component built
62+
# without it - the test harnesses construct components with Cls.__new__(Cls) to exercise one
63+
# method in isolation. refresh_discovery() must not be able to raise on such an instance: an
64+
# AttributeError escaping run() is exactly the degradation an observer is forbidden to cause.
65+
component_name = None
66+
_discovery_report = None
67+
5868
def __init__(self, base, **kwargs):
5969
"""
6070
Initialise the component base.
@@ -72,6 +82,10 @@ def __init__(self, base, **kwargs):
7282
self.args = base.args
7383
self.count_errors = 0
7484
self.run_timeout = 60 * 60 # Default run time in seconds, can be overridden by subclasses
85+
# Overridden with the registry key by Components.initialize() once this component is
86+
# constructed through the registry; a component built directly (the standalone CLI
87+
# harnesses) keeps this class-name fallback instead.
88+
self.component_name = self.__class__.__name__
7589
self.initialize(**kwargs)
7690

7791
@abstractmethod
@@ -146,6 +160,74 @@ def update_success_timestamp(self):
146160
"""Update the last success timestamp to the current time"""
147161
self.last_success_timestamp = datetime.now(timezone.utc)
148162

163+
def report_discovery(self, report):
164+
"""Report what this component discovered to the discovery catalogue.
165+
166+
Silently does nothing when there is no coordinator - the standalone CLI harnesses run a
167+
component against a MockBase with no registry at all.
168+
"""
169+
components = getattr(self.base, "components", None)
170+
coordinator = getattr(components, "coordinator", None) if components else None
171+
if coordinator:
172+
coordinator.report(self.component_name or type(self).__name__, report)
173+
174+
def refresh_discovery(self):
175+
"""Rebuild this component's discovery report and file it if it has moved on.
176+
177+
Call unconditionally once per run() cycle. A component opts in by defining
178+
build_discovery(), returning the report dict, or None when it has not discovered enough to
179+
describe yet (no serial, no account) - a component with no build_discovery() is a no-op.
180+
181+
This is the whole reporting loop, so that a reporter only has to write the part that is
182+
actually its own. Three rules the reporters are otherwise each expected to remember are
183+
structural here instead:
184+
185+
- The report is rebuilt and compared IN FULL on every call, never keyed on a hand-maintained
186+
snapshot of whatever build_discovery() happens to read. A key has to be kept in step with
187+
the build by hand, and when it drifts the symptom is a report frozen in its first,
188+
incomplete state for the life of the process - the endpoint that had not yet decoded its
189+
serial, the entity Home Assistant had not published yet. Comparing the built report cannot
190+
drift, and needs no completeness check either: a report that fills in later simply differs
191+
from the stored one and replaces it. Builds are dict work over data already in hand and
192+
run() is called about once a minute, so rebuilding to compare costs nothing measurable,
193+
and report() is still only reached when something actually moved.
194+
- The marker advances only after the report has been filed, and never on the failure path,
195+
so a transient failure is retried on the next cycle instead of being lost. This is why the
196+
call belongs OUTSIDE any one-shot "if first:" gate: "first" is a start()-local that flips
197+
to False forever the instant run() returns True, so a single failed cycle inside it can
198+
never be retried.
199+
- A failure is caught and logged here, never raised. An observer must not be able to degrade
200+
the health of the component it observes: an exception escaping run() withholds
201+
update_success_timestamp() and eventually pushes a healthy component toward unhealthy.
202+
Logged only, never non_fatal_error_occurred(): that sets base.had_errors, which makes
203+
update_pred() skip record_status() and suppress the run notification, so a purely
204+
observational side channel would be changing Predbat's user-visible status (see solis.py's
205+
own comment on the same trap).
206+
"""
207+
build = getattr(self, "build_discovery", None)
208+
if build is None:
209+
return
210+
try:
211+
report = build()
212+
if report is None or report == self._discovery_report:
213+
return
214+
self.report_discovery(report)
215+
self._discovery_report = report
216+
except Exception as e:
217+
self.log("Warn: {}: failed to report discovery for the catalogue: {}".format(self.component_name or type(self).__name__, e))
218+
219+
def discovery_entities(self, descriptors):
220+
"""Keep only the entity descriptors Home Assistant has actually seen.
221+
222+
`descriptors` maps a Predbat standard control name to its descriptor dict, each carrying at
223+
least an "entity_id". An entity spec describes what a component CAN publish, not what it HAS
224+
published on this install with this firmware - most reporters publish a good part of theirs
225+
conditionally - so the catalogue must never claim an entity exists that Home Assistant has
226+
never seen. Checking the state store is also what keeps this true across a restart
227+
mid-cycle, where a spec would still claim everything.
228+
"""
229+
return {name: descriptor for name, descriptor in descriptors.items() if self.get_state_wrapper(descriptor["entity_id"]) is not None}
230+
149231
@property
150232
def currency_symbols(self):
151233
"""Get the currency symbols from the base system"""

‎apps/predbat/components.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import asyncio
3939
import os
4040

41+
from coordinator import Coordinator
42+
4143

4244
def load_component_class(component_info):
4345
"""Import a registry entry's module and return its component class.
@@ -749,6 +751,14 @@ class Components:
749751
"""
750752

751753
def __init__(self, base):
754+
"""Create the registry, with an empty component set and its own discovery coordinator.
755+
756+
The coordinator lives here - never as a PredBat attribute - because create_debug_yaml()
757+
dumps every non-excluded member of PredBat.__dict__, and the coordinator holds the raw
758+
unredacted reports plus the pseudonym salt. "components" is in DEBUG_EXCLUDE_LIST (as is
759+
"coordinator", defensively), so nothing under self.components is dumped automatically at
760+
all; create_debug_yaml() instead adds a plain redacted dict via coordinator.catalogue().
761+
"""
752762
self.components = {}
753763
self.component_tasks = {}
754764
# Why a configured component could not be loaded or constructed, by name. Such a component
@@ -757,6 +767,7 @@ def __init__(self, base):
757767
self.component_errors = {}
758768
self.base = base
759769
self.log = base.log
770+
self.coordinator = Coordinator(base)
760771

761772
def initialize(self, only=None, phase=0):
762773
"""Initialise components without starting them"""
@@ -804,6 +815,9 @@ def initialize(self, only=None, phase=0):
804815
component_class = load_component_class(component_info)
805816
self.log(f"Initialising {component_info['name']} interface")
806817
self.components[component_name] = component_class(self.base, **arg_dict)
818+
# Overrides the class-name fallback ComponentBase.__init__ set, with the
819+
# registry key report_discovery() should file this component's reports under.
820+
self.components[component_name].component_name = component_name
807821
except Exception as e:
808822
# A component that will not import (a missing package, a syntax error) or
809823
# construct must not take Predbat down with it: record why, leave it inactive

0 commit comments

Comments
 (0)