TEL-05: read the meter, not Home Assistant's opinion of the meter

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
This commit is contained in:
glenn schrooyen
2026-08-25 20:51:23 +02:00
co-authored by Claude Opus 5
parent ad9c5772a4
commit 98109a9b91
5 changed files with 715 additions and 9 deletions
+67 -3
View File
@@ -70,6 +70,13 @@ Either way, do not build the missing shape out of template sensors. The point of
`meter_source` is that the sign convention is derived in one tested place rather
than in YAML nobody reviews underneath a safety input.
> ✅ **If your meter is a HomeWizard P1, use `homewizard_local`, not
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
> polls the meter directly instead of watching a Home Assistant entity — and
> that is the difference between an age sensor a watchdog can threshold and one
> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for
> installs where the meter is only reachable through Home Assistant.
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
> hardware.** No meter in this installation uses either one. Both were written
> to the assumption in `specs.md` §5.2 that a Belgian P1 exposes two unsigned
@@ -84,9 +91,11 @@ than in YAML nobody reviews underneath a safety input.
| option | default | meaning |
|---|---|---|
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket |
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket; `homewizard_local` polls a HomeWizard P1's own local API, bypassing Home Assistant |
| `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
| `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average |
| `meter_poll_s` | 5 | `homewizard_local` only. Seconds between polls. **Must be well under `meter_max_age_s`** — see below |
| `p1_host` | | `homewizard_local` only. The meter's own address, `host` or `host:port` (e.g. `192.168.2.250`) |
| `meter_mqtt_topic` | | `mqtt_p1` only |
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
| `p1_export_entity` | | The **unsigned** injection sensor |
@@ -143,6 +152,50 @@ tested, and guessing a key here means guessing a kilowatt:
it is present it is used for the age, which is what stops a retained message
replayed on reconnect from presenting a ten-minute-old reading as current.
#### `homewizard_local` — polling the meter instead of Home Assistant
Set `p1_host` to the meter's address and the add-on does `GET /api/v1/data` on
it every `meter_poll_s` seconds, reading `active_power_w` (signed, same
convention as `ha_signed`) and the three `active_power_l{1,2,3}_w` fields. Home
Assistant is not involved: no entity, no WebSocket, no integration to
mis-configure. Per-phase figures are used only when the meter serves all
`meter_phases` of them — a single-phase meter returns `null` for L2/L3, and the
connection-level reading is still accepted on its own.
**Why this mode exists:** every HTTP response is an *arrival*. The meter
answered, now, with its current reading — whether or not the number moved. That
is the signal `sensor.p1_sample_age_s` needs and the one Home Assistant cannot
give it at all (see the note below). It is also simply fewer moving parts: the
five-second cadence is the meter's own, rather than an integration's polling of
it re-published as a state change.
**Cadence.** The age is never fresher than the poll interval, so:
| | |
|---|---|
| meter's own update rate | ~5.0 s (measured 4.97 s) |
| `meter_poll_s` default | 5 s — nothing to gain below the meter's own rate |
| `meter_max_age_s` default | 30 s, i.e. six polls of headroom |
| `meter_poll_s >= meter_max_age_s` | **refused at startup** — every reading would be stale before its successor arrived |
| `meter_poll_s > meter_max_age_s / 2` | warned — one missed poll makes the reading stale |
A failed poll — timeout, connection refused, non-200, unparseable body — is a
**missing** reading. It submits nothing, so the reading does not become 0 W, the
last good value and its timestamp are left alone, and the age goes on climbing.
That is exactly what a dead meter should look like.
**What it still cannot see: a frozen meter.** A meter that answers `200 OK`
forever with a stale number is arriving, so no arrival detector — this one
included — can tell it from a healthy one. The local API does expose the raw
material the HA path 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
power 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 at 30 s would rebuild the false-trip limit cycle at
the exact operating point the controller aims for. Freeze detection is a
separate problem and needs the low-power case solved first.
#### `sensor.p1_sample_age_s`
Published over MQTT discovery whenever a broker is available: **seconds since the
@@ -189,8 +242,19 @@ behind it would trip the firmware watchdog on a system that is working fine.
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
> identical signal, so a bigger number only chooses which of the two errors you
> get. The real fix is an arrival stamp the meter itself provides, i.e. reading
> the HomeWizard local API directly rather than through an HA entity.
> get. The real fix is an arrival stamp the meter itself provides — and that now
> exists: **`meter_source: homewizard_local`**. If you have a HomeWizard P1,
> switch to it. If your meter is only reachable through Home Assistant, this
> limit still applies to you and the watchdog threshold still must not be armed.
**Where the age is trustworthy:**
| mode | the age measures | safe to threshold from firmware |
|---|---|---|
| `homewizard_local` | time since the meter **answered** | **yes** — every HTTP response is an arrival |
| `ha_dsmr` | time since one of several entities changed | no — statistically usually fine, which is a masked bug, not an absent one |
| `ha_signed` | time since the one entity changed | **no** — see above |
| `mqtt_p1` | time since a message arrived | arrivals yes, but a stuck bridge republishing keeps arriving |
### Control