Files
goodwe-addon/goodwe_controller/CHANGELOG.md
T
glenn schrooyenandClaude Opus 5 7aa1bd052e SAFETY-18: a meter frozen at a wrong-but-fresh value now raises an alarm
A meter that answers 200 OK with a stale document forever is ARRIVING on every
poll, so every freshness mechanism in the stack reads it as healthy while the
loop integrates a constant error and rails: 360/135/75 s at the live install's
max_w: 5000 for a meter frozen at 350/1000/>=3000 W. Fifteen minutes of a 5 kW
unattended import sets the month's billed capaciteitstarief peak.

The detector is a conjunction, not the bare unchanged_s threshold TEL-05
measured and rejected:

    unchanged_s >= meter_freeze_s   AND   |command| within 5 % of max_w

Both terms already existed and were already on the status page, and they fail
for independent reasons: at the converged point the command is nowhere near the
rail, and at the rail against a real load the kWh registers move in seconds. At
the shipped 300 s the relation is structural rather than a margin - the slowest
a healthy 1 Wh register can tick and still look unchanged is 12 W, which is
inside the 15 W deadband, so the command there is parked at 0 W.

Notification only, deliberately: an HA persistent notification with a fixed
notification_id, an ERROR log line, a UI event and a red status row. No claim,
no latch, no 0 W. A false trip parks the battery; a false notification costs an
alert. Only meter_source: homewizard_local is covered - the status page says
NOT COVERED elsewhere rather than pretending the guard is armed.

DOCS.md states the capacity-tariff exposure this does NOT close: peak_cap_w caps
maintenance charging and nothing caps the loop. That cap is SAFETY-07.

test_freeze.py grades both directions and was verified by breaking what it
watches (six mutations, all red): silent over the committed 40-minute converged
capture, silent over an hour of TEL-05's low-power case where the rejected bare
threshold is shown to fire, alarming on a genuinely frozen meter driven over
real HTTP through the real HomeWizardLocalSource (the rig's own hwsim freeze
fault when reachable), silent on the same railed command against a meter that is
still moving, and asserting on the service call that leaves Controller.cycle()
rather than on the watchdog's own flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:39:54 +02:00

