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
+341 -3
View File
@@ -18,9 +18,11 @@ from datetime import datetime, timedelta, timezone
import aiohttp # already required by app.p1, so this adds no new dependency
from app.p1 import (
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, QuarterAverager,
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_MQTT,
build_source, is_enabled, make_sample, parse_mqtt_payload, split_signed,
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, HomeWizardLocalSource,
QuarterAverager,
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD, SOURCE_MQTT,
build_source, is_enabled, make_sample, parse_homewizard, parse_mqtt_payload,
split_signed,
)
fails = []
@@ -862,6 +864,333 @@ check("no phase list at all is still fine - per-phase billing is optional",
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"},
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.hits = 0
async def handle(self, request):
from aiohttp import web
self.hits += 1
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: 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")
# ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog
@@ -877,6 +1206,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("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("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.
row = [s for s in SENSORS if s[0] == "p1_age"]
@@ -957,6 +1288,13 @@ Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig).publish(
check("with ha_signed selected, p1_age is published too",
"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()
if fails:
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")