TEL-05: read the meter directly, because HA cannot tell us a reading arrived #5

Closed
admin wants to merge 0 commits from TEL-05 into release/1.0
Owner

sensor.p1_sample_age_s measured time since the value changed, not since the meter reported - HA fires no state_changed for a repeated reading and exposes no arrival signal at all. Proven on the rig three ways and confirmed against the live house (ten repeated values, last_reported frozen every time). Real captured data has inter-change gaps of 42.2 s and 97.0 s, so the firmware watchdog would have commanded 0 W on a healthy meter.

meter_source: homewizard_local polls GET /api/v1/data. Every HTTP response is a genuine arrival. Same pipeline as ha_signed - only the arrival mechanism changed.

The decisive rig result is the steady case, not the frozen one: hwsim served a literally constant 350.0 W for 70 s - the exact state where HA gave samples: 0 across 45 s - and this transport took 14 arrivals with the age never exceeding the poll cadence.

179 -> 236 checks. 10 mutants, all 10 reddening a named check, file restored byte-identical each time.

One AC in the ticket was internally contradictory and is not met as written. A --fault freeze meter still answers 200 OK, so it is arriving: no arrival detector can make its age climb, by definition. Freeze detection is delivered instead on the energy registers as unchanged_s (70.1 s frozen vs 10.0 s steady), deliberately not folded into the age and not thresholded - at a converged -10 W the export register needs ~6 minutes to move by its 1 Wh resolution, so thresholding it would rebuild the false-trip cycle at our target operating point.

`sensor.p1_sample_age_s` measured time since the value *changed*, not since the meter *reported* - HA fires no `state_changed` for a repeated reading and exposes no arrival signal at all. Proven on the rig three ways and confirmed against the live house (ten repeated values, `last_reported` frozen every time). Real captured data has inter-change gaps of 42.2 s and 97.0 s, so the firmware watchdog would have commanded 0 W on a healthy meter. `meter_source: homewizard_local` polls `GET /api/v1/data`. Every HTTP response is a genuine arrival. Same pipeline as `ha_signed` - only the arrival mechanism changed. **The decisive rig result is the steady case, not the frozen one**: hwsim served a literally constant 350.0 W for 70 s - the exact state where HA gave `samples: 0 across 45 s` - and this transport took 14 arrivals with the age never exceeding the poll cadence. 179 -> 236 checks. 10 mutants, all 10 reddening a named check, file restored byte-identical each time. **One AC in the ticket was internally contradictory and is not met as written.** A `--fault freeze` meter still answers 200 OK, so it *is* arriving: no arrival detector can make its age climb, by definition. Freeze detection is delivered instead on the energy registers as `unchanged_s` (70.1 s frozen vs 10.0 s steady), deliberately not folded into the age and not thresholded - at a converged -10 W the export register needs ~6 minutes to move by its 1 Wh resolution, so thresholding it would rebuild the false-trip cycle at our target operating point.
admin added 1 commit 2026-08-25 20:55:38 +02:00
A fourth meter_source, `homewizard_local`, polling a HomeWizard P1's own
local API (GET /api/v1/data) instead of watching an HA entity.

The point is the age sensor. sensor.p1_sample_age_s is FW-01's watchdog
input, and on every transport we had it measured "time since the value
CHANGED", not "time since the meter REPORTED". Home Assistant offers
nothing better: a repeated reading emits no state_changed, advances
last_reported on neither serialiser, and state_reported cannot be
subscribed to at all. Measured twice - 70 s of a frozen meter on the
ENV-01 rig, and ten repeated readings against the live house. Our own
capture of this house's meter goes 42.2 s and 97.0 s between changes,
both past the default meter_max_age_s of 30, so the age sensor would
have commanded 0 W on a perfectly healthy meter.

Here every HTTP response is an arrival. The meter answered, now, with
its current reading; whether the number moved is not consulted. Five
identical readings are five arrivals.