594 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog
## 0.5.2
**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not
stopped.** (SAFETY-18)
A meter that answers `200 OK` with a stale document forever is *arriving* on
every poll, so every freshness mechanism in the stack reads it as healthy:
`meter_max_age_s` is reset by each response, the firmware watchdog is fed by the
heartbeat and by a sample age that never climbs, and the arbiter contributes only
±`max_w`. The loop integrates a constant error and rails — replaying the real
`compute()` at 15 s cycles, in 150/60/30 s at `max_w: 2000` for a meter frozen at
350/1000/≥3000 W, and 360/135/75 s at `max_w: 5000`. **The live install runs
`max_w: 5000`**, which is both the schema maximum and the device rating.
The new detector (`app/freeze.py`) is a **conjunction**, not the bare
`unchanged_s` threshold TEL-05 measured and rejected:
unchanged_s ≥ meter_freeze_s AND |command| within 5 % of max_w
Both terms already existed and were already on the status page. They fail for
independent reasons: at the converged point the command is nowhere near the rail,
and at the rail against a real load the kWh registers move within seconds. At the
shipped 300 s there is also a structural relation, not just a margin — the
slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which
is inside the 15 W deadband, so the command there is parked at 0 W and the two
halves cannot both be true.
**Notification only, deliberately.** An HA persistent notification (fixed
`notification_id`, so a repeat replaces rather than stacks), an `ERROR` log line,
a UI event and a red status banner. No claim, no latch, no 0 W: a false trip
parks the battery and the operator's next move is to switch the guard off; a
false notification costs an alert. The trip is a separate decision that needs
data from a real install.
- New option `meter_freeze_s` (default 300, range 60–3600, no "off").
- **Only `meter_source: homewizard_local` is covered** — it is the only transport
that sees the meter's own document. Everywhere else the status page says
**NOT COVERED** rather than "armed".
- `DOCS.md` now states the **capacity-tariff exposure** this does *not* close:
`peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying
meter can drive a 5 kW unattended import, and ≥15 minutes of that sets the
month's billed peak (measured: one 4998 W quarter moved a monthly peak from
4832 → 5013 W). That cap is SAFETY-07.
- `test_freeze.py` grades it in both directions: silent over 40 minutes of the
live house's real converged capture (committed as
`scenarios/p1-age-2026-08-26.jsonl.gz`), silent over an hour of TEL-05's
low-power case where a bare threshold is shown to fire, and alarming on a
genuinely frozen meter driven over real HTTP through the real
`HomeWizardLocalSource` — against the rig's own `hwsim` `freeze` fault when the
infra repo is reachable, an equivalent stub otherwise. Its negative twin is the
load-bearing one: the same railed command against a meter that is still moving
must stay silent at the same threshold.
## 0.5.1
**SAFETY-02's latch now survives an add-on restart, which is the difference
between a latch and a pause.** `auto` was persisted and `sign_wd.tripped` was
not, so reconstructing the `Controller` against the same store — exactly what a
container restart does — brought it back **armed, into the same fault, with no
operator involved**: `tripped=False`, banner back to `NOT READY`, and it resumed
commanding (measured: −1000 W, −2000 W) before re-tripping 180 s later. Under a
Supervisor that restarts a crashed add-on that is a loop of three-minute
wrong-direction bursts — and "restart the add-on" is the first thing an operator
looking at a `FAILSAFE` banner and a 0 W inverter will try.
`sign_tripped` and `sign_reason` are now `Store` keys beside the maintenance
state, written the moment the trip happens (reason first, flag second, so a crash
between the two can never persist a latch with no explanation) and restored in
`Controller.__init__`. Cleared only by the operator's start. `DOCS.md` said "it
does not clear itself"; that was false across a restart and now is not.
Also in this release, all of it review follow-up rather than new behaviour:
- **The arming gate is tested against the real arbiter.** It was the decision the
whole ticket turns on and it was asserted in three comment blocks and verified
by nothing — every case passed `following` in as a literal. `test_watchdog.py`
now derives it from `resolve()` across twelve claim configurations, including
the redirect case `winner == "loop"` alone would have got wrong and the `lo`
limit that raises magnitude without flipping sign.
- **The MQTT `status: "failsafe"` value is tested.** The check that claimed to
cover it restated the assertion three lines above it and never touched the
payload.
- **A forward hazard written down where it will be read, and made to bite.**
`winner == "loop"` disarms the watchdog under any higher-priority SET claim.
Today only maintenance and the safety stops emit one and neither follows the
meter; a peak-shaving strategy at `P_PEAK` **does**, and would blind the
watchdog for every peak event.
The gate is now `watchdog.loop_is_following()`, called by both `main.py` and
the test, for the same reason `disagrees()` was extracted: the test held a
*transcription* of the shipped expression, so editing the shipped gate alone to
`winner in ("loop", "peak")` left every suite green while the watchdog went
blind. Two copies, and the one that drifts is the one doing the grading.
⚠️ Extracting it was not sufficient on its own. The realistic P_PEAK row —
peak shaving asking for *more* than the loop — still could not detect the
widened gate, because the magnitude term rejects it regardless and masks the
winner term. A second row with peak asking for *less* isolates `winner` as the
only deciding term, and that is the one that turns red.
- **The worked example described a configuration that does not ship.** At the
defaults `maintenance_charge_w` (2500) exceeds `max_w` (2000), so that claim is
an arbiter *contradiction* resolving to 0 W, not a −2500 W charge. The example
is now written at 1500 W. (The contradiction is a real pre-existing bug —
a default cheap-window maintenance charge silently never happens — filed
separately, deliberately not fixed here.)
- **`disagrees()` is public**, so `sim/replay_sign.py` stops carrying its own copy
of the predicate it grades.
- `held_s` takes the clock instead of reading one, restoring the pure-function
property the module advertises.
- **The `sign_reason`-before-`sign_tripped` write ordering is enforced, not just
documented.** Swapping the two lines used to pass every check. A store that
fails the second write now proves the property the ordering exists for: what
survives a crash mid-trip is a reason with no latch, never a latch with no
explanation.
- Measured figures corrected throughout: 93.4 % / 4212 of 17280 (was 92.9 % /
4127 of 16349), per-cluster 0.3 / 0.9 / 10.5 / 0.0 %.
## 0.5.0
**SAFETY-02 — the sign-disagreement watchdog.** The add-on now watches whether
the setpoint it is emitting and the grid figure it is regulating on point the
same way. If they oppose each other, both over `sign_disagree_w` (300 W), for
`sign_disagree_s` (180 s) **continuously**, it files a failsafe claim at
`P_SAFETY`, commands **0 W**, and latches there until an operator starts control
again. New module `app/watchdog.py`; new options `sign_disagree_w` and
`sign_disagree_s`.
This is the gap the existing failsafes leave open. The stale-input watchdog asks
whether the meter is still talking, the saturation freeze asks whether the
inverter can follow, the output clamp asks whether the number is too big — a
loop can pass all three while charging the battery out of the grid, because the
only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly
that four times in one day, for up to 77 minutes at a stretch, and reached a
commanded −14 768 W against a ±5000 W inverter.
Validated against that day. `sim/replay_sign.py` in the infra repo drives this
module over the committed RS485 captures of both 2026-08-20 (the four episodes)
and 2026-08-23 (healthy): **18 trips on the fault day, every one inside a window
`NOTES.md` §4.7 accounts for, and 0 on the healthy day.** The four episodes do
share one signature rather than merely resembling each other — sign agreement
runs 0.3 / 0.9 / 10.5 / 0.0 % inside them against 93.4 % on the healthy day.
Three decisions worth knowing about:
- **It is disarmed whenever the grid-following loop is not what is driving.**
Sign agreement is a property of a loop that follows the meter. A maintenance
grid charge opposes the meter *by design* — 2500 W of charge into a house
drawing 500 W is +3000 W on the meter against a −2500 W command, for hours —
and armed unconditionally this would trip on every maintenance cycle. The gate
tests the emitted number, not the winning claim's name: a limit claim can let
the loop "win" and still replace its value with one pointing the other way.
- **The trip latches.** Commanding 0 W removes the command side of the
comparison, so the disagreement always clears the moment the trip works.
Un-latched, this would trip, clear, and hand the actuator back to the loop that
was driving the wrong way, every three minutes forever.
- **`sign_disagree_w` at or above `max_w` is refused at startup** (validator rule
5). The arbiter never lets a command exceed `max_w`, so such a threshold makes
the watchdog incapable of firing while the status page still reports it armed.
A disarmed guard that advertises itself as armed is the failure mode this
project keeps refusing to ship.
What it does **not** catch, stated so nobody assumes otherwise: an inverter that
acts opposite to what it was told moves the meter *further* the same way, so
command and meter agree while everything runs away. That is the saturation
freeze's job, which compares the command against the battery's actual power.
`test_validate.py`'s "the smallest sane max_w still starts" case now names a
reachable sign threshold, because a 1 W ceiling genuinely does disarm the 300 W
default and is now correctly refused.
## 0.4.0
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
instead of logging a line and carrying on. `validate_startup_config()` runs in
`amain()` before the broker, the web server or any task exists, so a refusal
cannot leave half a controller running behind a UI.
It refuses only where continuing produces one of two outcomes: **(a)** a stated
limit is untrue - the operator configured a bound and a lower layer silently
enforces a different one - or **(b)** the add-on is inert and indistinguishable
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
become unsafe, it becomes a convincing impression of a working controller.
Four things qualify:
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
that changed behaviour. It used to warn `the device wins` and run anyway, with
`write_setpoint()` silently clamping, so the configured ceiling was never the
real one and the only evidence had scrolled off the log. The refusal names the
configured value, the device's value, and which wins.
- **No `setpoint_entity`.** A controller with no actuator: every write is a
no-op, the device rating is never read, and the ESP32 heartbeat that holds off
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
`meter_max_age_s`. These were already caught, but they only *disabled
ingestion*, which leaves the grid reading None forever: permanent "inputs
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
has sent anything to yet.
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
reading as fresh and keeps commanding, while the firmware has already vetoed
and is holding the inverter at 0 W - healthy meter, running controller,
nothing moving, and no line anywhere saying the number you set was overruled.
Structurally the same fault as the `max_w` case. Only *above* the veto is
refused: below it the add-on gives up first, which is fail-safe and visible on
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
be imported from a compile-time YAML substitution, so it carries a note about
the one direction in which it can go stale.
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
`validate_startup_config()` is now also the simulation suite's entry point, and
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
than as the typo it is.
The predicates for the meter group still live in `p1.validate_meter_options()`,
which `build_source()` also calls - one definition, two callers, not two
competing validators. Two transports gained a check they never had: `ha_dsmr`
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
which previously built a transport that could never read.
Deliberately **not** refused, because it is untidy rather than unsafe:
`integrator_max_w` above `max_w` (the arbiter still bounds the wire to ±max_w,
so the cost is unwind latency, not amperage - warned), a missing `soc_entity` /
`batt_entity` (fail-safe, and already a red NOT READY banner naming it), a
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
late; refusing would make a slow boot need a human - re-checked once the real
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
A refusal is legible as a refusal: a banner that says in words that this is a
configuration problem and nothing is broken, the numbered problems, no traceback,
and exit status 78 (`EX_CONFIG`).
**SAFETY-12.** The control loop now evaluates on a clock as well as on a change.
`run_control()` ran `cycle()` only when the meter value differed from the
previous poll, so a meter that repeated a value ran **no control cycles at
all** - while the heartbeat went on re-emitting the last command and the
firmware went on putting it on the wire every 2 s. Layer 1 stayed fed, every
indicator read healthy, and a command computed from an old reading kept being
executed through whatever the load did next. Measured on the rig during TEL-05:
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
**It was worse than a latency limit: it was a safety claim that could not
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
never called again - so that claim had no way to run. Constant meter, SoC entity
dying at t=4 s, same binary:
```
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
final target: 220.0 W reason: 'loop -> 220 W'
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
final target: 0.0 W reason: 'safety -> 0 W'
```
The pre-fix controller held 220 W through the entire input outage with its
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
behaviour was not "holds a stale command safely" - it was a failsafe that a
repeating meter switched off. `test_loop.py` asserts both halves.
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
meter it never elapses, so the cycle rate, the write sequence and therefore the
per-cycle gain the tuning was measured against are unchanged - asserted in
`test_loop.py` by driving the real loop twice on one binary (a huge
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
commanded values.
The reset is not the only thing holding that up, and the other argument is the
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
even a hypothetical floor racing the arrivals could not exceed the nominal
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
while `cycle_floor_s` stays above the meter's update interval, and the schema
minimum is 5 s - which is exactly the reference meter's interval. Nothing
enforces the relationship; `validate.py` is the obvious home for it and does not
have it yet.
Observed over six real minutes on the same rig, one binary, meter held at a
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
the command stuck at 220 W against a 350 W import for the rest of the window.
**After, 24 cycles**, worst gap 15.14 s, and the ordinary PI ramp doing its job -
220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W, one step per cycle,
rail at t=136 s and flat after. Three minutes with the meter parked at the
target instead: **1 cycle before, 12 after, and 19 writes either way - the same
19**. Three minutes on a normally-changing meter: **36 cycles and 49 writes on
both sides, the same values in the same order.** The heartbeat write count never
moves; the only writes the fix adds are the ten the loop was supposed to be
making all along.
15 s is bracketed, not round.
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
the poller steps over an update entirely - a ~10 s gap between changed values on
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
sits above the beat-skip so it does not fire on that alone; what it takes to
reach 15 s is a beat-skip *and* a failed poll coinciding.
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
guaranteed to have re-decided at least twice inside any window in which layer 1
might otherwise be the only thing deciding. ⚠️ The default is therefore not
independently justified at 15: if the firmware's veto ever moves, this moves
with it. Noted beside the constant in `main.py`.
It is also exactly `stale_input_s`, this system's existing answer to "how long
may we hold without re-deciding".
A floor cycle runs the **full** law on the same inputs, integrator included. In
P1 mode the freshness question is already answered upstream and answered harder
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
this system already calls fresh. ⚠️ That is transport-conditional: with
`meter_source: off`, `read_inputs()` takes the grid figure straight from
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
gone quiet reads as current on that path, with or without this change. Running
the full law is still right there - the alternative is a degraded second law
reasoning about an age nobody measured - but the floor does not make that path
fresh. The integrator advances per *cycle*, not per second,
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
that. A gentler second law for floor cycles would be a second set of dynamics
nobody can test on this hardware.
⚠️ **This is not a freeze detector**, and TEL-05 established one cannot be built
here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move
while the power figure legitimately repeats, so any threshold on `unchanged_s`
false-trips at the exact operating point we run at. Nothing in this change
decides whether a repeated reading is *true*. It stops "the number is the same"
from meaning "do not think". The consequence, stated plainly: against a meter
genuinely frozen at a wrong value the loop will now correct toward that wrong
value rather than hold a stale command, bounded by `max_w`, the arbiter and the
device rating. That was already what happened to a lying meter whose value
merely jittered; bit-exact repetition was never a safety property.
`DOCS.md`'s "Saturation is counted in cycles, not seconds" section documented
the old behaviour as an accepted deviation. It is **qualified, not deleted**:
the deviation survives (the window is still counted in cycles, and 3 x 15 s is
still not the specification's 10 s) but it is now bounded at 45 s instead of
open-ended, and the passage's second justification - "nothing accumulates while
it is stalled" - is called out as having covered integrator windup and not
staleness, which was the actual hazard. The same wrong argument in
`control.py`'s saturation comment is corrected too.
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
unsigned registers and refuse negatives, so neither fits the meter installed here.
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
directly. This exists because of something the HA paths cannot do: Home Assistant
fires no state change for a repeated reading and exposes no arrival signal at
all, so `sensor.p1_sample_age_s` on those transports measures time since the
*value changed*, not since the meter *reported*. Real captured data has gaps of
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
local API is a genuine arrival, so the age means what it says.
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
age never exceeding the poll cadence.
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
install that does not opt in.
Freeze detection is separate and deliberately not thresholded: a meter answering
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
energy registers carry that as `unchanged_s`, surfaced on the status line for a
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
about six minutes to move, so any threshold would false-trip at the target
operating point.
## Unreleased
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
(default 5 s, the meter's own update rate) sets the cadence.
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
the meter *reported*, and therefore the first one a firmware watchdog may
threshold.** Every HTTP response is an arrival: the meter answered, now, with
its current reading, and whether the *number* moved is not consulted. Home
Assistant cannot express that at all — a repeated reading emits no
`state_changed`, advances `last_reported` on neither serialiser, and
`state_reported` is not subscribable ("Event filter is required"). On
`ha_signed` that made a healthy meter under a flat load indistinguishable from
a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s
between changes — both past the default `meter_max_age_s` of 30, i.e. a false
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
mode.
Everything TEL-01 established is reused, not re-implemented: ingest
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
is a *missing* reading — never 0 W — and it does not reset the age.
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
freeze` injected. Still defaults to `off`.
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
detector can: a meter answering `200 OK` forever with a stale number is
arriving. The local API does expose what the HA path never had — the
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
`unchanged_s`, but that is deliberately **not** folded into the age and not
thresholded: this controller regulates grid power toward ~0 W, and at a
converged −10 W the export register needs six minutes to move by its 1 Wh
resolution while the power figure legitimately repeats. Thresholding it at 30 s
would rebuild the false-trip limit cycle at the exact operating point we aim
for. Freeze detection needs the low-power case solved first, separately. It is
shown on the status page's P1 line instead — leaving it unthresholded only
holds up if a human can read it, so now they can.
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
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
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
holds any resting point inside it indefinitely, and the meter bills import and
export on separate registers, so resting at +14 W import costs 0.34 kWh/day
with the loop behaving perfectly. Biasing the target slightly negative moves
that residue onto the export register. Configurable in the Configuration tab;
see DOCS.md for the trade-off table.
Behaviour is unchanged at `target_grid_w: 0`.
## 0.2.0
Precedence between strategies is now a first-class object instead of an if/else
ladder, ahead of there being more than three of them.
Every strategy returns a CLAIM each cycle - `set` ("I want X") or `limit` ("the
result must stay within these bounds") - and `arbiter.py` resolves them by one
rule:
1. Highest-priority `set` wins; no claim at all means 0 W.
2. Then every `limit` whose priority is >= that set's priority applies, most
restrictive first.
3. Contradictory limits are a BUG: command 0 W and say so.
Clause 2 is why "money outranks maintenance" is now a consequence of the
priorities rather than a special case in a Jinja template: the maintenance
charge-only limit binds the loop, but will not bind a higher-priority peak
shaving claim when one exists.
- Maintenance shaping (charge-only, cheap-window floor) moved out of the control
law. `control.py` is once again only a controller that tracks the meter.
- The loop computes from the ARBITER's last output, not its own last wish. If
something outranked it, that is what the hardware actually did, and tracking
anything else makes it jump when it regains control.
- Every decision is explainable: "loop -> 0 W, limited by maintenance
(charge-only)" now appears in the UI and the log, instead of a bare number.
- Safety limits (device rating, supervised max_w) bind every strategy including
the highest, and are still enforced a second time at the point of writing.
## 0.1.6
Findings from installing this on a live system, replacing a working YAML
implementation. Every one of these was silent - the add-on looked healthy while
being completely unable to do its job.
- **`run.sh` must use `#!/usr/bin/with-contenv sh`.** The HA base images run
s6-overlay, which starts services with a SANITISED environment. With a plain
shebang, SUPERVISOR_TOKEN is simply absent and every Home Assistant call
returns 401 - while `homeassistant_api: true` makes the permissions look
correctly granted. Startup now logs the token length and probes the Core API,
so the next person sees it in one line.
- **Bump `version:` for every change.** Supervisor keys the built image by
version, so editing source and rebuilding silently reuses the old image. Two
fixes appeared not to work because of this.
- **Dependencies come from apk, not pip.** Alpine is musl and there are no musl
wheels for aiohttp; pip would compile it on the client's Pi.
- **paho-mqtt 1.x and 2.x are both supported.** Alpine ships 1.x, which has no
`CallbackAPIVersion`; that raised and took the whole add-on down with it.
- **MQTT can no longer take down control.** Publisher construction is wrapped -
observability must never stop the controller.
- **MQTT discovery is published from `on_connect`.** paho drops QoS-0 publishes
issued before the CONNACK, so announcing straight after `connect()` published
nothing at all while logging "MQTT connected".
- **Repeated failures log at most once a minute.** The control loop retries every
second; unthrottled warnings rolled the log buffer and destroyed the startup
diagnostics needed to debug the 401 above.
- **`auto_start` works.** The store's defaults supplied `auto: False`, so the
fallback to the option could never fire.
Known issue: after deleting the MQTT entities from the registry during
development, Home Assistant would not re-adopt them from retained discovery -
not even after clearing the retained topics and reconnecting. The add-on
publishes correct discovery and live state (verified on the broker); this is an
HA-side adoption problem and affects status entities only, never control.
## 0.1.0
First packaged release. Ports the control loop and the monthly maintenance
cycle from the reference Home Assistant implementation into an add-on.
- Grid-following control: gain/slew/clamp/deadband with anti-windup, all tuned
against measured hardware behaviour (see FIELD-GUIDE.md §14).
- Saturation freeze **with the duration term** — three consecutive diverging
cycles, not one. The instantaneous test fires on every large correction,
because the plant itself needs 3-6 s to settle.
- Monthly maintenance cycle as an ownership state machine: drain / charge /
hold, with exactly one writer of the setpoint at any moment.
- Capacity-tariff awareness: the maintenance charge is capped by quarter-hour
peak headroom, and peak shaving outranks the maintenance schedule.
- Failsafe behaviour: commands 0 W on missing inputs, on stop, and on shutdown.
Never replays a stale setpoint - the reference implementation did, and the
hardware watchdog cannot catch that.
- Ingress UI with a commissioning checklist that names problems in words.
- Optional MQTT discovery for status entities.
Known limits:
- Home Assistant OS / Supervised only (add-ons cannot run on Container/Core).
- The inverter protocol is reverse-engineered; no vendor contract.
- Without the optional RS485 e-stop, nothing covers the host machine dying.