TEL-04: a third meter_source for a single signed entity
TEL-01 shipped ha_dsmr and mqtt_p1, and neither can read the meter that is
actually fitted here. The house has a HomeWizard P1 exposing ONE signed
entity, sensor.p1_meter_active_power (+ import, - export); ha_dsmr wants two
unsigned registers and refuses a negative one outright, which is every
exporting telegram. So sensor.p1_sample_age_s could not be produced at this
site, and FW-01's watchdog needs it - measured, not theoretical: the house P1
went 51.1 s and 36.2 s without a state change overnight, both past
meter_max_age_s 30, so without the age sensor the watchdog would false-trip
the battery to 0 W.
Adds meter_source: ha_signed, reading p1_net_entity (and optionally
p1_phase_net_entities in L1..L3 order for the capacity-tariff peak). The
derivation is split_signed(), sitting next to make_sample's subtraction for
the same reason it does - the moment a user is asked to write two template
sensors that split a signed value, the sign convention is back in unreviewed
YAML underneath a safety input, which is exactly what TEL-01 removed.
The transport is a subclass of HaDsmrSource overriding only _wanted() and
build(), so every rule TEL-01 established is inherited rather than
re-implemented: ingest timestamping, meter_max_age_s, the clock-recomputed
sensor.p1_sample_age_s republished ~1 Hz, the plausibility ceiling, the
"prime the cache from get_states but never build a sample out of it" rule,
"a reconnect emits nothing", and unavailable/unknown treated as a MISSING
reading and never as 0 W.
Defaults to off. An existing install is unaffected until it opts in.
test_p1.py: 122 -> 174 checks. Includes an end-to-end run of the new
transport against a fake Home Assistant websocket, and the sign convention
asserted against real captured readings from
sim/scenarios/ha-p1_meter_active_power-2026-08-{20,23}.json (-5710 W at
13:46 local under full sun is export; +775 W at midnight is import).
Non-vacuity: ten mutations of the new rules, each applied alone and reverted
byte-identical. Nine turn the suite red. The tenth - splitting the per-phase
signed values rather than passing them through - is an equivalent mutant,
because make_sample subtracts the two lists again and does not sign-check
per-phase figures. That is recorded in a ponytail: comment at the site rather
than left for the next reviewer to rediscover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
This commit is contained in:
co-authored by
Claude Opus 5
parent
c24bc0a011
commit
8b51a51e20
@@ -18,8 +18,9 @@ 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, QuarterAverager, SOURCE_HA, SOURCE_MQTT,
|
||||
make_sample, parse_mqtt_payload,
|
||||
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, QuarterAverager,
|
||||
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_MQTT,
|
||||
build_source, is_enabled, make_sample, parse_mqtt_payload, split_signed,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -548,6 +549,271 @@ check("the averager integrated the live stream", live.averager.elapsed_s > 0.2)
|
||||
check("the primed cache let the first telegram build immediately",
|
||||
live.samples == 2 and live.last.import_w == 0.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed: one signed entity -> the same two magnitudes")
|
||||
# The meter actually fitted at this house is a HomeWizard P1 publishing ONE
|
||||
# signed sensor. ha_dsmr cannot read it - it wants two unsigned registers and
|
||||
# refuses a negative one, which is every exporting telegram.
|
||||
|
||||
check("a positive reading is import", split_signed(1500.0) == (1500.0, 0.0))
|
||||
check("a negative reading is export", split_signed(-900.0) == (0.0, 900.0))
|
||||
check("zero is a balanced reading, not a missing one",
|
||||
split_signed(0.0) == (0.0, 0.0))
|
||||
check("exactly one magnitude is ever non-zero",
|
||||
all(a == 0.0 or b == 0.0 for a, b in
|
||||
(split_signed(v) for v in (-5710.0, -1.0, 0.0, 1.0, 4384.0))))
|
||||
# ⚠️ The split must not change the number. A control loop steered by a value
|
||||
# that was rounded or clamped on the way in is steered by a different meter.
|
||||
check("the split round-trips the signed value exactly",
|
||||
all(make_sample(SOURCE_HA_SIGNED, *split_signed(v), phases=1).net_w == v
|
||||
for v in (-11763.0, -5710.0, -0.5, 0.0, 0.5, 775.0, 4384.0)))
|
||||
|
||||
raises("a non-numeric signed reading is rejected", lambda: split_signed("n/a"))
|
||||
raises("a signed None is rejected, not read as zero", lambda: split_signed(None))
|
||||
raises("a signed NaN is rejected", lambda: split_signed(float("nan")))
|
||||
raises("a signed infinity is rejected", lambda: split_signed(float("inf")))
|
||||
# ⚠️ This one matters MORE on the signed path than on the unsigned one. On
|
||||
# ha_dsmr the §20 contamination is also caught by "unsigned cannot be negative";
|
||||
# here 64954 arrives as a perfectly well-formed positive signed reading and the
|
||||
# plausibility ceiling is the only thing standing in front of it.
|
||||
raises("the 64954 signed-decode contamination is still rejected",
|
||||
lambda: split_signed(64954.0))
|
||||
raises("...and its negative twin too", lambda: split_signed(-64954.0))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed: the sign convention, against real captured readings")
|
||||
# ⚠️ Not a datasheet claim. These are literal values out of
|
||||
# sim/scenarios/ha-p1_meter_active_power-2026-08-{20,23}.json, HA recorder
|
||||
# exports of sensor.p1_meter_active_power at this house, copied here rather than
|
||||
# read from that repo so this file still runs on a laptop with nothing installed
|
||||
# (§17). If the convention were inverted, the physics below would be absurd.
|
||||
|
||||
# 2026-08-23T11:46:52Z - the day's most negative reading, 13:46 local, full sun.
|
||||
s = make_sample(SOURCE_HA_SIGNED, *split_signed(-5710.0), phases=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 and s.import_w == 0.0)
|
||||
# 2026-08-19T22:00:00Z - midnight local, 20 Aug's file starts here. No sun.
|
||||
s = make_sample(SOURCE_HA_SIGNED, *split_signed(775.0), phases=1)
|
||||
check("the overnight base load (+775 W) is IMPORT",
|
||||
s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0)
|
||||
# 2026-08-20T12:20:58Z - 14:20 local, the largest export in either capture.
|
||||
s = make_sample(SOURCE_HA_SIGNED, *split_signed(-11763.0), phases=1)
|
||||
check("the -11763 W midday extreme is export and survives the ceiling",
|
||||
s.net_w == -11763.0 and s.export_w == 11763.0)
|
||||
# 2026-08-23T10:18:28Z - the largest import in the healthy capture.
|
||||
s = make_sample(SOURCE_HA_SIGNED, *split_signed(4384.0), phases=1)
|
||||
check("the +4384 W peak is import", s.net_w == 4384.0 and s.import_w == 4384.0)
|
||||
# The whole convention in one line: night draws, midday feeds back.
|
||||
check("night is positive and midday is negative, which is the convention",
|
||||
split_signed(775.0)[0] > 0 and split_signed(-5710.0)[1] > 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed transport: building a sample out of one entity state")
|
||||
|
||||
NET = {"net": "sensor.p1_meter_active_power", "phase_net": []}
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
sig = HaSignedSource(None, ing, NET, token="x")
|
||||
|
||||
check("nothing cached yet builds nothing", sig.build() is False and ing.last is None)
|
||||
sig._absorb("sensor.p1_meter_active_power", "1000")
|
||||
check("one signed entity is a complete telegram on its own",
|
||||
sig.build() is True and ing.net_w == 1000.0)
|
||||
check("the sample is tagged with its own transport",
|
||||
ing.last.source == SOURCE_HA_SIGNED)
|
||||
sig._absorb("sensor.p1_meter_active_power", "-2500")
|
||||
sig.build()
|
||||
check("a negative state lands as a negative net", ing.net_w == -2500.0)
|
||||
|
||||
before = ing.last
|
||||
sig._absorb("sensor.p1_meter_active_power", "unavailable")
|
||||
check("an unavailable signed entity is a parse error", ing.parse_errors == 1)
|
||||
check("an unavailable entity does not build a sample", sig.build() is False)
|
||||
# ⚠️ The rule the whole ticket turns on: a missing reading is MISSING. Resolving
|
||||
# it to 0 W would read as a perfectly balanced house and defeat the staleness
|
||||
# trigger that FW-01's watchdog is built on.
|
||||
check("an unavailable entity leaves the last good sample untouched, not 0 W",
|
||||
ing.last is before and ing.net_w == -2500.0)
|
||||
sig._absorb("sensor.p1_meter_active_power", "unknown")
|
||||
check("an unknown signed entity is treated the same way", ing.parse_errors == 2)
|
||||
sig._absorb("sensor.p1_meter_active_power", "banana")
|
||||
check("a non-numeric signed state is a parse error, not 0 W",
|
||||
ing.parse_errors == 3 and ing.net_w == -2500.0)
|
||||
sig._absorb("sensor.p1_meter_active_power", "64954")
|
||||
check("64954 is refused at the signed transport too",
|
||||
sig.build() is False and ing.parse_errors == 4)
|
||||
sig._absorb("sensor.not_ours", "123")
|
||||
check("an unsubscribed entity is never cached by the signed transport",
|
||||
"sensor.not_ours" not in sig.cache)
|
||||
|
||||
# A rejected reading must not make the age look fresh - the age is what the
|
||||
# firmware watchdog reads, and a rejection is exactly when it must keep climbing.
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
sig = HaSignedSource(None, ing, dict(NET), token="x")
|
||||
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
|
||||
ingest_mono=time.monotonic() - 20.0))
|
||||
sig._absorb("sensor.p1_meter_active_power", "unavailable")
|
||||
sig.build()
|
||||
check("a rejected reading does not reset the published age",
|
||||
ing.published_age_s > 19 and ing.net_w == 1200.0)
|
||||
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
|
||||
ingest_mono=time.monotonic() - 40.0))
|
||||
check("...and the age keeps climbing past max_age_s on its own",
|
||||
ing.stale is True and ing.net_w is None)
|
||||
|
||||
# The three-phase reading the TEL-04 survey recorded at this house: L1 +2301 W,
|
||||
# L2 +468 W, L3 -2582 W, netting +187 W. A signed per-phase set splits the same
|
||||
# way, and the exporting phase must still clamp out of the billed figure.
|
||||
ing3 = P1Ingest(phases=3, max_age_s=30.0)
|
||||
NET3 = {"net": "sensor.p1_meter_active_power",
|
||||
"phase_net": ["sensor.p1_l1", "sensor.p1_l2", "sensor.p1_l3"]}
|
||||
sig3 = HaSignedSource(None, ing3, NET3, token="x")
|
||||
for eid, val in (("sensor.p1_meter_active_power", "187"), ("sensor.p1_l1", "2301")):
|
||||
sig3._absorb(eid, val)
|
||||
check("an incomplete signed phase set waits instead of guessing", sig3.build() is False)
|
||||
sig3._absorb("sensor.p1_l2", "468")
|
||||
sig3._absorb("sensor.p1_l3", "-2582")
|
||||
check("a complete signed three-phase set builds", sig3.build() is True)
|
||||
check("signed per-phase entities keep the exporting phase negative",
|
||||
ing3.last.per_phase_w == (2301.0, 468.0, -2582.0))
|
||||
check("per-phase IMPORT clamps the exporting phase to zero",
|
||||
ing3.last.per_phase_import_w == (2301.0, 468.0, 0.0))
|
||||
check("the phase import sum is 2769 W while the connection nets 187 W",
|
||||
sum(ing3.last.per_phase_import_w) == 2769.0 and ing3.last.net_w == 187.0)
|
||||
check("the signed per-phase tuple length matches meter_phases",
|
||||
len(ing3.last.per_phase_w) == ing3.phases == 3)
|
||||
|
||||
sig_bad = HaSignedSource(None, P1Ingest(phases=3, max_age_s=30.0),
|
||||
{"net": "sensor.net", "phase_net": ["sensor.a", "sensor.b"]},
|
||||
token="x")
|
||||
for eid in ("sensor.net", "sensor.a", "sensor.b"):
|
||||
sig_bad._absorb(eid, "100")
|
||||
check("two phases delivered against meter_phases 3 is rejected, not padded",
|
||||
sig_bad.build() is False and sig_bad.ingest.last is None
|
||||
and sig_bad.ingest.parse_errors == 1)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed transport: end to end against a fake Home Assistant")
|
||||
# The transport is a subclass, so this is what proves the INHERITED machinery -
|
||||
# auth, subscribe, the get_states priming rule, the reconnect-emits-nothing
|
||||
# rule - still behaves when only _wanted() and build() were replaced.
|
||||
|
||||
|
||||
async def _e2e_signed():
|
||||
from aiohttp import web
|
||||
import app.p1 as p1mod
|
||||
|
||||
done = asyncio.Event()
|
||||
eid = "sensor.p1_meter_active_power"
|
||||
|
||||
async def fake_ha(request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
await ws.send_json({"type": "auth_required", "ha_version": "2026.8"})
|
||||
auth = await ws.receive_json()
|
||||
assert auth["type"] == "auth" and auth["access_token"] == "tok"
|
||||
await ws.send_json({"type": "auth_ok"})
|
||||
sub = await ws.receive_json()
|
||||
assert sub["type"] == "subscribe_events"
|
||||
await ws.send_json({"id": sub["id"], "type": "result", "success": True})
|
||||
get = await ws.receive_json()
|
||||
assert get["type"] == "get_states"
|
||||
await ws.send_json({"id": get["id"], "type": "result", "success": True, "result": [
|
||||
{"entity_id": eid, "state": "775.0"},
|
||||
{"entity_id": "sensor.something_else", "state": "hello"},
|
||||
]})
|
||||
# Two real telegrams, both literal captured values: overnight import,
|
||||
# then the midday export peak.
|
||||
for val in ("775.0", "-5710.0"):
|
||||
await asyncio.sleep(0.5)
|
||||
await ws.send_json({"type": "event", "event": {"data": {
|
||||
"entity_id": eid,
|
||||
"new_state": {"entity_id": eid, "state": val}}}})
|
||||
await asyncio.sleep(0.5)
|
||||
await ws.send_json({"type": "event", "event": {"data": {
|
||||
"entity_id": eid,
|
||||
"new_state": {"entity_id": eid, "state": "unavailable"}}}})
|
||||
await asyncio.sleep(0.5)
|
||||
done.set()
|
||||
return ws
|
||||
|
||||
srv = web.Application()
|
||||
srv.router.add_get("/ws", fake_ha)
|
||||
runner = web.AppRunner(srv)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
p1mod.WS_URL = f"http://127.0.0.1:{port}/ws"
|
||||
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
async with aiohttp.ClientSession() as sess:
|
||||
src = HaSignedSource(sess, ing, dict(NET), token="tok")
|
||||
task = asyncio.get_running_loop().create_task(src.run())
|
||||
try:
|
||||
await asyncio.wait_for(done.wait(), 20)
|
||||
await asyncio.sleep(0.5)
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await runner.cleanup()
|
||||
return ing, src
|
||||
|
||||
|
||||
live, wire = asyncio.run(_e2e_signed())
|
||||
# ⚠️ TWO, not three - the same rule as the ha_dsmr e2e. get_states primes the
|
||||
# cache but must never become a sample: after a Core restart it is a
|
||||
# RestoreEntity value of unknown age, and stamping it with ingest_ts=now reports
|
||||
# a fresh meter that may have been dead for an hour.
|
||||
check("ha_signed does not manufacture a sample from cached HA state",
|
||||
live.samples == 2)
|
||||
check("the signed telegrams arrived over a real websocket",
|
||||
live.last.source == SOURCE_HA_SIGNED)
|
||||
check("the final export telegram nets negative, over the wire",
|
||||
live.last.net_w == -5710.0 and live.last.export_w == 5710.0)
|
||||
check("ha_signed subscribes to the one entity and caches nothing else",
|
||||
wire.ids == {"sensor.p1_meter_active_power"}
|
||||
and "sensor.something_else" not in wire.cache)
|
||||
check("a mid-stream unavailable signed state is a parse error, not a sample",
|
||||
live.parse_errors == 1 and live.samples == 2)
|
||||
# ⚠️ And the cached half is DROPPED, so no later telegram can be assembled out
|
||||
# of a value that stopped reporting.
|
||||
check("an unavailable entity is evicted from the cache", wire.cache == {})
|
||||
check("the last good reading survives the unavailable, and is not 0 W",
|
||||
live.net_w == -5710.0)
|
||||
check("the averager integrated the live signed stream", live.averager.elapsed_s > 0.2)
|
||||
# ⚠️ The entity FW-01 waits on. It must be a number here exactly as it is on the
|
||||
# other transports - the house P1 went 51.1 s and 36.2 s between state changes
|
||||
# overnight, and without this the watchdog false-trips the battery to 0 W.
|
||||
check("sensor.p1_sample_age_s is a live number on this transport too",
|
||||
isinstance(live.published_age_s, float) and live.published_age_s >= 0.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed: selection by config")
|
||||
|
||||
check("ha_signed is enabled", is_enabled({"meter_source": SOURCE_HA_SIGNED}) is True)
|
||||
built = build_source({"meter_source": SOURCE_HA_SIGNED,
|
||||
"p1_net_entity": "sensor.p1_meter_active_power"},
|
||||
P1Ingest(), None, None)
|
||||
check("meter_source ha_signed selects the signed transport",
|
||||
isinstance(built, HaSignedSource))
|
||||
check("...wired to p1_net_entity, and subscribed to exactly that one entity",
|
||||
built.ids == {"sensor.p1_meter_active_power"})
|
||||
# ⚠️ The three modes must not bleed into each other: ha_dsmr must keep ignoring
|
||||
# p1_net_entity, or a half-configured install silently reads the wrong sensor.
|
||||
plain = build_source({"meter_source": SOURCE_HA,
|
||||
"p1_import_entity": "sensor.i", "p1_export_entity": "sensor.e",
|
||||
"p1_net_entity": "sensor.p1_meter_active_power"},
|
||||
P1Ingest(), None, None)
|
||||
check("ha_dsmr still selects the unsigned transport and ignores p1_net_entity",
|
||||
type(plain) is HaDsmrSource and plain.ids == {"sensor.i", "sensor.e"})
|
||||
check("meter_source off still selects nothing",
|
||||
build_source({"meter_source": "off"}, P1Ingest(), None, None) is None)
|
||||
check("an unrecognised meter_source selects nothing rather than guessing",
|
||||
build_source({"meter_source": "ha_signd"}, 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
|
||||
@@ -556,7 +822,6 @@ print("the age sensor must not exist when P1 is off")
|
||||
# age were published with meter_source off it would climb past 30 s on every
|
||||
# existing install within half a minute and pin the inverter at 0 W forever.
|
||||
|
||||
from app.p1 import is_enabled # noqa: E402
|
||||
from app.mqtt import SENSORS, MqttPublisher # noqa: E402
|
||||
|
||||
check("meter_source off is disabled", is_enabled({"meter_source": "off"}) is False)
|
||||
@@ -636,6 +901,14 @@ check("with P1 on, p1_age is published", "p1_age" in pub_on.last)
|
||||
check("...as a number, so has_state() becomes true only once we feed it",
|
||||
isinstance(pub_on.last["p1_age"], float))
|
||||
|
||||
# ⚠️ And on ha_signed identically - this is the whole reason TEL-04 exists. The
|
||||
# age sensor is a hard prerequisite for the FW-01 flash, and it has to appear on
|
||||
# the transport that can actually read the meter in this house.
|
||||
pub_sig = _Pub()
|
||||
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))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")
|
||||
|
||||
Reference in New Issue
Block a user