@@ -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"""
0 commit comments