9 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 1b343da8e3 DOCS: say plainly that ha_dsmr and mqtt_p1 have never seen real hardware
No meter in this installation uses either transport. Both were written to
specs.md 5.2's assumption that a Belgian P1 exposes two unsigned registers,
which the meter actually fitted here does not - it is the HomeWizard P1 that
ha_signed reads. Their only coverage is test_p1.py and an end-to-end test
against a fake Home Assistant.

Deliberately not called "experimental". That word says the design is
unfinished, which is not the defect and is vaguer than the truth; these are
complete and reviewed, they have simply never had a real telegram through
them. The failure this note is guarding against is a future session debugging
a meter problem, treating those two paths as proven, and looking elsewhere.

Placed where the mode is chosen rather than in a footnote, because the choice
is the decision it should inform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 17:35:11 +02:00
glenn schrooyenandClaude Opus 5 e663e10245 TEL-04 review: unshadow the helper, validate config at startup, and record
what the rig proved about the age sensor

Review findings 1, 3, 5 and 6. Finding 2 is deliberately untouched - it is
its own ticket.

3. `built` was rebound at test_p1.py:816 by `built = build_source(...)`,
   silently disarming the build() wrapper for anything appended below it.
   Renamed to `sel`. Reproduced the reviewer's failure before fixing:
   appending a check that calls built() after that line gives
   `TypeError: 'HaSignedSource' object is not callable` and aborts at 163 of
   180; with the rename the same probe reaches 180 and passes.

5. DOCS.md now states the "length must equal meter_phases" constraint that
   config.yaml already carried, plus what leaving the list empty actually
   costs: on the surveyed reading the phases carry 2769 W of import while the
   connection nets 187 W, so the tariff quantity is understated ~15x.

6. build_source now checks the ha_signed wiring once at startup instead of
   once per telegram: a blank p1_net_entity, or a phase list whose length
   disagrees with meter_phases, logs an error and disables ingestion. Both
   otherwise fail in the single way indistinguishable from a healthy source
   nobody has fed yet - no samples, a climbing age, the watchdog holding the
   battery at 0 W, and nothing in the log.

1. THE AGE SENSOR. Measured on the ENV-01 rig against the real HomeWizard
   integration, meter frozen via hwsim's `?fault=freeze` seam (cleared in a
   finally:, rig verified restored):

     - websocket state_changed for the meter over 70 s : 0
     - last_reported advanced (REST serialiser)        : no
     - last_reported advanced (websocket serialiser)   : no
     - subscribe_events(state_reported)                : rejected,
       "Event filter is required for event state_reported"

   So Home Assistant exposes NO arrival signal for a repeated reading, and
   the proposed fix - stamp from last_reported via subscribe_entities - is
   not available. subscribe_entities listens only to EVENT_STATE_CHANGED, and
   as_compressed_state carries no last_reported at all.

   The age is therefore "time since the value changed", which on ha_dsmr is
   mostly harmless (a telegram moves several entities) and on ha_signed is
   not: one entity means a healthy meter under a flat load is
   indistinguishable from a dead one. Recorded loudly in DOCS.md, in the
   HaSignedSource docstring and in the CHANGELOG, with the measured 42.2 s
   and 97.0 s gaps from our own capture.

   meter_max_age_s is deliberately NOT widened. The two conditions produce an
   identical signal, so a larger number does not separate them - it only
   chooses which of the two errors you get, and it would disarm the watchdog
   for a genuinely dead meter as well. The honest fix is an arrival stamp the
   meter itself provides.

test_p1.py: 174 -> 179 checks, all green. Other three suites unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 17:34:37 +02:00
glenn schrooyenandClaude Opus 5 632be44f6c TEL-04: make a missing build() guard fail legibly instead of aborting
Review point from the non-vacuity run. Deleting build()'s "is the net entity
cached at all" guard makes build() raise KeyError rather than return False.
That still failed the suite, but by aborting it with a traceback at whichever
check ran first - a red that costs the next person ten minutes deciding
whether the suite is broken or the code is.

New `built()` helper in test_p1.py wraps build() and turns an escaping
exception into a returned value, so the comparison against True/False fails
by name. Applied only to the ha_signed section; TEL-01's own checks are
untouched.

Mutation 4 before: 0 named checks red, aborted at check 123 of 174.
Mutation 4 after:  2 named checks red - "nothing cached yet builds nothing"
and "an unavailable entity does not build a sample" - all 174 reached.