Reuses TEL-01's pipeline rather than restructuring it: same split_signed
sign convention as ha_signed, same make_sample, same ingest stamping,
meter_max_age_s, clock-recomputed age, plausibility bounds and the §20
unsigned-decode rejection. A failed or timed-out poll submits nothing,
so it is a missing reading - never 0 W - and does not reset the age.
meter_poll_s (default 5 s, the meter's own rate) is checked against
meter_max_age_s once at startup, like the ha_signed entity ids.

⚠️ An arrival stamp cannot see a FROZEN meter, and no arrival detector
can - one answering 200 OK with a stale number is arriving. The local
API does expose what HA never had (the total_power_*_kwh registers stop
advancing) and the transport tracks it as `unchanged_s`, but it is
deliberately not folded into the age and not thresholded: this
controller regulates grid toward ~0 W, and at a converged -10 W the
export register needs six minutes to move by its 1 Wh resolution while
the power figure legitimately repeats. Thresholding that would rebuild
the false-trip limit cycle at the exact operating point we aim for.

test_p1.py 179 -> 236 checks. Still defaults to off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
admin added 1 commit 2026-08-25 21:17:45 +02:00
Four follow-ups on the reviewed and approved TEL-05 work. Additive; no shipped
behaviour changes except the two failure paths below.

1. unchanged_s had no operator surface. DOCS.md told a reader "the transport
   tracks it as unchanged_s" and there was nowhere to look: main.py built the
   transport, scheduled run(), and never read the object again. The status page
   now shows it on the healthy P1 line. Still NOT thresholded and NOT folded
   into the age - that refusal was reviewed and upheld, because at the converged
   -10 W this controller aims for a 1 Wh register needs ~6 minutes to move, so
   any limit false-trips at the target operating point. The whole argument for
   leaving it to a human requires the human being able to see it.

2. The "equivalent mutant" note on the content_type guard was wrong, and the
   comment is downgraded to say so. web.Response(text=...) defaults to
   text/plain, so the fake meter CAN serve valid JSON under the wrong mimetype.
   Test added; shipped behaviour was already correct.

3. A timed-out poll logged an empty reason: str(asyncio.TimeoutError()) is "",
   so the status page read "last error:" and then nothing, on a hung meter, at
   the moment the battery had just gone to 0 W. Falls back to the class name.
   Note str(err), not `err or ...` - an exception object is always truthy.

4. submit() sat outside the try in poll_once() and run() had no except, so a
   raise would kill the poll task permanently and SILENTLY - safe (the age
   climbs, the controller commands 0 W) but indistinguishable from a dead meter.
   Both wrapped; poll_s is already the retry cadence, so no backoff.

Also a comment at the parse_homewizard range(phases) slice: a 3-phase meter
configured as 1-phase understates the capacity-tariff figure. Filed separately,
not fixed here.

242 checks in test_p1.py (236 before, 6 new). test_control 55, test_arbiter 18,
test_maintenance 21, all untouched and green. Each new check proved non-vacuous:
six mutations, six named reds, no suite aborts, sources restored byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
Author
Owner

Closing: the content of this branch is already on release/1.0 — git rev-list --count origin/release/1.0.. returns 0.

The work shipped, but it reached the release branch by a different route (a direct push, or carried in with another merge) rather than through this PR, so Gitea never auto-closed it. That is a process failure on my part: it left the PR list looking like nothing had shipped, and it orphans any review discussion held here.

Nothing is lost and nothing needs re-doing. Going forward, work lands by merging its PR.

Closing: **the content of this branch is already on `release/1.0`** — `git rev-list --count origin/release/1.0..` returns **0**. The work shipped, but it reached the release branch by a different route (a direct push, or carried in with another merge) rather than through this PR, so Gitea never auto-closed it. That is a process failure on my part: it left the PR list looking like nothing had shipped, and it orphans any review discussion held here. Nothing is lost and nothing needs re-doing. Going forward, work lands by merging its PR.
admin closed this pull request 2026-08-28 01:19:02 +02:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#5