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

sensor.p1_sample_age_s measured time since the value CHANGED, not since the
meter REPORTED. Home Assistant 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, where ten repeated meter values left last_reported
frozen every time. Real captured data in sim/scenarios/ has inter-change gaps of
42.2 s and 97.0 s, and the firmware watchdog thresholds that age at 30 s, so it
would have commanded 0 W on a perfectly 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 evidence is the STEADY case, not the frozen one: hwsim served a
literally constant 350.0 W for 70 s - the exact state where the HA path recorded
samples: 0 across 45 s - and this transport took 14 arrivals with the age never
exceeding the poll cadence. Reproduced independently by the reviewer against the
real hwsim, and again by the tester at 14/14.

179 -> 242 checks. Twelve mutants from the reviewer and six from the developer,
every one reddening a NAMED check rather than aborting the suite.

WHAT THIS DOES NOT DO. The ticket asked for two things that cannot both be true:
that the age reset on every HTTP response including an unchanged value, AND that
a frozen meter show a climbing age. A frozen meter still answers 200 OK - it IS
arriving - so no arrival detector can do both. That acceptance criterion was
wrong and was written by the PM; the developer refused it rather than quietly
building half of it.

Freeze detection is delivered separately as unchanged_s on the energy registers,
which the local API exposes and HA never did: 70.2 s frozen against 10.0 s
steady. Deliberately unthresholded, because at the converged -10 W this
controller aims for, a 1 Wh register needs about six minutes to move while the
power figure legitimately repeats - any threshold would rebuild the false-trip
cycle at the target operating point. It is surfaced on the status line so a
human can read it, which is what makes leaving it unthresholded defensible
rather than a dodge.

