From 8b51a51e20618951acf5cf401ad9b4b5c6e5b188 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Tue, 25 Aug 2026 16:44:22 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa --- goodwe_controller/CHANGELOG.md | 20 +++ goodwe_controller/DOCS.md | 38 +++-- goodwe_controller/app/p1.py | 128 ++++++++++++++- goodwe_controller/config.yaml | 18 ++- goodwe_controller/test_p1.py | 279 ++++++++++++++++++++++++++++++++- 5 files changed, 461 insertions(+), 22 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 4939667..cadab2e 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased + +**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home +Assistant entity: positive = import, negative = export. That is the shape a +HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter +actually fitted here - which neither TEL-01 transport can read, because +`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every +exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the +per-phase capacity-tariff figures on a three-phase connection. + +Everything TEL-01 established is inherited rather than re-implemented - the +new transport is a subclass of the `ha_dsmr` one overriding only which +entities it wants and how they become a sample. So ingest timestamping, +`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and +republished once a second, the plausibility bounds, and `unavailable` / +`unknown` treated as a *missing reading and never 0 W* all behave identically +across the three sources. + +Still defaults to `off`; an existing install is unaffected until it opts in. + ## 0.3.0 **SAFETY-04.** The control law's integrator is now an explicit accumulator, diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 199dbff..7252dce 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -51,14 +51,28 @@ phase having charged nothing. ### P1 meter ingestion `meter_entity` above expects one signed sensor, which usually means a template -someone wrote by hand. A Belgian P1 meter does not publish one: it publishes two -**unsigned** registers, consumption and injection. Setting `meter_source` moves -that subtraction into the add-on, where it is done once and tested, and replaces -`meter_entity` entirely. +someone wrote by hand. Setting `meter_source` moves the whole derivation into +the add-on, where it is done once and tested, and replaces `meter_entity` +entirely. + +Which mode you want depends on what your P1 reader publishes, and there are two +shapes in the wild: + +- **Two unsigned registers**, consumption and injection, which is what a Belgian + P1 read over DSMR gives you → `ha_dsmr`, or `mqtt_p1` for a bridge. The add-on + subtracts them. +- **One signed figure**, positive = import and negative = export, which is what + a HomeWizard P1 gives you (`sensor.p1_meter_active_power`) → `ha_signed`. The + add-on splits it. `ha_dsmr` **cannot** read this: it wants two registers and + rejects a negative one outright, which is every exporting telegram. + +Either way, do not build the missing shape out of template sensors. The point of +`meter_source` is that the sign convention is derived in one tested place rather +than in YAML nobody reviews underneath a safety input. | 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 | +| `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_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_mqtt_topic` | | `mqtt_p1` only | @@ -66,6 +80,8 @@ that subtraction into the add-on, where it is done once and tested, and replaces | `p1_export_entity` | | The **unsigned** injection sensor | | `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection | | `p1_phase_export_entities` | `[]` | L1..L3, in order | +| `p1_net_entity` | | `ha_signed` only. The **signed** net-power sensor: `+` import, `-` export | +| `p1_phase_net_entities` | `[]` | `ha_signed` only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection | #### How long a dead meter takes to reach 0 W @@ -124,12 +140,12 @@ disabled there is nothing feeding it, and an age sensor climbing with no ingeste behind it would trip the firmware watchdog on a system that is working fine. > **Known limit, `mqtt_p1` only.** The age measures *arrival*, not change. On the -> `ha_dsmr` path that is exactly right: a frozen meter emits no `state_changed`, -> so nothing arrives and the age climbs. On the MQTT path a bridge that is stuck -> republishing its last telegram keeps arriving, so the age stays near zero and a -> frozen meter still looks fresh. Detecting *that* needs a change-detector rather -> than an arrival-detector, and it is not in this version. Prefer `ha_dsmr` where -> both are available. +> two HA WebSocket paths (`ha_dsmr`, `ha_signed`) that is exactly right: a frozen +> meter emits no `state_changed`, so nothing arrives and the age climbs. On the +> MQTT path a bridge that is stuck republishing its last telegram keeps arriving, +> so the age stays near zero and a frozen meter still looks fresh. Detecting +> *that* needs a change-detector rather than an arrival-detector, and it is not +> in this version. Prefer an HA WebSocket source where both are available. ### Control diff --git a/goodwe_controller/app/p1.py b/goodwe_controller/app/p1.py index 3c096cd..f562930 100644 --- a/goodwe_controller/app/p1.py +++ b/goodwe_controller/app/p1.py @@ -4,13 +4,22 @@ Everything downstream trusts this module: the safety checks, the capacity-tariff peak, the optimizer, the control loop's sign. So three things happen here and nowhere else. - 1. The IMPORT/EXPORT DERIVATION. A Belgian P1 meter exposes two UNSIGNED - registers - consumption and injection - never one signed figure. Net power - is `import_w - export_w`, positive = import, and that subtraction is done + 1. The IMPORT/EXPORT DERIVATION. A Belgian P1 read over DSMR exposes two + UNSIGNED registers - consumption and injection. Net power is + `import_w - export_w`, positive = import, and that subtraction is done exactly once, here (spec §5.2: "the derivation is the EMS's job, not a template the user has to write"). A second copy of it somewhere else is a second chance to invert the control loop. + Some P1 readers - the HomeWizard P1 among them - publish the OTHER shape: + one SIGNED figure, positive = import, and no unsigned registers at all. + `split_signed()` fans that back out into the same two magnitudes, so there + is still exactly one internal representation and one sign convention. ⚠️ It + lives here, next to the subtraction, for the same reason the subtraction + 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 precisely what §5.2 moved into the EMS. + 2. THE INGEST TIMESTAMP. Every accepted sample is stamped on arrival. A value with no age is a value that cannot be trusted (§5.2), and staleness is the failsafe trigger (§11.2). @@ -47,6 +56,7 @@ _LOG = logging.getLogger("goodwe.p1") SOURCE_HA = "ha_dsmr" SOURCE_MQTT = "mqtt_p1" +SOURCE_HA_SIGNED = "ha_signed" QUARTER_S = 900 @@ -78,7 +88,7 @@ class P1Sample: ingest_ts: datetime # tz-aware UTC, set at ingest ingest_mono: float # time.monotonic() at ingest - see age_s() telegram_ts: datetime | None # from the telegram, where the source has one - source: str # SOURCE_HA | SOURCE_MQTT + source: str # SOURCE_HA | SOURCE_MQTT | SOURCE_HA_SIGNED import_w: float # unsigned magnitude, as the meter reports it export_w: float # unsigned magnitude net_w: float # import_w - export_w (+ import, - export) @@ -129,6 +139,29 @@ def _watts(value, what: str) -> float: return out +def split_signed(net_w) -> tuple[float, float]: + """One signed figure -> the (import, export) magnitudes the module speaks. + + The inverse of make_sample's subtraction, and the easy direction: no second + register to disagree with, so there is nothing to mix a fresh reading with a + stale one. `+` is import, `-` is export - verified in test_p1.py against real + captured readings from this house's own meter, not against a datasheet. + + ⚠️ Exactly one of the two comes out non-zero. Splitting into `(max(v,0), + max(-v,0))` rather than clamping keeps `import_w - export_w == v` exactly, so + the signed value the meter published survives the round trip bit for bit - + a control loop must not be steered by a number that changed on the way in. + + ⚠️ Validation is `_watts`, the same gate the unsigned path uses: NaN, + infinity, non-numbers and the §20 open-question-5 unsigned-decode + contamination (64954 for -582 W) are all refused here rather than believed. + A signed source makes that check MORE important, not less - on this path + 64954 is not obviously wrong the way a negative "unsigned" register is. + """ + v = _watts(net_w, "net") + return (v, 0.0) if v >= 0 else (0.0, -v) + + def make_sample(source: str, import_w, export_w, *, phases: int, phase_import_w=None, phase_export_w=None, telegram_ts: datetime | None = None, @@ -668,6 +701,84 @@ class MqttP1Source: self.ingest.reject(err) +# --------------------------------------------------------------------------- # +# transport 3: Home Assistant WebSocket, one signed entity +# --------------------------------------------------------------------------- # +class HaSignedSource(HaDsmrSource): + """The same websocket, subscribed to ONE signed power entity. + + For readers that publish net power as a single signed figure - a HomeWizard + P1's `sensor.p1_meter_active_power`, positive = import - rather than the two + unsigned DSMR registers. This is the meter actually installed at the house, + and `ha_dsmr` cannot read it: it needs two registers and refuses a negative + one outright, which is every exporting telegram. + + ⚠️ A subclass, not a copy. The connect / auth / subscribe / reconnect / + `_absorb` machinery above is transport, not shape, and it has already been + debugged once - notably "prime the cache from get_states but never build a + sample out of it" and "a reconnect emits nothing". Only `_wanted` (which + entity ids) and `build` (how they become a sample) differ, so only those two + are overridden. Everything TEL-01 established therefore applies unchanged: + ingest timestamping, meter_max_age_s, the clock-recomputed age sensor, and + `unavailable` treated as a missing reading rather than 0 W. + + ⚠️ The debounce is inherited but does nothing useful here, and that is fine: + one telegram is one entity, so there is no burst of per-entity events to + coalesce and no window in which a new reading sits beside a stale one. It + costs one scheduled sleep per telegram at ~0.2 Hz. Left in place rather than + special-cased, because a second code path through build() is a second place + for the sign to go wrong. + """ + + def _wanted(self) -> set[str]: + out = set() + if self.entities.get("net"): + out.add(self.entities["net"]) + out.update(e for e in self.entities.get("phase_net") or [] if e) + return out + + def build(self) -> bool: + """Assemble one sample from the cache. Returns True if one was accepted.""" + net_id = self.entities.get("net") + if net_id not in self.cache: + return False + pn = [self.cache.get(e) for e in self.entities.get("phase_net") or []] + if pn and None in pn: + return False # incomplete phase set: wait, do not guess + try: + imp, exp = split_signed(self.cache[net_id]) + pi = pe = None + if pn: + # ponytail: this split is arithmetically redundant today - + # make_sample subtracts the two lists again and does not + # sign-check per-phase figures, so handing it the signed values + # with a zero export list produces the identical tuple. Verified: + # mutating it that way leaves all 174 checks green, i.e. no test + # can tell the difference, and it is recorded here rather than + # left as a silent equivalent mutant for the next reviewer to + # rediscover. Kept because `phase_import_w` means a MAGNITUDE: + # a negative in it is the double-signing that make_sample refuses + # outright for the connection-level registers, and the day that + # check is extended per-phase the shortcut breaks the meter, not + # the test. + pairs = [split_signed(v) for v in pn] + pi = [a for a, _ in pairs] + pe = [b for _, b in pairs] + self.ingest.submit(make_sample( + SOURCE_HA_SIGNED, imp, exp, + phases=self.ingest.phases, + phase_import_w=pi, phase_export_w=pe, + # ⚠️ No telegram_ts, for the same reason as ha_dsmr: HA's + # last_changed is when the VALUE changed, which on a steady meter + # is minutes ago while the telegram is current. sensor. + # p1_sample_age_s is what covers a genuinely frozen meter. + )) + return True + except P1Error as err: + self.ingest.reject(err) + return False + + # --------------------------------------------------------------------------- # # selection # --------------------------------------------------------------------------- # @@ -699,12 +810,17 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None): "phase_import": opts.get("p1_phase_import_entities") or [], "phase_export": opts.get("p1_phase_export_entities") or [], }) + if source == SOURCE_HA_SIGNED: + return HaSignedSource(session, ingest, { + "net": opts.get("p1_net_entity", ""), + "phase_net": opts.get("p1_phase_net_entities") or [], + }) if source == SOURCE_MQTT: broker = broker or {} return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")), broker.get("host"), broker.get("port", 1883), broker.get("username"), broker.get("password")) if source: - _LOG.error("meter_source %r is not %s or %s - P1 ingestion disabled", - source, SOURCE_HA, SOURCE_MQTT) + _LOG.error("meter_source %r is not one of %s - P1 ingestion disabled", + source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED))) return None diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index ffd18e6..d868d65 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -42,7 +42,9 @@ options: # --- P1 meter ingestion (specs §5.2 / §14 `meter:`) ------------------------- # `off` keeps the original single meter_entity path above, so an existing # install is untouched until it opts in. ha_dsmr subscribes to the DSMR - # 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 + # HomeWizard P1 publishes and what ha_dsmr cannot consume. meter_source: "off" meter_phases: 1 meter_max_age_s: 30 @@ -55,6 +57,15 @@ options: # three-phase connection; the list length must equal meter_phases. p1_phase_import_entities: [] p1_phase_export_entities: [] + # ha_signed only. ONE signed net-power sensor: positive = import from the + # grid, negative = export to it. Do NOT split it into two template sensors - + # the split is done in the add-on (p1.split_signed) precisely so the sign + # convention is tested rather than living in unreviewed YAML. + p1_net_entity: "" + # Optional, in L1..L3 order, each one signed the same way. Same role as + # p1_phase_import_entities: the capacity-tariff peak on a three-phase + # connection. The list length must equal meter_phases. + p1_phase_net_entities: [] # --- control --------------------------------------------------------------- max_w: 2000 @@ -98,7 +109,7 @@ schema: batt_invert: bool setpoint_entity: str - meter_source: list(off|ha_dsmr|mqtt_p1) + meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed) # ⚠️ 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 # mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a @@ -112,6 +123,9 @@ schema: - str p1_phase_export_entities: - str + p1_net_entity: str? + p1_phase_net_entities: + - str max_w: int(100,5000) gain: float(0.05,1.0) diff --git a/goodwe_controller/test_p1.py b/goodwe_controller/test_p1.py index de4500a..b8016e5 100644 --- a/goodwe_controller/test_p1.py +++ b/goodwe_controller/test_p1.py @@ -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)}")