TARIFF-01 measured the quarter and said the consumer would be this card. It is
one new module, app/peakguard.py, and one new claim.
THE MECHANISM. Every cycle the guard projects the open 900 s block to its
end-of-interval average - from P1 telegrams only, never from the command - and
compares it to a ceiling. Over the ceiling it files ONE arbiter claim: a lower
bound on the setpoint at P_PEAK. That single shape is both behaviours 10.1 asks
for, a hard clamp on charging and pre-emptive discharge, and it cannot cap
discharge or export, because a floor cannot. 9.1's asymmetry becomes a property
of the object instead of a rule to remember.
excess = 900 x (projected - ceiling) / seconds remaining
floor = MEASURED battery power + excess
The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard
is gentlest where its projection rests on the least evidence. The floor is sized
from delivered battery power, not the standing command: a floor built on intent
re-adds its own unmet ask every cycle and winds to the rail whenever the inverter
fails to follow, and test_peakguard.py part 1 shows the two diverging.
IT TRIPS, AND THE CAPTURES DECIDED THAT. On the healthy reference day it engages
on zero of 95 quarters. The action is level-triggered, unlatched and re-derived
every cycle, so a false engagement costs one cycle of discharge rather than a
parked battery - the cost SAFETY-18 could not bound and this one can. Replayed on
2026-08-20 the billed quarter goes from 5029.8 W, above the inverter's own 5000 W
rating, to 3485 W, under the soft limit and without saturating. Quarters under
the limit across both committed days: 189/190 -> 190/190. Two days is not thirty
and AC 3 remains TARIFF-01's open item.
BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different
action. A fresh averager projects a confident 0 W, which is under every ceiling
there is; a meter gap longer than meter_max_age_s makes the projection read LOW;
a frozen meter reading export projects 0 W at full coverage. All three become a
floor of 0 W - no importing while the billed quantity is unobservable - which is
one-directional and cannot cost pack energy. That is the backstop SAFETY-18 could
not be: its alarm arrives at 300 s of a 900 s quarter and commands nothing. The
frozen term is freeze.looks_frozen() itself, on this cycle's standing command.
THE CEILING IS peak_cap_w, RAISED ONLY BY AN OBSERVED READING. NO_RECORD and
NOT_OBSERVED both leave it at the cap, so an unreadable peak store makes the
guard stricter and never looser. The gate is the reading's STATE, not whether a
number happens to be attached: today a `peak_w is not None` test behaves
identically, and depending on that coincidence is how a future last-known-peak
field would raise the ceiling off a read that failed. headroom_w() is
deliberately not consumed - cap minus peak turns a zero peak into FULL freedom.
TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the
arithmetic, and both caught by running the thing rather than by reading it:
* A LIMIT does not out-vote another LIMIT. arbiter.resolve() applies every
limit at or above the winner's priority and takes the most restrictive, so
the peak floor (lo >= 0) and the maintenance charge-only shaping (hi <= 0)
resolved to a CONTRADICTION and 0 W - the battery parked in the exact quarter
it should shave. Fixed by suppression, not priority: main.py drops the
maintenance shaping while the guard is engaged, which is what that branch
already did for the optional peak_forecast_entity - a key empty on a default
install, so until now the suspension could not fire on the site that needed
it.
* Thresholding `excess > 0` released the claim the instant it began working,
producing seventeen engage/release cycles in one 900 s quarter and leaving
that quarter 49 W over the limit it had spent fifteen minutes holding. That
is 10.2's oscillation. The threshold is now the floor itself, which is the
same arithmetic read as a level rather than as a switch.
PeakTracker.project() no longer reads a private name with a default.
getattr(averager, "_last_net", 0.0) failed quiet AND disarming - a rename credits
0 W held, the projection reads low, the money guard does not engage.
QuarterAverager.held_net_w is a documented public property now, read without a
default, and Projection carries it. TEST-13 item 4.
NO NEW OPTION AND NO FOURTH DEFAULTS SITE. The ceiling is peak_cap_w, the
coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s,
and the achievable discharge comes from max_w and the device rating. config.yaml
and validate.py are untouched; 14's capacity_peak_limit_kw stays declined. On
meter_source: off there are no telegrams, so the guard does not run at all and
the status page says NOT COVERED - running it there blinded it permanently and
stopped every legacy install from charging, which is what the first cut did until
test_clamp.py's export case caught it.
test_peakguard.py: eight parts, all figures derived from the shipped config.yaml
and the committed captures. Part 8 runs seven one-line defeats of the guard and
reports which check bites; six do, and the seventh is reported as NOT caught,
because max(cap, peak) makes a zero peak the strictest ceiling there is and there
is nothing left to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
53 KiB
Changelog
Unreleased
The grid-following loop is now capped against the month's billed capacity peak
(SAFETY-07). New app/peakguard.py. TARIFF-01 measured the quarter; this is the
consumer it was built for. Every cycle the guard projects the open quarter,
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
setpoint at P_PEAK. That shape is both behaviours §10.1 asks for — a hard clamp
on charging and pre-emptive discharge — and it cannot cap discharge or export,
because a floor cannot. excess = 900 × (projected − ceiling) ÷ remaining, and
the floor is the MEASURED battery power plus that: a floor built on the standing
command winds itself to the rail whenever the inverter fails to follow, and one
built on delivered power cannot.
It trips where SAFETY-18 only notifies, and the captures decided that. On the healthy reference day it engages on zero of 95 quarters; the action is level-triggered, unlatched and re-derived every cycle, so a false engagement costs one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the billed quarter goes from 5029.8 W — above the inverter's own 5000 W rating — to 3485 W, under the soft limit and without saturating. Quarters under the limit across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is still TARIFF-01's open item and this does not claim it.
Blindness forbids importing, and it is checked before the projection is
graded. A fresh averager projects a confident 0 W, which is under every ceiling
there is; a meter gap longer than meter_max_age_s makes the projection read LOW;
and a frozen meter reading export projects 0 W at full coverage. All three become
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
and commands nothing. The frozen term is freeze.looks_frozen() itself, evaluated
on this cycle's standing command, not a second copy of the conjunction.
The ceiling is peak_cap_w, raised only by an OBSERVED reading. NO_RECORD
and NOT_OBSERVED both leave it at the cap, so an unreadable peak store makes the
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
be attached to it: today a peak_w is not None test behaves identically, and
depending on that coincidence is how a future last-known-peak field would silently
raise the ceiling off a read that failed. headroom_w() is deliberately not
consumed — cap − peak turns a zero peak into FULL freedom.
Two defects found building it, both in the interaction rather than the
arithmetic. A LIMIT does not out-vote another LIMIT — arbiter.resolve() applies
every limit at or above the winner's priority and takes the most restrictive — so
the peak floor (lo ≥ 0) and the maintenance charge-only shaping (hi ≤ 0)
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
should shave. Fixed by suppression rather than priority: main.py drops the
maintenance shaping while the guard is engaged, which is what that branch already
did for the optional peak_forecast_entity — a key that is empty on a default
install, so until now the suspension could not fire on the site that needed it.
And thresholding excess > 0 released the claim the instant it started working,
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
itself, which is the same arithmetic read as a level rather than as a switch.
PeakTracker.project() no longer reads a private name with a default.
getattr(averager, "_last_net", 0.0) failed quiet AND in the disarming direction:
a rename credits 0 W held, the projection reads low, and the money guard does not
engage. QuarterAverager.held_net_w is now a documented public property, read
without a default, and Projection carries it. TEST-13 item 4.
No new option, and no fourth defaults site. The ceiling is peak_cap_w, the
coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s,
and the achievable discharge comes from max_w and the device rating.
config.yaml and validate.py are untouched; §14's capacity_peak_limit_kw
stays declined as the kW/W mismatch trap it is. On meter_source: off there are
no telegrams, so the guard does not run at all and the status page says NOT
COVERED — running it there would have blinded it permanently and stopped every
legacy install from charging, which is what the first cut did until
test_clamp.py's export case caught it.
The capacity-tariff peak is now measured, banked and survives a restart
(TARIFF-01). New app/tariff.py. The 15-minute block average itself was
already built and unwired - p1.QuarterAverager computes clock-aligned 900 s
blocks, time-weighted over irregular telegram spacing, offtake-only and
per-phase - and P1Ingest.blocks was a 96-entry list a restart erased. This
adds the SQLite behind it (/data/tariff.db): one row per completed quarter in
the indefinite 15-min tier, one row per calendar month in the permanent peak
tier, and no DELETE against either anywhere in the module.
Blocks, not a sliding window. The card says "rolling"; 9.1 bills "the highest 15-minute average offtake", stated per QUARTER. "Rolling" here means continuously updated, not a trailing-15-minutes window - a trailing window reports a peak the meter never bills, and reports it HIGH, because a burst straddling :15 fills it completely while contributing to two block averages at partial weight.
A restart mid-block resumes it, and a restart that outlives the block banks
it. QuarterAverager grew snapshot()/restore(); the tracker checkpoints
the open block every 15 s (worst case 1.7 % of one quarter) and clears the
checkpoint in the same breath as a block close. The held sample keeps its
ORIGINAL timestamp across the restart, so the outage is integrated under the
same max_hold_s rule a running process would apply and uncovered seconds drag
the closed block DOWN rather than being held forward. quarter_start is the
primary key and a re-bank may only ever RAISE a figure, so a replay cannot
double-count and a partial re-run cannot lower a quarter already measured.
An unreadable store is not a zero peak. peak() answers OBSERVED /
NO_RECORD / NOT_OBSERVED and carries peak_w = None unless observed - the
riglock.holder() fix applied to something that spends money. A peak read as
0 W hands SAFETY-07 maximum headroom and silently disarms the guard at the
moment its own storage breaks, so there is no accessor that defaults it: a
caller who forgets gets a TypeError, and the status page prints a different
row for "no quarter yet" than for "the database did not answer".
Calendar months are LOCAL, blocks are UTC. A quarter starting 22:15 UTC on 31 August bills in September. Block alignment still needs no timezone.
No new option, and no second peak entity. The soft limit is the existing
peak_cap_w; config.yaml and validate.py are untouched, so CFG-03 is not
widened. peak_forecast_entity stays the input to maintenance_charge_floor()
- repointing that consumer changes what moves energy and belongs to SAFETY-07.
It measures and nothing else. No claim, no clamp, no command; graded on the
parsed syntax in test_tariff.py, which also proves the same grader lights up
on main.py. Per SAFETY-20, the quantity is DELIVERED power - tariff.py only
ever sees P1Sample, the meter's own reading, never a setpoint.
AC 3 is not met and the check says so. It asks for 30 days of replay; two
days of this house's real meter exist (2026-08-20, 2026-08-23), now committed as
scenarios/p1-net-*.jsonl.gz. test_tariff.py part 9 replays them, reports
the percentage under the shipped limit next to the sample size in days, and
asserts nothing about the 95 % figure.
A computed setpoint over the rating now alarms instead of being clipped in
silence (SAFETY-03). The clamp was never the missing half - specs.md 10.3
records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting
14 768 W while pinned at the 5 kW rail, and a silent clamp turns exactly that
into a flat, legal-looking 5000 W hold. write_setpoint() - the one path every
write takes, cycle, heartbeat, operator button and shutdown alike - now clamps to
the validated max_w and reports whether the raw value differed, and
Controller.check_output_clamp() raises OUTPUT_CLAMP: a persistent
notification (goodwe_output_clamp), an ERROR, an event row and a red status
banner. Not a trip: the clamped value is written and the loop keeps running,
per 11.2's "clamp and alarm".
The bound is max_w and nothing is min()ed at runtime. Section 14 requires
the loop clamp and the driver's setpoint_limit_w to agree, validated at startup
with a refusal on mismatch - which CFG-01 already ships. The device range is
still applied second, as defence in depth; after a successful validation it
cannot engage, so if it ever does, that is louder than the first stage and it
alarms too, naming the bound. No new config key: the guard's limit is the one
the operator already set, and its cadence is a module constant.
Decision grew i_clamped / clamped booleans, and that is the load-bearing
part. reason is one string that later stages overwrite - a cycle that clamps
the integrator and is then slew limited reports slew-limited, and one that
clamps while saturated reports saturated-freeze. Both are the winding case,
so an alarm keyed on the string is silent on exactly the cycles it exists for:
mutating main.py back onto "clamp" in decision.reason drops the 10.3 replay
from 2 engagements to 0. The flags are set where the clamp happens and are
never overwritten. On a default install (integrator_max_w following max_w)
the integrator bound is the one that bites and the output clamp is unreachable,
so the integrator flag is the whole signal.
Distinct from the saturation freeze, and checked in the negative. A clamp is
a software bug signal; saturation is the hardware failing to follow a legal
command, and its action is to freeze the integrator, not to alarm. They share no
reason code, notification id, status row or counter. test_clamp.py drives a
loop that saturates for 10 consecutive cycles at 1.4 % of the rating and asserts
zero clamp notifications, zero engagements, no log row and no status row - after
first asserting that the saturation freeze really did engage, so the silence is
an observation rather than a scenario that did nothing.
The action log is coalesced per episode, and that was a real bug.
log_event() keeps 40 rows; one row per engagement flushed the entire ring
within 40 cycles of a winding loop, and the first thing it evicted was
SAFETY-18's own METER FROZEN? row - this guard silencing another guard's log.
Caught by test_freeze.py going red, not by inspection. One row per episode now,
carrying the exact engagement count read off the counter.
A non-finite setpoint is refused, and it was worse than passing one through.
nan is loss of input (11.1 rule 1: command zero, keep commanding), not an
out-of-range value. Measured: because every comparison against nan is False, a
naive max(-limit, min(limit, nan)) keeps its first argument and turns a nan
into a command at the full rating, then reports it as a clamp engagement.
Review fixes (PR #12). Three, and the first was a behavioural regression
this PR introduced: clamp_to_rating() returned early from the max_w
stage, so the device bound - the "applied second, defence in depth" half - was
reached only when the rating bound had NOT fired. The bigger the overshoot, the
less the guard did. At max_w 5000 against a +-4000 device, +9999 emitted
+5000 W, outside the device range, while a mere +4500 was correctly held to
+4000. That undid the pre-PR guarantee "never send a value the device will
reject outright" and undercut this module's own case for clamping to max_w
rather than a runtime min(), which rests entirely on that stage catching a
validation that did not run. Both stages now always run, the result names both
bounds when both bite (max_w+device), and the property is asserted over a
492-point sweep rather than on witness values - the defect was invisible to any
single-value check that happened to sit inside one stage. No reachable
production path was found for it (two validation gates and the arbiter bound sit
in front), but latent is not harmless.
Two harness defects, both of the kind where a check cannot fail for the value it
forbids: the hysteresis check issued range(CLEAR_CYCLES - 1) clean cycles,
which at CLEAR_CYCLES = 1 issues zero and passes while printing
"0 clean cycles do NOT clear it" - green for the chatter setting the constant
exists to prevent. And the re-assert check stimulated at REASSERT_S + 9, green
for any value including 3600, i.e. the hour of silence the module promises
not to produce. Both are now written in wall-clock terms that do not move when
the constant does: one clean cycle must not clear the alarm, and a loop
pinned across the 900 s capacity-tariff quarter must speak at least 5 times and
on no more than 20 % of cycles. CLEAR_CYCLES = 1, REASSERT_S = 3600 and
REASSERT_S = 0 each go red.
test_clamp.py (new, assert-based, no framework) covers the clamp
arithmetic and its boundaries - exactly +-max_w is not an engagement - the
alarm's edge/re-assert/hysteresis cadence, the 10.3 runaway replayed through the
real Controller.cycle() against an unclamped transcription, the saturation
negative, and the direct-write path. Six author mutations reproduced red,
including "clamp kept, alarm deleted" (the historical build) and "alarm keyed on
reason". Two of the check's own hypotheses about how the freeze and the clamp
interact were refused by the sweep and rewritten to what it measured: a
frozen cycle can clamp, and does so exactly when the integrator reverses.
test_freeze.py's notification assertions now filter by notification_id
rather than by service - the add-on raises more than one kind of notification,
and its railing scenarios legitimately raise this one too.
Cheap-window maintenance charging was unreachable at the shipped defaults, and
now the config that makes it unreachable is refused (MAINT-04). config.yaml
shipped max_w: 2000 against maintenance_charge_w: 2500. The cheap-window
shaping files Claim.limit(hi = -maintenance_charge_w) against the supervised
SAFETY limit's lo = -max_w, so lo > hi — the arbiter's clause 3, correctly
resolved to 0 W and logged as a CONTRADICTION. Maintenance silently never
charged in a cheap window on a default install, and the only evidence read
like a bug in the arbiter rather than like the two numbers an operator set.
Measured against the real arbiter, loop asking +500 W:
max_w=2000 hi=-2500 -> +0 W contradiction=True
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(cheap-window charge)
validate_startup_config() now refuses maintenance_charge_w > max_w (rule 6),
the same shape as CFG-01's sign_disagree_w >= max_w: a number the operator set,
overridden by a layer they cannot see. Equality is reachable (-max_w is a
single valid point) and deliberately not refused. Verified by starting the
add-on on the pre-fix pair: exit 78, the refusal banner, no traceback.
maintenance_discharge_w was checked, not assumed, and it is NOT the same
case. The shaping is not symmetric. The drain phase does not shape the loop,
it OWNS the actuator — Claim.set(P_MAINTENANCE, +discharge_w) — and a SET is
clamped by the limits above it rather than contradicted by them. 2500 against
a 2000 W ceiling emits +2000 W, and the arbiter's own explain() prints
maintenance -> 2000 W, limited by supervised(max_w) every cycle. Nothing is
silent and energy still moves; the cycle completes slower (13.8 kWh usable at
2000 W is ~6.9 h against the 720 min drain timeout). Warned about, not
refused.
The shipped defaults changed so the validator does not reject them.
maintenance_charge_w and maintenance_discharge_w are now 2000, at the
shipped max_w. max_w was deliberately NOT raised instead: it is the
supervised safety ceiling and raising a default upward ships more power to every
new install. Raising max_w (the reference install runs 5000) is what unlocks a
faster maintenance cycle — raise it first, then these. test_validate.py now
reads config.yaml itself and asserts the shipped numbers pass the rule, so a
default that the validator refuses cannot ship again.
⚠️ Upgrading an existing default install: the add-on may refuse to start
An install still carrying maintenance_charge_w: 2500 against max_w: 2000
now meets rule 6 and exits 78 with the refusal banner instead of starting.
That is the intended behaviour — the configuration was already broken, it was
just silent about it — but an add-on that will not come back after an update
needs the fix stated here rather than only in the log.
The fix, either way: set maintenance_charge_w to 2000 or lower, OR raise
max_w to 2500 or higher (Settings → Add-ons → GoodWe RS485 Controller →
Configuration), then start it. The banner in the log says the same thing and
names both numbers.
Whether you hit this at all depends on whether the Supervisor kept the old
value, and both cases are covered above because we did not verify which one
applies here. The Supervisor merges an add-on's stored options over the
defaults in config.yaml, so:
- A key the operator never edited re-derives from the new
config.yamland becomes 2000 on upgrade. Non-event, nothing to do. - A key the operator did set persists at 2500 and the add-on refuses to start until it is changed. Note this includes anyone who used the Configuration page's YAML editor, which writes the whole options block back — so "I never touched that field" is not a guarantee the old value is gone.
⚠️ This is reasoned from the Supervisor's documented merge behaviour, not
measured on the live install. The reference install's stored options could not
be read (/api/hassio/ refuses a long-lived token) and probing it was out of
scope — it regulates a real battery. It runs max_w: 5000 and is unaffected
either way.
⚠️ Known, deliberately left, and worth a follow-up: main.py and
maintenance.py still fall back to 2500 when the key is absent from the
options dict, while config.yaml and validate.py now say 2000. Unreachable on
a Supervisor install (every key is always supplied) and reachable only from
tests/sim. The real fix is one source of truth for option defaults, which is
bigger than this ticket and touches files another ticket is in.
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.