So the flash covers meter SILENCE, not meter DISHONESTY.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-08-25 21:27:13 +02:00
co-authored by Claude Opus 5
6 changed files with 915 additions and 10 deletions
+40
View File
@@ -2,6 +2,46 @@
## Unreleased ## Unreleased
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
(default 5 s, the meter's own update rate) sets the cadence.
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
the meter *reported*, and therefore the first one a firmware watchdog may
threshold.** Every HTTP response is an arrival: the meter answered, now, with
its current reading, and whether the *number* moved is not consulted. Home
Assistant cannot express that at all — a repeated reading emits no
`state_changed`, advances `last_reported` on neither serialiser, and
`state_reported` is not subscribable ("Event filter is required"). On
`ha_signed` that made a healthy meter under a flat load indistinguishable from
a dead one, and 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, i.e. a false
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
mode.
Everything TEL-01 established is reused, not re-implemented: ingest
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
is a *missing* reading — never 0 W — and it does not reset the age.
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
freeze` injected. Still defaults to `off`.
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
detector can: a meter answering `200 OK` forever with a stale number is
arriving. The local API does expose what the HA path never had — the
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
`unchanged_s`, but that 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 it at 30 s
would rebuild the false-trip limit cycle at the exact operating point we aim
for. Freeze detection needs the low-power case solved first, separately. It is
shown on the status page's P1 line instead — leaving it unthresholded only
holds up if a human can read it, so now they can.
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home **TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
Assistant entity: positive = import, negative = export. That is the shape a Assistant entity: positive = import, negative = export. That is the shape a
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
+72 -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 `meter_source` is that the sign convention is derived in one tested place rather
than in YAML nobody reviews underneath a safety input. 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 > ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
> hardware.** No meter in this installation uses either one. Both were written > 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 > 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 | | 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_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_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 | | `meter_mqtt_topic` | | `mqtt_p1` only |
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template | | `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
| `p1_export_entity` | | The **unsigned** injection sensor | | `p1_export_entity` | | The **unsigned** injection sensor |
@@ -143,6 +152,55 @@ 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 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. 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.
You read it yourself instead: the add-on's status page shows it on the P1 line,
as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
power that figure stays in the seconds; minutes of it while the load is clearly
not near zero is the meter to go and look at.
#### `sensor.p1_sample_age_s` #### `sensor.p1_sample_age_s`
Published over MQTT discovery whenever a broker is available: **seconds since the Published over MQTT discovery whenever a broker is available: **seconds since the
@@ -189,8 +247,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. > 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 > 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 > 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 > get. The real fix is an arrival stamp the meter itself provides — and that now
> the HomeWizard local API directly rather than through an HA entity. > 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 ### Control
+18 -1
View File
@@ -97,6 +97,10 @@ class Controller:
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)), self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
max_age_s=float(opts.get("meter_max_age_s", 30))) max_age_s=float(opts.get("meter_max_age_s", 30)))
self.p1_enabled = is_enabled(opts) self.p1_enabled = is_enabled(opts)
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
self.p1_source = None
# live state # live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False))) self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
@@ -365,10 +369,22 @@ class Controller:
+ (f" - last error: {self.p1.last_error}" + (f" - last error: {self.p1.last_error}"
if self.p1.last_error else "")}) if self.p1.last_error else "")})
else: else:
# ⚠️ unchanged_s is REPORTED, never thresholded and never folded
# into the age - see HomeWizardLocalSource.unchanged_s for why
# (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 exact operating point we target). The whole argument for
# leaving it unthresholded is that a human interprets it, which
# requires a human being able to see it - so here it is. getattr:
# only homewizard_local has one, and p1_source is None until
# amain() builds the transport.
unchanged = getattr(self.p1_source, "unchanged_s", None)
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False,
"text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, " "text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, "
f"{age:.0f} s old, {self.p1.samples} telegrams, " f"{age:.0f} s old, {self.p1.samples} telegrams, "
f"{self.p1.parse_errors} rejected"}) f"{self.p1.parse_errors} rejected"
+ (f", measurement unchanged for {unchanged:.0f} s"
if unchanged is not None else "")})
rows = [("battery SoC", self.soc, o.get("soc_entity")), rows = [("battery SoC", self.soc, o.get("soc_entity")),
("battery power", self.batt, o.get("batt_entity"))] ("battery power", self.batt, o.get("batt_entity"))]
if not self.p1_enabled: if not self.p1_enabled:
@@ -540,6 +556,7 @@ async def amain() -> None:
# unit - would be computed from a fraction of the data. # unit - would be computed from a fraction of the data.
p1_source = build_source(opts, controller.p1, session, broker) p1_source = build_source(opts, controller.p1, session, broker)
if p1_source is not None: if p1_source is not None:
controller.p1_source = p1_source # so checks() can report on it
tasks.append(asyncio.create_task(p1_source.run())) tasks.append(asyncio.create_task(p1_source.run()))
await stop.wait() await stop.wait()
+283 -2
View File
@@ -57,6 +57,7 @@ _LOG = logging.getLogger("goodwe.p1")
SOURCE_HA = "ha_dsmr" SOURCE_HA = "ha_dsmr"
SOURCE_MQTT = "mqtt_p1" SOURCE_MQTT = "mqtt_p1"
SOURCE_HA_SIGNED = "ha_signed" SOURCE_HA_SIGNED = "ha_signed"
SOURCE_HOMEWIZARD = "homewizard_local"
QUARTER_S = 900 QUARTER_S = 900
@@ -88,7 +89,7 @@ class P1Sample:
ingest_ts: datetime # tz-aware UTC, set at ingest ingest_ts: datetime # tz-aware UTC, set at ingest
ingest_mono: float # time.monotonic() at ingest - see age_s() ingest_mono: float # time.monotonic() at ingest - see age_s()
telegram_ts: datetime | None # from the telegram, where the source has one telegram_ts: datetime | None # from the telegram, where the source has one
source: str # SOURCE_HA | SOURCE_MQTT | SOURCE_HA_SIGNED source: str # one of the SOURCE_* constants above
import_w: float # unsigned magnitude, as the meter reports it import_w: float # unsigned magnitude, as the meter reports it
export_w: float # unsigned magnitude export_w: float # unsigned magnitude
net_w: float # import_w - export_w (+ import, - export) net_w: float # import_w - export_w (+ import, - export)
@@ -798,6 +799,236 @@ class HaSignedSource(HaDsmrSource):
return False return False
# --------------------------------------------------------------------------- #
# transport 4: the HomeWizard P1's own local API
# --------------------------------------------------------------------------- #
HOMEWIZARD_PATH = "/api/v1/data"
HOMEWIZARD_PORT = 80
def parse_homewizard(doc, phases: int, *, now: datetime | None = None,
ingest_mono: float | None = None) -> P1Sample:
"""One `GET /api/v1/data` document -> a sample. Raises P1Error.
The meter's own JSON, of which we read four keys:
{"active_power_w": -5710.0, # SIGNED, + import - export
"active_power_l1_w": ..., "active_power_l2_w": ..., "..._l3_w": ...,
"total_power_import_kwh": 1234.567, "total_power_export_kwh": 890.123}
Same shape as `ha_signed` - one signed connection figure plus signed
per-phase figures - so it goes through the same `split_signed` and the same
`make_sample`. Nothing about the sign convention is re-derived here.
⚠️ No telegram timestamp, because the API carries none, and that is correct
rather than a gap: the document was produced by the meter in the moment it
answered, so the ingest stamp IS the measurement time. This is the whole
reason the transport exists - see HomeWizardLocalSource.
⚠️ Per-phase figures are taken only when the meter serves ALL `phases` of
them. A single-phase HomeWizard returns `active_power_l2_w: null`, and a
partial set must not become a fabricated tuple. Unlike the HA transports
there is no "wait for the rest" case to worry about: every field here came
out of ONE response, so a missing phase cannot be a stale phase, and the
connection-level reading is still a genuine, complete measurement. Dropping
the whole sample over an absent per-phase field would turn a healthy meter
into a climbing age, which is the exact false-trip this ticket removes.
"""
if not isinstance(doc, dict):
raise P1Error(f"meter response is not a JSON object ({type(doc).__name__})")
imp, exp = split_signed(doc.get("active_power_w"))
pi = pe = None
# ⚠️ range(phases), not the legs the meter served: a 3-phase meter configured
# as meter_phases: 1 yields a per-phase tuple covering ONE leg of three.
# Control is unaffected (it uses the connection figure), but the
# capacity-tariff peak is understated. Filed separately - not fixed here.
legs = [doc.get(f"active_power_l{i + 1}_w") for i in range(phases)]
if all(v is not None for v in legs):
pairs = [split_signed(v) for v in legs]
pi = [a for a, _ in pairs]
pe = [b for _, b in pairs]
return make_sample(SOURCE_HOMEWIZARD, imp, exp, phases=phases,
phase_import_w=pi, phase_export_w=pe,
ingest_ts=now, ingest_mono=ingest_mono)
def measurement_fingerprint(doc) -> tuple:
"""The part of a response that a genuinely new measurement has to move.
Power plus both energy registers. A meter under any real load advances a
kWh counter (1 Wh resolution: ~10 s at 350 W), so this changes even when the
power figure happens to repeat. Used ONLY for `unchanged_s` - see there for
why it must not be allowed anywhere near the age.
"""
if not isinstance(doc, dict):
return ()
return (doc.get("active_power_w"),
doc.get("total_power_import_kwh"),
doc.get("total_power_export_kwh"))
class HomeWizardLocalSource:
"""Polls the meter's own local HTTP API instead of a Home Assistant entity.
⚠️ THIS IS THE ONLY TRANSPORT WHOSE AGE MEASURES ARRIVAL. That is the entire
reason it exists, and it is not an optimisation - it is the difference
between an age sensor a firmware watchdog may threshold and one it may not.
Every HTTP response is a genuine arrival: the meter answered, now, with its
current reading. Whether the NUMBER moved is irrelevant, so a healthy meter
under a flat load resets the age exactly like a busy one. Home Assistant
cannot provide this at all - it emits `state_changed` only on a change, does
not advance `last_reported` on either serialiser for a repeat, and refuses a
`state_reported` subscription outright ("Event filter is required"). Measured
on the ENV-01 rig over 70 s of a frozen meter, and independently against the
live house over ten repeated readings. So on `ha_signed` the age means "time
since the value changed", and our own capture of this house's meter has
42.2 s and 97.0 s between changes - both past the default `meter_max_age_s`
of 30, i.e. a false trip to 0 W on a perfectly healthy meter.
Everything else is TEL-01's pipeline unchanged: ingest timestamping,
`meter_max_age_s`, the clock-recomputed age, the plausibility bounds, and a
failed read treated as a MISSING reading rather than 0 W.
⚠️ A failed or timed-out poll submits nothing. It is a rejection, so the
last good sample and its stamp stay exactly where they were and the age goes
on climbing - which is precisely the signal a dead meter should produce.
⚠️ POLL CADENCE vs `meter_max_age_s`. The age can only be as fresh as the
poll interval, so `meter_poll_s` must sit well under `meter_max_age_s` or the
reading flaps in and out of staleness on a healthy meter. The real meter
updates every ~5.0 s (NOTES.md:640 measures 4.97 s), so polling faster than
that buys nothing but re-reads. Default 5 s against a 30 s max age; the
startup check in build_source refuses `meter_poll_s >= meter_max_age_s` and
warns past half of it.
ponytail: a plain `asyncio.sleep` loop rather than a scheduler, and no
backoff. A poll that fails costs one rejection and is retried on the next
tick; a meter that is down stays down and the age reports it. The ceiling is
a meter so slow that polls overlap - the request timeout is held under the
poll interval so they cannot.
"""
def __init__(self, session, ingest: P1Ingest, host: str, *,
port: int = HOMEWIZARD_PORT, poll_s: float = 5.0,
timeout_s: float | None = None):
self.session = session
self.ingest = ingest
self.host, self.port = host, int(port)
self.poll_s = float(poll_s)
# ⚠️ Held under the poll interval on purpose: a timeout longer than the
# cadence queues polls behind a hung meter, and a backlog of requests
# all landing at once would stamp several arrivals for one measurement.
self.timeout_s = float(timeout_s) if timeout_s else max(1.0, self.poll_s * 0.8)
self.url = f"http://{self.host}:{self.port}{HOMEWIZARD_PATH}"
self.connected = False
self.polls = 0
self.poll_errors = 0
self._fingerprint: tuple | None = None
self._changed_mono: float | None = None
@property
def unchanged_s(self) -> float | None:
"""Seconds since the meter last served a DIFFERENT measurement.
⚠️ Diagnostic only, and deliberately NOT folded into
`sensor.p1_sample_age_s`. A frozen meter (SAFETY-01's six-minute
runaway) answers 200 OK with a stale document forever, so no arrival
detector can see it - only the document standing still can, and this is
that signal. It is exposed rather than acted on because it is NOT safe
as a 30 s watchdog input: this controller regulates grid power toward
~0 W, and at a converged -10 W the export register takes six minutes to
advance by its 1 Wh resolution while the power figure legitimately
repeats. Thresholding that would rebuild the very limit cycle the
arrival stamp just removed, at the exact operating point we aim for.
Whoever wires a freeze detector must handle the low-power case first.
"""
if self._changed_mono is None:
return None
return max(0.0, time.monotonic() - self._changed_mono)
async def run(self) -> None:
"""Long-lived task: poll, submit, sleep, forever.
⚠️ Nothing but cancellation may end this loop. An escaping exception
would kill the poll task for the lifetime of the add-on, and it would do
it QUIETLY: the failure mode is safe (no submissions, the age climbs, the
watchdog holds the battery at 0 W) but it looks identical to a dead
meter, so the operator goes hunting the wrong device. Retry on the next
tick instead - poll_s is already the retry cadence, so no backoff.
"""
_LOG.info("P1 ingest: polling %s every %.1fs", self.url, self.poll_s)
while True:
try:
await self.poll_once()
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - the task must outlive it
_LOG.warning("P1 meter poll %s: %s - retrying in %.1fs", self.url,
str(err) or type(err).__name__, self.poll_s)
await asyncio.sleep(self.poll_s)
async def poll_once(self) -> bool:
"""One GET. Returns True if a sample was accepted."""
self.polls += 1
try:
async with self.session.get(
self.url,
timeout=aiohttp.ClientTimeout(total=self.timeout_s)) as resp:
if resp.status != 200:
raise P1Error(f"meter returned HTTP {resp.status}")
# content_type=None: the meter's own firmware is the authority on
# what it serves, and refusing a reading over a Content-Type
# header would be a fabricated outage.
# ⚠️ Kept because a real HomeWizard firmware that ever answers
# text/plain would otherwise read as a dead meter and hold the
# battery at 0 W. (This was once recorded here as an equivalent
# mutant - it is not. aiohttp's json_response always sets
# application/json, but web.Response(text=...) defaults to
# text/plain, so the fake meter CAN serve valid JSON under the
# wrong header, and test_p1.py now does.)
doc = await resp.json(content_type=None)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - any read failure is a gap
self.connected = False
self.poll_errors += 1
# ⚠️ str() of a bare asyncio.TimeoutError is the EMPTY STRING, so
# f"...: {err}" renders "last error:" and then nothing - on the
# status page, on a hung meter, at the moment the battery has just
# dropped to 0 W and someone is reading that line to find out why.
# The class name is the only thing that says "it timed out".
# ⚠️ str(err), not `err or ...`: an exception object is ALWAYS truthy,
# empty message or not, so the `or` would never reach the fallback.
self.ingest.reject(
f"meter poll {self.url}: {str(err) or type(err).__name__}")
return False
self.connected = True
try:
sample = parse_homewizard(doc, self.ingest.phases)
# ⚠️ Submitted unconditionally, INCLUDING a document identical to the
# last one. The response is the arrival; the number is not.
# Inside the try on purpose: submit() outside it would put a raise on
# a path with no handler at all, killing the poll task permanently -
# safely (the age climbs) but silently. run() backstops the rest.
self.ingest.submit(sample)
self._note(doc)
except P1Error as err:
self.ingest.reject(err)
return False
return True
def _note(self, doc) -> None:
fp = measurement_fingerprint(doc)
if self._changed_mono is None or fp != self._fingerprint:
self._fingerprint = fp
self._changed_mono = time.monotonic()
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# selection # selection
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -851,6 +1082,55 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
return None return None
return HaSignedSource(session, ingest, return HaSignedSource(session, ingest,
{"net": net, "phase_net": phase_net}) {"net": net, "phase_net": phase_net})
if source == SOURCE_HOMEWIZARD:
host = str(opts.get("p1_host", "") or "").strip()
# A pasted browser URL is the obvious way to fill this in wrong.
host = host.removeprefix("http://").removeprefix("https://").rstrip("/")
host, _, port_s = host.partition(":")
# ⚠️ Checked ONCE here, like the ha_signed entity ids and for the same
# reason: a misconfigured source otherwise fails in the one way that
# looks exactly like a healthy one nobody has sent anything to yet - no
# samples, a climbing age, the watchdog holding the battery at 0 W, and
# nothing in the log saying why.
if not host:
_LOG.error("meter_source %s needs p1_host (the meter's own address, "
"e.g. 192.168.2.250) - P1 ingestion disabled",
SOURCE_HOMEWIZARD)
return None
try:
port = int(port_s) if port_s else HOMEWIZARD_PORT
except ValueError:
_LOG.error("p1_host %r has an unparseable port - P1 ingestion disabled",
opts.get("p1_host"))
return None
try:
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
# cadence of 0 is a misconfiguration that must be reported, not
# quietly corrected into something that looks like it was asked for.
raw = opts.get("meter_poll_s", 5)
poll_s = float(5 if raw is None or raw == "" else raw)
except (TypeError, ValueError):
_LOG.error("meter_poll_s %r is not a number - P1 ingestion disabled",
opts.get("meter_poll_s"))
return None
if poll_s <= 0:
_LOG.error("meter_poll_s must be positive - P1 ingestion disabled")
return None
# ⚠️ The age can never be fresher than the poll interval. At or past
# max_age_s every reading is stale before its successor arrives, so the
# controller would sit permanently on missing inputs while the meter is
# perfectly healthy - refuse it rather than ship that.
if poll_s >= ingest.max_age_s:
_LOG.error("meter_poll_s %.1f is not under meter_max_age_s %.1f - every "
"reading would go stale before the next poll. P1 ingestion "
"disabled.", poll_s, ingest.max_age_s)
return None
if poll_s > ingest.max_age_s / 2:
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
"%.1f: one missed poll makes the reading stale. The meter "
"updates every ~5 s; 5 s against 30 s is the tested pair.",
poll_s, ingest.max_age_s)
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
if source == SOURCE_MQTT: if source == SOURCE_MQTT:
broker = broker or {} broker = broker or {}
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")), return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),
@@ -858,5 +1138,6 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
broker.get("username"), broker.get("password")) broker.get("username"), broker.get("password"))
if source: if source:
_LOG.error("meter_source %r is not one of %s - P1 ingestion disabled", _LOG.error("meter_source %r is not one of %s - P1 ingestion disabled",
source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED))) source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED,
SOURCE_HOMEWIZARD)))
return None return None
+14 -1
View File
@@ -45,10 +45,21 @@ options:
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below; # integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a # ha_signed reads ONE signed HA entity (+ import / - export), which is what a
# HomeWizard P1 publishes and what ha_dsmr cannot consume. # HomeWizard P1 publishes and what ha_dsmr cannot consume.
# homewizard_local skips Home Assistant entirely and polls the meter's own
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
# the meter REPORTED rather than when the value last CHANGED - HA emits
# nothing at all for a repeated reading - so it is the only mode a firmware
# watchdog may threshold. See DOCS.md.
meter_source: "off" meter_source: "off"
meter_phases: 1 meter_phases: 1
meter_max_age_s: 30 meter_max_age_s: 30
meter_mqtt_topic: "" meter_mqtt_topic: ""
# homewizard_local only. The meter's own address, `host` or `host:port`.
p1_host: ""
# homewizard_local only. Seconds between polls. Must be well under
# meter_max_age_s - the age is never fresher than this interval - and there is
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
meter_poll_s: 5
# The two UNSIGNED Belgian registers. The EMS derives net power from them # The two UNSIGNED Belgian registers. The EMS derives net power from them
# (import - export); do NOT point these at a signed template sensor. # (import - export); do NOT point these at a signed template sensor.
p1_import_entity: "" p1_import_entity: ""
@@ -109,14 +120,16 @@ schema:
batt_invert: bool batt_invert: bool
setpoint_entity: str setpoint_entity: str
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed) meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A # ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
# telegram whose phase count disagrees is rejected at ingest and logged, so a # telegram whose phase count disagrees is rejected at ingest and logged, so a
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a # mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
# quietly wrong number. # quietly wrong number.
meter_phases: int(1,3) meter_phases: int(1,3)
meter_max_age_s: int(5,300) meter_max_age_s: int(5,300)
meter_poll_s: int(1,60)
meter_mqtt_topic: str? meter_mqtt_topic: str?
p1_host: str?
p1_import_entity: str? p1_import_entity: str?
p1_export_entity: str? p1_export_entity: str?
p1_phase_import_entities: p1_phase_import_entities:
+488 -3
View File
@@ -11,6 +11,7 @@ computes the wrong quarter-hour figure whenever the telegram cadence changes.
""" """
import asyncio import asyncio
import json
import sys import sys
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -18,9 +19,11 @@ from datetime import datetime, timedelta, timezone
import aiohttp # already required by app.p1, so this adds no new dependency import aiohttp # already required by app.p1, so this adds no new dependency
from app.p1 import ( from app.p1 import (
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, QuarterAverager, P1Error, P1Ingest, HaDsmrSource, HaSignedSource, HomeWizardLocalSource,
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_MQTT, QuarterAverager,
build_source, is_enabled, make_sample, parse_mqtt_payload, split_signed, SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD, SOURCE_MQTT,
build_source, is_enabled, make_sample, parse_homewizard, parse_mqtt_payload,
split_signed,
) )
fails = [] fails = []
@@ -862,6 +865,432 @@ check("no phase list at all is still fine - per-phase billing is optional",
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"}, {"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"},
P1Ingest(phases=3), None, None), HaSignedSource)) P1Ingest(phases=3), None, None), HaSignedSource))
# --------------------------------------------------------------------------- #
print("homewizard_local: parsing the meter's own /api/v1/data document")
# The same JSON `sim/hwsim.py` serves and the same JSON the real meter at
# 192.168.2.250 serves. One SIGNED connection figure plus three signed legs, so
# it goes through the same split_signed as ha_signed - the sign convention is
# not re-derived on this transport, it is reused.
def hw_doc(w, l1=None, l2=None, l3=None, imp_kwh=1234.567, exp_kwh=890.123):
return {"wifi_ssid": "sim", "smr_version": 50, "meter_model": "SIM-P1",
"total_power_import_kwh": imp_kwh, "total_power_export_kwh": exp_kwh,
"active_power_w": w,
"active_power_l1_w": l1, "active_power_l2_w": l2,
"active_power_l3_w": l3, "total_gas_m3": 0.0}
s = parse_homewizard(hw_doc(775.0, l1=775.0), 1)
check("the overnight base load (+775 W) is import on this transport too",
s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0)
check("the sample is tagged homewizard_local", s.source == SOURCE_HOMEWIZARD)
s = parse_homewizard(hw_doc(-5710.0, l1=-5710.0), 1)
check("the midday PV peak (-5710 W) is export, not a 5.7 kW draw",
s.net_w == -5710.0 and s.export_w == 5710.0)
check("a single-phase document still yields its one leg", s.per_phase_w == (-5710.0,))
# The TEL-04 three-phase survey reading, served the HomeWizard way.
s = parse_homewizard(hw_doc(187.0, 2301.0, 468.0, -2582.0), 3)
check("signed legs keep the exporting phase negative",
s.per_phase_w == (2301.0, 468.0, -2582.0))
check("per-phase import clamps the exporting leg out of the billed figure",
s.per_phase_import_w == (2301.0, 468.0, 0.0))
check("the legs sum to 2769 W while the connection nets 187 W",
sum(s.per_phase_import_w) == 2769.0 and s.net_w == 187.0)
# ⚠️ A single-phase HomeWizard serves `active_power_l2_w: null`. Unlike the HA
# transports there is no "wait for the rest" case - every field came out of ONE
# response, so a missing leg cannot be a STALE leg. Dropping the whole sample
# over it would turn a healthy meter into a climbing age, which is the exact
# false trip this transport exists to remove.
def parsed(doc, phases=1):
"""parse_homewizard(), with an escaping exception turned into a visible value.
Same reason as `built()` above: if the "all legs or none" guard goes missing,
parsing raises, and without this the suite aborts on a traceback instead of
reddening the NAMED check that says which rule died.
"""
try:
return parse_homewizard(doc, phases)
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" parse_homewizard raised {type(err).__name__}: {err}")
return err
s = parsed(hw_doc(500.0, l1=500.0), 3)
check("a meter serving fewer legs than meter_phases still gives a reading",
getattr(s, "net_w", None) == 500.0 and getattr(s, "per_phase_w", "?") is None)
check("...and does not fabricate a partial phase tuple",
getattr(s, "per_phase_import_w", "?") is None)
raises("a response that is not a JSON object is rejected",
lambda: parse_homewizard([1, 2, 3], 1))
raises("a document with no active_power_w is rejected, not read as 0 W",
lambda: parse_homewizard({"total_gas_m3": 0.0}, 1))
raises("a null active_power_w is rejected", lambda: parse_homewizard(hw_doc(None), 1))
raises("a string active_power_w is rejected", lambda: parse_homewizard(hw_doc("775"), 1))
raises("a NaN active_power_w is rejected",
lambda: parse_homewizard(hw_doc(float("nan")), 1))
raises("the 64954 signed-decode contamination is refused here too",
lambda: parse_homewizard(hw_doc(64954.0), 1))
raises("a non-numeric leg is rejected rather than quietly dropped",
lambda: parse_homewizard(hw_doc(600.0, "2000", 0.0, 100.0), 3))
# --------------------------------------------------------------------------- #
print("homewizard_local: every HTTP response is an arrival")
# THE WHOLE TICKET. sensor.p1_sample_age_s has to mean "time since the meter
# REPORTED", not "time since the value CHANGED". Home Assistant cannot express
# the first: a repeated reading emits no state_changed, advances last_reported
# on neither serialiser, and state_reported cannot be subscribed to at all
# ("Event filter is required"). Measured on the ENV-01 rig over 70 s of a frozen
# meter and again against the live house over ten repeated readings. 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 - i.e. the age sensor would command 0 W on a
# perfectly healthy meter. Polling the meter itself removes that: the response is
# the arrival, and the number in it is not consulted.
class _FakeMeter:
"""hwsim in eight lines: serves whatever document it is told to, or a fault."""
def __init__(self, doc=None):
self.doc = doc
self.status = 200
self.body = None # set to raw text to serve something unparseable
self.delay = 0.0 # set to seconds to imitate a meter that hangs
self.hits = 0
async def handle(self, request):
from aiohttp import web
self.hits += 1
if self.delay:
await asyncio.sleep(self.delay)
# ⚠️ web.Response(text=...) defaults to text/plain. That is not just the
# "unparseable body" path: fed VALID json it serves a good document under
# the wrong mimetype, which is the only way to reach the content_type
# guard in poll_once - json_response can never produce it.
if self.body is not None:
return web.Response(text=self.body, status=self.status)
return web.json_response(self.doc, status=self.status)
async def _hw_rig(fn):
"""Run `fn(make_source, meter)` against a real HTTP server on localhost."""
from aiohttp import web
meter = _FakeMeter(hw_doc(350.0, l1=350.0))
app = web.Application()
app.router.add_get("/api/v1/data", meter.handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
async with aiohttp.ClientSession() as sess:
try:
return await fn(sess, port, meter)
finally:
await runner.cleanup()
async def _arrivals(sess, port, meter):
ing = P1Ingest(phases=1, max_age_s=30.0)
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
out = {}
out["first"] = await src.poll_once()
out["stamp1"] = ing.last.ingest_mono
# Four more polls of the IDENTICAL document - the meter has not moved a watt.
for _ in range(4):
await asyncio.sleep(0.05)
await src.poll_once()
out["ing"], out["src"], out["meter"] = ing, src, meter
return out
r = asyncio.run(_hw_rig(_arrivals))
ing, src = r["ing"], r["src"]
check("a poll of the meter's own API builds a sample",
r["first"] is True and ing.net_w == 350.0)
# ⚠️ THE acceptance criterion. Five identical readings, five arrivals. On
# ha_signed this whole sequence produces exactly ONE state_changed and then
# silence, and the age climbs to 30 s on a meter that is answering perfectly.
check("five identical readings are five arrivals, not one",
ing.samples == 5 and r["meter"].hits == 5 and src.polls == 5)
check("an unchanged value still stamps a NEW arrival time",
ing.last.ingest_mono > r["stamp1"])
check("...so the age resets on a response carrying an unchanged number",
ing.published_age_s < 1.0 and ing.stale is False)
check("no arrival was ever a parse error", ing.parse_errors == 0)
# --------------------------------------------------------------------------- #
print("homewizard_local: frozen vs steady, the pair HA cannot separate")
# ⚠️ Read this before changing anything here. A frozen meter (hwsim --fault
# freeze) answers 200 OK forever with a stale document. It is ARRIVING. So the
# age - an arrival detector, correctly - reads fresh on both, and that is not a
# defect in the age, it is the definition of the signal. What the local API adds
# that Home Assistant never had is the ENERGY REGISTERS: a meter under real load
# advances total_power_import_kwh (1 Wh resolution, ~10 s at 350 W) even when the
# power figure repeats, and a frozen one does not. That is the discriminator, and
# it is exposed as `unchanged_s` - deliberately NOT folded into the age, because
# this controller regulates grid power toward ~0 W and at a converged -10 W the
# export register needs six minutes to move. Thresholding unchanged_s at 30 s
# would rebuild the false-trip limit cycle at the exact operating point we aim
# for. See DOCS.md and HomeWizardLocalSource.unchanged_s.
async def _freeze_vs_steady(sess, port, meter):
ing_f = P1Ingest(phases=1, max_age_s=30.0)
frozen = HomeWizardLocalSource(sess, ing_f, "127.0.0.1", port=port, poll_s=1.0)
meter.doc = hw_doc(350.0, l1=350.0) # --fault freeze: never moves
for i in range(4):
if i:
await asyncio.sleep(0.1) # sleep BEFORE, so unchanged_s
await frozen.poll_once() # is read the instant a poll lands
ing_s = P1Ingest(phases=1, max_age_s=30.0)
steady = HomeWizardLocalSource(sess, ing_s, "127.0.0.1", port=port, poll_s=1.0)
kwh = 1234.567
for i in range(4):
if i:
await asyncio.sleep(0.1)
# A steady 350 W house: the power figure repeats, the register climbs.
kwh += 0.001
meter.doc = hw_doc(350.0, l1=350.0, imp_kwh=round(kwh, 3))
await steady.poll_once()
return (ing_f, frozen), (ing_s, steady)
(ing_f, frozen), (ing_s, steady) = asyncio.run(_hw_rig(_freeze_vs_steady))
check("a frozen meter keeps arriving, so both read the same power",
ing_f.net_w == 350.0 and ing_s.net_w == 350.0)
# ⚠️ Recorded as a rule, not a shortcoming: the age is an ARRIVAL detector and a
# frozen meter genuinely is arriving. Anyone tempted to make the age catch freeze
# is about to reintroduce the false trip on a steady house.
check("the age cannot separate them, and is fresh on both",
ing_f.published_age_s < 1.0 and ing_s.published_age_s < 1.0)
check("the frozen meter's measurement stands still", frozen.unchanged_s > 0.25)
check("...while the steady meter's energy register keeps advancing",
steady.unchanged_s < 0.05)
check("so the two ARE separable on the local API, which HA could not do",
frozen.unchanged_s > steady.unchanged_s * 3)
check("unchanged_s is None before the first response ever lands",
HomeWizardLocalSource(None, P1Ingest(), "h").unchanged_s is None)
# --------------------------------------------------------------------------- #
print("homewizard_local: a failed poll is a missing reading, never 0 W")
async def _failures(sess, port, meter):
ing = P1Ingest(phases=1, max_age_s=30.0)
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
await src.poll_once()
# Age the good sample by hand so a reset would be unmistakable.
ing.submit(make_sample(SOURCE_HOMEWIZARD, 350.0, 0.0, phases=1,
ingest_mono=time.monotonic() - 90.0))
out = {}
meter.status = 500
out["http500"] = await src.poll_once()
out["conn_after_500"] = src.connected
meter.status, meter.body = 200, "<html>gateway</html>"
out["notjson"] = await src.poll_once()
meter.body = None
meter.doc = {"total_gas_m3": 0.0} # 200 OK, no power in it
out["nopower"] = await src.poll_once()
out["ing"], out["src"] = ing, src
# And a meter that is not listening at all: `--fault down`.
dead = HomeWizardLocalSource(sess, P1Ingest(phases=1, max_age_s=30.0),
"127.0.0.1", port=1, poll_s=1.0)
out["down"] = await dead.poll_once()
out["dead"] = dead
return out
r = asyncio.run(_hw_rig(_failures))
ing, src = r["ing"], r["src"]
check("an HTTP 500 is not a reading", r["http500"] is False)
# A source that was connected and then failed must SAY it is disconnected -
# otherwise the diagnostic reads healthy while the age climbs, which is exactly
# the "connected: True, parse_errors: 0, samples: 0" state the rig recorded.
check("a poll that fails clears the connected flag", r["conn_after_500"] is False)
check("a 200 OK carrying something that is not JSON is not a reading",
r["notjson"] is False)
check("a 200 OK with no active_power_w in it is not a reading", r["nopower"] is False)
check("a meter that refuses the connection is not a reading",
r["down"] is False and r["dead"].connected is False)
check("every failure was counted as a parse error", ing.parse_errors == 3)
check("...and the transport records the transport-level ones separately",
src.poll_errors == 2 and src.polls == 4)
# ⚠️ The rule the safety chain rests on. A failed poll must not manufacture a
# balanced house, and must not reset the clock the watchdog reads.
check("a failed poll does not reset the age", ing.published_age_s > 89)
check("a failed poll leaves the last good value in place, and it is not 0 W",
ing.last.net_w == 350.0 and ing.stale is True and ing.net_w is None)
check("no failed poll ever became a sample", ing.samples == 2)
check("a 200 OK marks the transport connected even when its body is refused",
src.connected is True)
# --------------------------------------------------------------------------- #
print("homewizard_local: a wrong header, a hung meter, a submit that throws")
# Three failure shapes that all end the same way if they are mishandled - no
# sample, a climbing age, the battery at 0 W - and each of which would send the
# operator hunting the wrong device.
async def _header_and_timeout(sess, port, meter):
out = {}
# Valid JSON under text/plain: exactly what content_type=None is for.
meter.body = json.dumps(hw_doc(350.0, l1=350.0))
ing = P1Ingest(phases=1, max_age_s=30.0)
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
out["mimetype"] = await src.poll_once()
out["ing"] = ing
meter.body = None
# A meter that takes the connection and then does not answer.
meter.delay = 0.6
ing_t = P1Ingest(phases=1, max_age_s=30.0)
slow = HomeWizardLocalSource(sess, ing_t, "127.0.0.1", port=port,
poll_s=1.0, timeout_s=0.05)
out["timeout"] = await slow.poll_once()
out["timeout_err"] = ing_t.last_error
meter.delay = 0.0
return out
r = asyncio.run(_hw_rig(_header_and_timeout))
# ⚠️ A real firmware answering text/plain must not read as a dead meter. Drop
# content_type=None from poll_once and this goes red with "unexpected mimetype".
check("valid JSON under the wrong Content-Type is still a reading",
r["mimetype"] is True and r["ing"].samples == 1 and r["ing"].net_w == 350.0)
# ⚠️ str(asyncio.TimeoutError()) is the EMPTY STRING. Without the class-name
# fallback the status page reads "last error:" and then nothing, on a hung
# meter, at the moment someone is reading that line to find out why the battery
# went to 0 W.
check("a timed-out poll names the fault instead of logging an empty reason",
r["timeout"] is False and "TimeoutError" in (r["timeout_err"] or ""))
async def _submit_rejects(sess, port, meter):
"""submit() raising P1Error must be a rejection, not an escaping exception."""
class _P1Boom(P1Ingest):
def submit(self, sample):
raise P1Error("register went backwards")
ing = _P1Boom(phases=1, max_age_s=30.0)
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
try:
ok = await src.poll_once()
except Exception as err: # noqa: BLE001 - an escape IS the failure
ok = err
return ok, ing.parse_errors
ok, errs = asyncio.run(_hw_rig(_submit_rejects))
check("a submit that rejects the sample is handled, not left to escape",
ok is False and errs == 1)
async def _submit_explodes(sess, port, meter):
"""And an UNEXPECTED raise must not kill the poll task for good."""
class _Boom(P1Ingest):
def submit(self, sample):
raise RuntimeError("kaboom")
src = HomeWizardLocalSource(sess, _Boom(phases=1, max_age_s=30.0),
"127.0.0.1", port=port, poll_s=0.05)
task = asyncio.create_task(src.run())
await asyncio.sleep(0.3)
alive = not task.done()
task.cancel()
# ⚠️ BaseException, and the same reason built() exists: if run() loses its
# guard the task is already dead HOLDING the RuntimeError, and awaiting it
# re-raises - aborting the whole suite with a traceback instead of reddening
# the check that names the rule. The death is what `alive` records; catching
# it here is bookkeeping, not leniency.
try:
await task
except BaseException: # noqa: BLE001
pass
return alive, src.polls
# ⚠️ The silent-death case. A dead poll task fails SAFE - the age climbs and the
# controller commands 0 W - but it looks exactly like a dead meter, so the
# operator spends the outage power-cycling hardware that was never at fault.
alive, polls = asyncio.run(_hw_rig(_submit_explodes))
check("a raise inside a poll does not permanently kill the polling task",
alive is True and polls > 1)
# --------------------------------------------------------------------------- #
print("homewizard_local: selection by config")
check("homewizard_local is enabled", is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True)
sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "192.168.2.250"},
P1Ingest(), None, None)
check("meter_source homewizard_local selects the polling transport",
isinstance(sel, HomeWizardLocalSource))
# ⚠️ getattr, not attribute access, for the same reason `built()` exists: a
# startup guard that goes wrong returns None here, and `None.url` would abort the
# suite with a traceback instead of reddening the check that names the rule.
check("...pointed at the meter's own local API on the default port",
getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data")
check("the default cadence is the meter's own ~5 s update rate",
getattr(sel, "poll_s", None) == 5.0)
# ⚠️ The request must not outlive the poll interval: a backlog of queued requests
# behind a slow meter would land several arrivals for one measurement.
check("the request timeout is held under the poll interval",
getattr(sel, "timeout_s", 99) < getattr(sel, "poll_s", 0))
sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "10.0.0.9:8080"},
P1Ingest(), None, None)
check("an explicit host:port is honoured, which is how the sim is reached",
getattr(sel, "url", None) == "http://10.0.0.9:8080/api/v1/data")
sel = build_source({"meter_source": SOURCE_HOMEWIZARD,
"p1_host": "http://192.168.2.250/"}, P1Ingest(), None, None)
check("a pasted browser URL still resolves to the right host",
getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data")
# ⚠️ Caught once at startup, not once per poll - a source wired up wrong
# otherwise fails in the one way indistinguishable from a healthy one nobody has
# polled yet: no samples, a climbing age, the watchdog at 0 W, nothing in the log.
check("a blank p1_host is refused rather than silently never polling",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": ""},
P1Ingest(), None, None) is None)
check("...and whitespace does not sneak past it",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": " "},
P1Ingest(), None, None) is None)
check("an unparseable port is refused rather than guessed",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "meter:eighty"},
P1Ingest(), None, None) is None)
# ⚠️ The age can never be fresher than the poll interval, so a cadence at or past
# max_age_s means every reading is stale before its successor arrives: the
# controller would sit permanently on missing inputs while the meter is fine.
check("a poll cadence at meter_max_age_s is refused",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": 30}, P1Ingest(max_age_s=30), None, None) is None)
check("...and one past it too",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": 45}, P1Ingest(max_age_s=30), None, None) is None)
check("a cadence with headroom is accepted",
isinstance(build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": 5}, P1Ingest(max_age_s=30),
None, None), HomeWizardLocalSource))
check("a zero cadence is refused rather than spinning",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": 0}, P1Ingest(max_age_s=30), None, None) is None)
check("a non-numeric cadence is refused",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": "fast"}, P1Ingest(max_age_s=30), None, None) is None)
# The four modes must not bleed into each other.
check("ha_signed ignores p1_host and still selects the entity transport",
type(build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
"p1_host": "192.168.2.250"}, P1Ingest(), None, None))
is HaSignedSource)
check("homewizard_local ignores p1_net_entity and needs its own host",
build_source({"meter_source": SOURCE_HOMEWIZARD,
"p1_net_entity": "sensor.n"}, P1Ingest(), None, None) is None)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
print("the age sensor must not exist when P1 is off") print("the age sensor must not exist when P1 is off")
# ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog # ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog
@@ -877,6 +1306,8 @@ check("a missing meter_source is disabled", is_enabled({}) is False)
check("an empty meter_source is disabled", is_enabled({"meter_source": ""}) is False) check("an empty meter_source is disabled", is_enabled({"meter_source": ""}) is False)
check("ha_dsmr is enabled", is_enabled({"meter_source": SOURCE_HA}) is True) check("ha_dsmr is enabled", is_enabled({"meter_source": SOURCE_HA}) is True)
check("mqtt_p1 is enabled", is_enabled({"meter_source": SOURCE_MQTT}) is True) check("mqtt_p1 is enabled", is_enabled({"meter_source": SOURCE_MQTT}) is True)
check("homewizard_local is enabled here too",
is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True)
# The entity id SAFETY-01's firmware subscribes to, pinned by object_id. # The entity id SAFETY-01's firmware subscribes to, pinned by object_id.
row = [s for s in SENSORS if s[0] == "p1_age"] row = [s for s in SENSORS if s[0] == "p1_age"]
@@ -957,6 +1388,60 @@ Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig).publish(
check("with ha_signed selected, p1_age is published too", check("with ha_signed selected, p1_age is published too",
"p1_age" in pub_sig.last and isinstance(pub_sig.last["p1_age"], float)) "p1_age" in pub_sig.last and isinstance(pub_sig.last["p1_age"], float))
# ⚠️ And on homewizard_local, where it is the one age FW-01 may actually
# threshold - every other transport's age measures when the VALUE changed.
pub_hw = _Pub()
Controller({"meter_source": SOURCE_HOMEWIZARD}, None, _Store(), pub_hw).publish()
check("with homewizard_local selected, p1_age is published",
"p1_age" in pub_hw.last and isinstance(pub_hw.last["p1_age"], float))
# --------------------------------------------------------------------------- #
print("unchanged_s has somewhere an operator can read it")
# ⚠️ The counter is deliberately NOT thresholded and NOT folded into the age -
# at the converged -10 W this controller aims for, a 1 Wh register needs six
# minutes to move, so any limit false-trips at the target operating point. That
# refusal only holds up if a HUMAN can interpret the number instead, and DOCS.md
# tells them "the transport tracks it as unchanged_s". Before this, nothing ever
# read the transport object back: connected, polls, poll_errors and unchanged_s
# were all write-only, and the documented signal existed nowhere an operator
# could see it.
def p1_rows(ctl):
"""The P1 status line(s), or the exception that stopped checks() making one.
Same reason as built(): a status line that raises would abort the suite with
a traceback instead of reddening the check that names the rule.
"""
try:
return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")]
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" checks() raised {type(err).__name__}: {err}")
return err
def _fed(source):
ctl = Controller({"meter_source": source}, None, _Store(), _Pub())
ctl.p1.submit(make_sample(source, 350.0, 0.0, phases=1,
ingest_mono=time.monotonic()))
return ctl
hw_ctl = _fed(SOURCE_HOMEWIZARD)
hw_src = HomeWizardLocalSource(None, hw_ctl.p1, "127.0.0.1")
hw_src._note(hw_doc(350.0, l1=350.0))
hw_ctl.p1_source = hw_src # what amain() does once the transport exists
rows = p1_rows(hw_ctl)
check("the healthy P1 status line reports the transport's unchanged_s",
isinstance(rows, list) and len(rows) == 1 and "unchanged" in rows[0])
# ⚠️ getattr, not attribute access: p1_source is None until amain() builds one,
# and ha_dsmr/ha_signed/mqtt_p1 have no such counter at all. Reaching for it
# directly would turn the whole status page into a 500 on every other transport.
ha_rows = p1_rows(_fed(SOURCE_HA))
check("...and the line is unharmed on a transport that has no such counter",
isinstance(ha_rows, list) and len(ha_rows) == 1 and "unchanged" not in ha_rows[0])
print() print()
if fails: if fails:
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}") print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")