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
35 KiB
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_localis 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.mdnow states the capacity-tariff exposure this does not close:peak_cap_wcaps 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.pygrades it in both directions: silent over 40 minutes of the live house's real converged capture (committed asscenarios/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 realHomeWizardLocalSource— against the rig's ownhwsimfreezefault 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
followingin as a literal.test_watchdog.pynow derives it fromresolve()across twelve claim configurations, including the redirect casewinner == "loop"alone would have got wrong and thelolimit 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 atP_PEAKdoes, and would blind the watchdog for every peak event.The gate is now
watchdog.loop_is_following(), called by bothmain.pyand the test, for the same reasondisagrees()was extracted: the test held a transcription of the shipped expression, so editing the shipped gate alone towinner 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
winneras 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) exceedsmax_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, sosim/replay_sign.pystops carrying its own copy of the predicate it grades. -
held_stakes the clock instead of reading one, restoring the pure-function property the module advertises. -
The
sign_reason-before-sign_trippedwrite 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_wat or abovemax_wis refused at startup (validator rule 5). The arbiter never lets a command exceedmax_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_wabove (or below) the setpoint device's own rating. This is the one that changed behaviour. It used to warnthe device winsand run anyway, withwrite_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_sourcethat can never produce a reading - missingp1_host, missingp1_net_entity, a blank MQTT topic, a phase list disagreeing withmeter_phases, an unknown source name, or ameter_poll_sat or pastmeter_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_sabove the firmware's ingest veto. The schema acceptsint(5,300); the ESP32's threshold is a compile-timemax_age_s: "30"and it trips layer 1 atp1_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 themax_wcase. 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 asvalidate.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_sthe reading is None, which is "inputs missing", which is aP_SAFETY0 W claim - so a floor cycle there can only ever run on a reading this system already calls fresh. ⚠️ That is transport-conditional: withmeter_source: off,read_inputs()takes the grid figure straight fromhass.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_wbounds 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:
- Highest-priority
setwins; no claim at all means 0 W. - Then every
limitwhose priority is >= that set's priority applies, most restrictive first. - 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.pyis 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.shmust 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 - whilehomeassistant_api: truemakes 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 afterconnect()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_startworks. The store's defaults suppliedauto: 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.