TEL-04 review: unshadow the helper, validate config at startup, and record

what the rig proved about the age sensor

Review findings 1, 3, 5 and 6. Finding 2 is deliberately untouched - it is
its own ticket.

3. `built` was rebound at test_p1.py:816 by `built = build_source(...)`,
   silently disarming the build() wrapper for anything appended below it.
   Renamed to `sel`. Reproduced the reviewer's failure before fixing:
   appending a check that calls built() after that line gives
   `TypeError: 'HaSignedSource' object is not callable` and aborts at 163 of
   180; with the rename the same probe reaches 180 and passes.

5. DOCS.md now states the "length must equal meter_phases" constraint that
   config.yaml already carried, plus what leaving the list empty actually
   costs: on the surveyed reading the phases carry 2769 W of import while the
   connection nets 187 W, so the tariff quantity is understated ~15x.

6. build_source now checks the ha_signed wiring once at startup instead of
   once per telegram: a blank p1_net_entity, or a phase list whose length
   disagrees with meter_phases, logs an error and disables ingestion. Both
   otherwise fail in the single way indistinguishable from a healthy source
   nobody has fed yet - no samples, a climbing age, the watchdog holding the
   battery at 0 W, and nothing in the log.

1. THE AGE SENSOR. Measured on the ENV-01 rig against the real HomeWizard
   integration, meter frozen via hwsim's `?fault=freeze` seam (cleared in a
   finally:, rig verified restored):

     - websocket state_changed for the meter over 70 s : 0
     - last_reported advanced (REST serialiser)        : no
     - last_reported advanced (websocket serialiser)   : no
     - subscribe_events(state_reported)                : rejected,
       "Event filter is required for event state_reported"

   So Home Assistant exposes NO arrival signal for a repeated reading, and
   the proposed fix - stamp from last_reported via subscribe_entities - is
   not available. subscribe_entities listens only to EVENT_STATE_CHANGED, and
   as_compressed_state carries no last_reported at all.

   The age is therefore "time since the value changed", which on ha_dsmr is
   mostly harmless (a telegram moves several entities) and on ha_signed is
   not: one entity means a healthy meter under a flat load is
   indistinguishable from a dead one. Recorded loudly in DOCS.md, in the
   HaSignedSource docstring and in the CHANGELOG, with the measured 42.2 s
   and 97.0 s gaps from our own capture.

   meter_max_age_s is deliberately NOT widened. The two conditions produce an
   identical signal, so a larger number does not separate them - it only
   chooses which of the two errors you get, and it would disarm the watchdog
   for a genuinely dead meter as well. The honest fix is an arrival stamp the
   meter itself provides.

test_p1.py: 174 -> 179 checks, all green. Other three suites unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
This commit is contained in:
glenn schrooyen
2026-08-25 17:34:37 +02:00
co-authored by Claude Opus 5
parent 632be44f6c
commit e663e10245
4 changed files with 129 additions and 17 deletions
+40 -4
View File
@@ -722,6 +722,25 @@ class HaSignedSource(HaDsmrSource):
ingest timestamping, meter_max_age_s, the clock-recomputed age sensor, and
`unavailable` treated as a missing reading rather than 0 W.
⚠️ THE AGE ON THIS TRANSPORT MEASURES TIME SINCE THE VALUE CHANGED, not time
since the meter reported, and on one entity those are very different things.
Home Assistant offers no arrival signal for a repeated reading: it emits no
`state_changed`, it does not advance `last_reported` on either serialiser,
and `state_reported` cannot be subscribed to over the websocket at all
("Event filter is required for event state_reported"). All three measured on
the ENV-01 rig against the real HomeWizard integration with the meter frozen
- 0 state_changed in 70 s, no timestamp movement anywhere.
`ha_dsmr` mostly escapes it because a DSMR telegram moves several entities at
once. This transport has ONE, so a healthy meter under a flat load looks
exactly like a dead one - and our own capture has the real meter going 42.2 s
and 97.0 s between changes, both past the default max_age_s of 30. Hence
DOCS.md: sensor.p1_sample_age_s is correct while the value moves and must not
yet be thresholded by the firmware watchdog on this transport. Raising
meter_max_age_s does not fix it, it only chooses which of the two errors you
get. The fix is an arrival stamp from the meter itself - reading the
HomeWizard local API rather than an HA entity - which is a separate ticket.
⚠️ The debounce is inherited but does nothing useful here, and that is fine:
one telegram is one entity, so there is no burst of per-entity events to
coalesce and no window in which a new reading sits beside a stale one. It
@@ -811,10 +830,27 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
"phase_export": opts.get("p1_phase_export_entities") or [],
})
if source == SOURCE_HA_SIGNED:
return HaSignedSource(session, ingest, {
"net": opts.get("p1_net_entity", ""),
"phase_net": opts.get("p1_phase_net_entities") or [],
})
net = str(opts.get("p1_net_entity", "") or "").strip()
phase_net = [str(e).strip() for e in
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
# ⚠️ Both of these are checked ONCE here rather than per telegram. A
# misconfigured source otherwise fails silently in the only way that
# looks exactly like a healthy one that has not been sent anything yet:
# no samples, a climbing age, and the firmware watchdog holding the
# battery at 0 W with nothing in the log saying why.
if not net:
_LOG.error("meter_source %s needs p1_net_entity - P1 ingestion "
"disabled (sensor.p1_sample_age_s would otherwise be "
"announced with nothing feeding it)", SOURCE_HA_SIGNED)
return None
if phase_net and len(phase_net) != ingest.phases:
_LOG.error("p1_phase_net_entities has %d entities but meter_phases "
"is %d - P1 ingestion disabled. Every telegram would be "
"rejected on the phase-count check.",
len(phase_net), ingest.phases)
return None
return HaSignedSource(session, ingest,
{"net": net, "phase_net": phase_net})
if source == SOURCE_MQTT:
broker = broker or {}
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),