Still 174 checks, all green, and the other nine mutations are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 17:01:33 +02:00
glenn schrooyenandClaude Opus 5 8b51a51e20 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
2026-08-25 16:44:22 +02:00
glenn schrooyenandClaude Opus 5 c24bc0a011 Pin LF, because the deployment target is a Linux container
core.autocrlf=true gave the checkout CRLF .py and .yaml. run.sh happened to be
LF, which is the only reason a plain copy would not have produced a "bad
interpreter" failure on the add-on's entrypoint.

0.3.0 was deployed by extracting from git with autocrlf forced off and verified
byte-identical to the blobs before copying. This makes that the default rather
than something the deployer has to remember.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:01:44 +02:00
glenn schrooyenandClaude Opus 5 80402c978f DEPLOY-01: 0.3.0, so the update is installable at all
release/1.0 carried SAFETY-04 and TEL-01 but still declared version 0.2.1 -
identical to what is installed and running on the house. Home Assistant keys
add-on updates off the version string, so the update would never have been
offered.

Two files. No code, no option defaults. What reaches the house at these
defaults is SAFETY-04's control law alone, and it carries a 4,928-case
equivalence proof against the previous law at integrator_max_w: 0. TEL-01 is
inert until meter_source is turned on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:52:47 +02:00
glenn schrooyenandClaude Opus 5 6c980e87b0 DEPLOY-01: bump version to 0.3.0, changelog for SAFETY-04 and TEL-01
Fixes the version collision noticed while planning DEPLOY-01: release/1.0
still carried version 0.2.1, identical to what is already running on the
live system, so Home Assistant would not have offered the update at all.

- config.yaml: version 0.2.1 -> 0.3.0 (minor: TEL-01 adds a feature,
  SAFETY-04 changes the control law's internals)
- CHANGELOG.md: 0.3.0 entry for SAFETY-04 and TEL-01, in the existing voice

No code under app/ touched, no option defaults changed. Verified:
meter_source: off, integrator_max_w: 0, target_grid_w: -10 all unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 10:33:17 +02:00
glenn schrooyenandClaude Opus 5 505a847d85 TEL-01: P1 ingestion, with the derivation and the sample age the EMS owns
Evidence at sign-off: 122 checks in test_p1.py, 14 mutations all red with the
tree restored byte-identical, and an end-to-end run of the HA transport against
a fake Home Assistant websocket server with a real auth handshake. Age
semantics verified live rather than from fixtures - a real 2.2 s sleep with no
telegram arriving, age climbing 2.2004 s.

The deliverable that matters beyond this ticket is sensor.p1_sample_age_s:
recomputed against a monotonic clock and republished ~1 Hz rather than stamped
per telegram, so a meter frozen at a constant value - which pushes no state
change and therefore emits nothing - still shows an age that climbs. SAFETY-01's
firmware subscribes to it and trips on has_state() && state >= max_age_s.

Two blockers on the way, both of which would have shipped. With meter_source
off - the default, chosen for zero regression - the age was published anyway
and climbed without bound, which would have crossed max_age_s within half a
minute and pinned every installed inverter at 0 W. And a reconnect emitted a
synthetic sample that reset the age, hiding an outage from the watchdog that
exists to catch it, contradicting the module's own docstring while a test
asserted the violation.

Not verifiable without hardware, and not claimed: real DSMR entity ids and
units, whether a real P1 MQTT bridge matches the documented strict schema, the
0.35 s debounce against real telegram timing, and MQTT reconnect against a real
broker.

Known limits, both documented and filed as SAFETY-12: mqtt_p1 cannot detect a
frozen bridge that keeps republishing, and a value-frozen meter stops the
control loop cycling at all - the latter pre-existing and affecting the legacy
meter_entity path today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:17:34 +02:00
glenn schrooyenandClaude Opus 5 f498d5fa54 SAFETY-04: bound the integrator independently of the output
Evidence at sign-off: 55 checks in test_control.py (24 on release/1.0),
independently reproduced. 108,031 failsafe-release combinations swept across
both grid directions, 2,500 randomised carried-i_w trajectories at 200 ticks,
300 repeated-meter-value stall scenarios - zero anomalies. The historical
runaway regression uses the real 10.3 numbers and goes red when the protection
is removed.

Two rejections on the way. The first cut deadlocked the loop at small |i_w| and
let the accumulator run 50% past the rail, which ADDED windup this codebase
never had - the accumulator used to be the post-clamp command, so it could not
exceed max_w by construction. The second deadlocked at i_w == 0.0 in the export
direction, found by sweeping the boundary after three reviewers had each
covered the same half of it.

The finding worth keeping: deleting the integrator freeze outright failed 0 of
55 checks, because the integrator bound truncated to exactly the value the
fixture asserted. A neighbouring mechanism was standing in for the one under
test. The same pattern turned up again in the output clamp. test_control.py now
carries the audit table and its invariant - every mechanism in compute() must
be noticed by at least two checks when deleted.

Deliberately not met as literally written: the detector counts cycles, not the
10 s the AC specifies. compute() is clockless and cycle() runs only on a
changed meter reading, so there is no wall-clock window at all. Documented in
the code and in DOCS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:17:18 +02:00
6 changed files with 652 additions and 24 deletions
+10
View File
@@ -0,0 +1,10 @@
# This add-on is deployed to a Linux container. core.autocrlf=true on the
# authoring box gave the checkout CRLF, so a plain copy shipped CRLF files -
# run.sh with CRLF is a "bad interpreter" failure, and any hash-based drift
# check between repo and deployment fails for a reason that has nothing to do
# with the code. Deploy with:
# git -c core.autocrlf=false archive release/1.0 goodwe_controller | tar -x
# which is how 0.3.0 went out, byte-identical to the blobs.
* text=auto eol=lf
*.png binary
*.gz binary
+70
View File
@@ -1,5 +1,75 @@
# Changelog # 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.
⚠️ **`sensor.p1_sample_age_s` is published on `ha_signed`, but must not yet be
thresholded by the ESP32 stale-input watchdog.** On the HA WebSocket paths the
age is stamped from `state_changed`, so it measures time since the value
*changed*, not since the meter *reported* - and Home Assistant exposes no
arrival signal for a repeated reading (no `state_changed`, no `last_reported`
movement on either serialiser, and `state_reported` is not subscribable over
the WebSocket). Measured on the ENV-01 rig against the real HomeWizard
integration. `ha_dsmr` mostly escapes it because a telegram moves several
entities at once; `ha_signed` has one, so a healthy meter under a flat load is
indistinguishable from a dead one. Our own capture has the house meter going
42.2 s and 97.0 s between changes. Raising `meter_max_age_s` does not fix that,
it only chooses which error you get; the fix is an arrival stamp from the meter
itself and is a separate ticket. Full detail in DOCS.md.
## 0.3.0
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
bounded independently of the output clamp instead of inheriting whatever
headroom the clamp happened to leave. It also freezes while the inverter is
not tracking, rather than continuing to wind up against a command nothing is
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
"follow `max_w`", which is the existing behaviour.
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
against the previous control law confirms it decides identically at
`integrator_max_w: 0`.
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
(consumption, injection) no longer need a hand-written signed template
sensor: the subtraction moves into the add-on, done once and tested. Two
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
nothing changes for an install that does not opt in.
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
accepted telegram, recomputed against the clock and republished roughly once
a second rather than only when a telegram lands. That is deliberate - Home
Assistant only pushes a state on change, so a meter sitting at a genuinely
constant reading would otherwise look identical to a dead one. Watching the
age instead means a frozen meter shows a climbing age, not a flat line. The
firmware watchdog subscribes to this exact entity id.
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
republishing its last telegram still "arrives", so the age cannot detect
that particular failure - prefer `ha_dsmr` where both are available. And a
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
to call the reading stale, then 15 s of `stale_input_s` on top), which is
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
## 0.2.1 ## 0.2.1
`target_grid_w` (default -10 W): what the meter should rest at. The deadband `target_grid_w` (default -10 W): what the meter should rest at. The deadband
+73 -12
View File
@@ -51,14 +51,40 @@ phase having charged nothing.
### P1 meter ingestion ### P1 meter ingestion
`meter_entity` above expects one signed sensor, which usually means a template `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 someone wrote by hand. Setting `meter_source` moves the whole derivation into
**unsigned** registers, consumption and injection. Setting `meter_source` moves the add-on, where it is done once and tested, and replaces `meter_entity`
that subtraction into the add-on, where it is done once and tested, and replaces entirely.
`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.
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
> hardware.** No meter in this installation uses either one. Both were written
> to the assumption in `specs.md` §5.2 that a Belgian P1 exposes two unsigned
> registers, and the meter actually fitted here does not — it is the HomeWizard
> P1 that `ha_signed` reads. They are covered by the unit checks in `test_p1.py`
> and by an end-to-end test against a fake Home Assistant, and nothing more.
>
> This is recorded because the realistic way it bites is someone debugging a
> meter problem months from now treating those two paths as proven and looking
> for the fault elsewhere. If you are the first person to point one at a real
> meter, expect to find something, and please update this note when you do.
| 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 | | `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_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_mqtt_topic` | | `mqtt_p1` only | | `meter_mqtt_topic` | | `mqtt_p1` only |
@@ -66,6 +92,17 @@ that subtraction into the add-on, where it is done once and tested, and replaces
| `p1_export_entity` | | The **unsigned** injection sensor | | `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_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_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. **The list length must equal `meter_phases`** |
Both per-phase lists are checked against `meter_phases` **once at startup**: a
list of the wrong length disables P1 ingestion with an error in the log, rather
than letting every telegram fail its phase-count check one at a time. Leaving
the list empty is fine and is not an error — you simply get no per-phase
figures, and therefore no capacity-tariff peak. On a three-phase connection
that is a much bigger omission than it looks: on a surveyed reading here the
phases carried 2769 W of import while the connection netted 187 W, so the
billed quantity is understated roughly fifteenfold if the phases are missing.
#### How long a dead meter takes to reach 0 W #### How long a dead meter takes to reach 0 W
@@ -123,13 +160,37 @@ The entity is only created when `meter_source` is not `off`. With P1 ingestion
disabled there is nothing feeding it, and an age sensor climbing with no ingester disabled there is nothing feeding it, and an age sensor climbing with no ingester
behind it would trip the firmware watchdog on a system that is working fine. 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 > **Known limit, `mqtt_p1`.** The age measures *arrival*. On the MQTT path a
> `ha_dsmr` path that is exactly right: a frozen meter emits no `state_changed`, > bridge that is stuck republishing its last telegram keeps arriving, so the age
> so nothing arrives and the age climbs. On the MQTT path a bridge that is stuck > stays near zero and a frozen meter still looks fresh. Detecting *that* needs a
> republishing its last telegram keeps arriving, so the age stays near zero and a > change-detector rather than an arrival-detector, and it is not in this version.
> 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 > ⚠️ **Known limit, `ha_signed` — do not drive a watchdog off this age yet.**
> both are available. > On the HA WebSocket paths the age is stamped when a `state_changed` arrives,
> which means it measures *time since the value last changed*, not time since
> the meter last reported. Home Assistant offers nothing better: a repeated
> reading produces no `state_changed`, does **not** advance `last_reported` on
> either the REST or the WebSocket serialiser, and `state_reported` cannot be
> subscribed to over the WebSocket at all (`Event filter is required for event
> state_reported`). All three measured on the ENV-01 rig against the real
> HomeWizard integration with the meter frozen: 0 `state_changed` in 70 s and no
> timestamp movement anywhere.
>
> `ha_dsmr` mostly escapes this because a DSMR telegram updates several entities
> and something in the set almost always moves. **`ha_signed` has exactly one
> entity, so a healthy meter under a flat load is indistinguishable from a dead
> one.** This is not hypothetical: in our own captures
> (`sim/scenarios/ha-p1_meter_active_power-2026-08-20.json`) the real house meter
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
> past the default `meter_max_age_s` of 30.
>
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
> correct whenever the value is moving, but it must not yet be thresholded by
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
> identical signal, so a bigger number only chooses which of the two errors you
> get. The real fix is an arrival stamp the meter itself provides, i.e. reading
> the HomeWizard local API directly rather than through an HA entity.
### Control ### Control
+158 -6
View File
@@ -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 peak, the optimizer, the control loop's sign. So three things happen here and
nowhere else. nowhere else.
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 meter exposes two UNSIGNED 1. The IMPORT/EXPORT DERIVATION. A Belgian P1 read over DSMR exposes two
registers - consumption and injection - never one signed figure. Net power UNSIGNED registers - consumption and injection. Net power is
is `import_w - export_w`, positive = import, and that subtraction is done `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 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 template the user has to write"). A second copy of it somewhere else is a
second chance to invert the control loop. 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 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 with no age is a value that cannot be trusted (§5.2), and staleness is the
failsafe trigger (§11.2). failsafe trigger (§11.2).
@@ -47,6 +56,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"
QUARTER_S = 900 QUARTER_S = 900
@@ -78,7 +88,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: str # SOURCE_HA | SOURCE_MQTT | SOURCE_HA_SIGNED
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)
@@ -129,6 +139,29 @@ def _watts(value, what: str) -> float:
return out 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, def make_sample(source: str, import_w, export_w, *, phases: int,
phase_import_w=None, phase_export_w=None, phase_import_w=None, phase_export_w=None,
telegram_ts: datetime | None = None, telegram_ts: datetime | None = None,
@@ -668,6 +701,103 @@ class MqttP1Source:
self.ingest.reject(err) 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 AGE ON THIS TRANSPORT MEASURES TIME SINCE THE VALUE CHANGED, not time
since the meter reported, and on one entity those are very different things.
Home Assistant offers no arrival signal for a repeated reading: it emits no
`state_changed`, it does not advance `last_reported` on either serialiser,
and `state_reported` cannot be subscribed to over the websocket at all
("Event filter is required for event state_reported"). All three measured on
the ENV-01 rig against the real HomeWizard integration with the meter frozen
- 0 state_changed in 70 s, no timestamp movement anywhere.
`ha_dsmr` mostly escapes it because a DSMR telegram moves several entities at
once. This transport has ONE, so a healthy meter under a flat load looks
exactly like a dead one - and our own capture has the real meter going 42.2 s
and 97.0 s between changes, both past the default max_age_s of 30. Hence
DOCS.md: sensor.p1_sample_age_s is correct while the value moves and must not
yet be thresholded by the firmware watchdog on this transport. Raising
meter_max_age_s does not fix it, it only chooses which of the two errors you
get. The fix is an arrival stamp from the meter itself - reading the
HomeWizard local API rather than an HA entity - which is a separate ticket.
⚠️ 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 # selection
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -699,12 +829,34 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
"phase_import": opts.get("p1_phase_import_entities") or [], "phase_import": opts.get("p1_phase_import_entities") or [],
"phase_export": opts.get("p1_phase_export_entities") or [], "phase_export": opts.get("p1_phase_export_entities") or [],
}) })
if source == SOURCE_HA_SIGNED:
net = str(opts.get("p1_net_entity", "") or "").strip()
phase_net = [str(e).strip() for e in
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
# ⚠️ Both of these are checked ONCE here rather than per telegram. A
# misconfigured source otherwise fails silently in the only way that
# looks exactly like a healthy one that has not been sent anything yet:
# no samples, a climbing age, and the firmware watchdog holding the
# battery at 0 W with nothing in the log saying why.
if not net:
_LOG.error("meter_source %s needs p1_net_entity - P1 ingestion "
"disabled (sensor.p1_sample_age_s would otherwise be "
"announced with nothing feeding it)", SOURCE_HA_SIGNED)
return None
if phase_net and len(phase_net) != ingest.phases:
_LOG.error("p1_phase_net_entities has %d entities but meter_phases "
"is %d - P1 ingestion disabled. Every telegram would be "
"rejected on the phase-count check.",
len(phase_net), ingest.phases)
return None
return HaSignedSource(session, ingest,
{"net": net, "phase_net": phase_net})
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", "")),
broker.get("host"), broker.get("port", 1883), broker.get("host"), broker.get("port", 1883),
broker.get("username"), broker.get("password")) broker.get("username"), broker.get("password"))
if source: if source:
_LOG.error("meter_source %r is not %s or %s - P1 ingestion disabled", _LOG.error("meter_source %r is not one of %s - P1 ingestion disabled",
source, SOURCE_HA, SOURCE_MQTT) source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED)))
return None return None
+17 -3
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller name: GoodWe RS485 Controller
version: "0.2.1" version: "0.3.0"
slug: goodwe_controller slug: goodwe_controller
description: >- description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -42,7 +42,9 @@ options:
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) ------------------------- # --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
# `off` keeps the original single meter_entity path above, so an existing # `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 # 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_source: "off"
meter_phases: 1 meter_phases: 1
meter_max_age_s: 30 meter_max_age_s: 30
@@ -55,6 +57,15 @@ options:
# three-phase connection; the list length must equal meter_phases. # three-phase connection; the list length must equal meter_phases.
p1_phase_import_entities: [] p1_phase_import_entities: []
p1_phase_export_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 --------------------------------------------------------------- # --- control ---------------------------------------------------------------
max_w: 2000 max_w: 2000
@@ -98,7 +109,7 @@ schema:
batt_invert: bool batt_invert: bool
setpoint_entity: str 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 # ⚠️ 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
@@ -112,6 +123,9 @@ schema:
- str - str
p1_phase_export_entities: p1_phase_export_entities:
- str - str
p1_net_entity: str?
p1_phase_net_entities:
- str
max_w: int(100,5000) max_w: int(100,5000)
gain: float(0.05,1.0) gain: float(0.05,1.0)
+324 -3
View File
@@ -18,8 +18,9 @@ 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, QuarterAverager, SOURCE_HA, SOURCE_MQTT, P1Error, P1Ingest, HaDsmrSource, HaSignedSource, QuarterAverager,
make_sample, parse_mqtt_payload, SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_MQTT,
build_source, is_enabled, make_sample, parse_mqtt_payload, split_signed,
) )
fails = [] fails = []
@@ -56,6 +57,25 @@ def raises(name, fn):
fails.append(name) fails.append(name)
def built(src):
"""`src.build()`, with any escaping exception turned into a visible value.
⚠️ Legibility of a RED, not leniency. build() is contracted to return a bool
and to funnel every bad telegram through ingest.reject() - a guard that goes
missing (say the "is the net entity cached at all" one) makes it raise
instead. That still fails the suite, but by aborting it with a traceback at
whichever check happened to run first, which costs the next person ten
minutes deciding whether the suite is broken or the code is. Returning the
exception makes it compare unequal to True/False, so the NAMED check goes red
and says which rule died.
"""
try:
return src.build()
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" build() raised {type(err).__name__}: {err}")
return err
def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None): def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None):
return make_sample(SOURCE_HA, net_import, net_export, phases=phases, return make_sample(SOURCE_HA, net_import, net_export, phases=phases,
phase_import_w=pi, phase_export_w=pe, phase_import_w=pi, phase_export_w=pe,
@@ -548,6 +568,300 @@ check("the averager integrated the live stream", live.averager.elapsed_s > 0.2)
check("the primed cache let the first telegram build immediately", check("the primed cache let the first telegram build immediately",
live.samples == 2 and live.last.import_w == 0.0) 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", built(sig) 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",
built(sig) 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")
built(sig)
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", built(sig) 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",
built(sig) 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")
built(sig)
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", built(sig3) is False)
sig3._absorb("sensor.p1_l2", "468")
sig3._absorb("sensor.p1_l3", "-2582")
check("a complete signed three-phase set builds", built(sig3) 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",
built(sig_bad) 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)
# ⚠️ `sel`, not `built` - that name is the build() wrapper defined at the top of
# this file, and rebinding it here silently disarms every check appended below
# this line. Caught in review: an added check went `TypeError: 'HaSignedSource'
# object is not callable` and aborted the suite, which is the exact failure the
# wrapper exists to prevent, reintroduced by a name collision.
sel = 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(sel, HaSignedSource))
check("...wired to p1_net_entity, and subscribed to exactly that one entity",
sel.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)
# ⚠️ Caught once at startup, not once per telegram. A source that is wired up
# wrong otherwise fails in the one way indistinguishable from a healthy source
# 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.
check("a blank p1_net_entity is refused rather than silently never receiving",
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": ""},
P1Ingest(), None, None) is None)
check("...and whitespace does not sneak past it",
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": " "},
P1Ingest(), None, None) is None)
check("a phase list that disagrees with meter_phases is refused at startup",
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
"p1_phase_net_entities": ["sensor.a", "sensor.b"]},
P1Ingest(phases=3), None, None) is None)
check("a phase list that agrees with meter_phases is accepted",
isinstance(build_source(
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
"p1_phase_net_entities": ["sensor.a", "sensor.b", "sensor.c"]},
P1Ingest(phases=3), None, None), HaSignedSource))
check("no phase list at all is still fine - per-phase billing is optional",
isinstance(build_source(
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"},
P1Ingest(phases=3), None, None), HaSignedSource))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
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
@@ -556,7 +870,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 # 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. # 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 from app.mqtt import SENSORS, MqttPublisher # noqa: E402
check("meter_source off is disabled", is_enabled({"meter_source": "off"}) is False) check("meter_source off is disabled", is_enabled({"meter_source": "off"}) is False)
@@ -636,6 +949,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", check("...as a number, so has_state() becomes true only once we feed it",
isinstance(pub_on.last["p1_age"], float)) 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() print()
if fails: if fails:
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}") print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")