checks / unit (pull_request) Successful in 2m39s
TARIFF-01 (#17) landed on release/1.0 first, so this branch was two commits behind and Gitea reported the PR unmergeable. Integrating rather than rebasing: the two reviewed commits stay addressable, and the branch is protected against force-push anyway. ONE CONFLICT, AND IT IS THE BORING HALF OF THE ONE REVIEW PREDICTED. Review of #16 expected collisions in four places - the tail of `options:`, the tail of `schema:`, the rule list inside validate_startup_config(), and the tail of validate.py. None of them happened: TARIFF-01 touched neither config.yaml nor validate.py. It added app/tariff.py, test_tariff.py, two P1 scenario captures, and edits to main.py and p1.py, none of which this branch goes near. The only conflict was CHANGELOG.md, where both branches prepended a block under the same `## Unreleased` heading. Both blocks kept, TARIFF-02 above TARIFF-01 since it lands second. Nothing was dropped or reworded on either side, and the resolution adds no line of its own beyond the blank one between them. Checked rather than assumed after the merge: - all ELEVEN test files pass, including TARIFF-01's own test_tariff.py (which this branch had never run) and the 93 s test_loop.py; - the 24-mutation battery still has 0 survivors; - no semantic overlap in config: TARIFF-01's peak tracker reads the peak_cap_w / peak_forecast_entity options that already existed, so the price options added here neither shadow nor duplicate anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
902 lines
54 KiB
Markdown
902 lines
54 KiB
Markdown
# Changelog
|
||
|
||
## Unreleased
|
||
|
||
**Price provider plugins, and one canonical all-in price format (TARIFF-02).**
|
||
`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE),
|
||
`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline
|
||
`manual_csv` - all emitting exactly `{"prices": [{"start", "eur_per_kwh",
|
||
"provisional"}]}`, tz-aware at the Brussels offset, EUR/kWh, all-in. **Nothing
|
||
consumes it yet**: this is the seam, landed first, so that the arbitrage work
|
||
that follows never learns a supplier's name.
|
||
|
||
**Double-counting the markup is refused rather than discouraged, given a
|
||
correct declaration.** A plugin does not return a price; it returns the figure
|
||
its source published and declares which of {energy, markup, taxes} that figure
|
||
contains. The framework adds the complement, once. Engie and TotalEnergies
|
||
publish a retail figure with their margin already inside; adding the configured
|
||
3 c/kWh on top would make every future arbitrage decision wrong by that amount
|
||
with nothing reporting it.
|
||
|
||
Two mechanisms, because one was not enough. `PluginOptions` raises on the
|
||
framework-owned keys - and review of PR #16 then wrote a double-counting plugin
|
||
three more ways, one of them touching nothing but `PRICE_OPTION_DEFAULTS`, a
|
||
public dict in the module every plugin already imports. So `register()` now also
|
||
**refuses at import any plugin whose own code mentions a framework key**
|
||
(`code_leaks()`, docstrings stripped via `ast` so a plugin's mandated
|
||
`SOURCES:` line does not false-positive). Honest wholesale baseline 0.188
|
||
EUR/kWh; every attack landed 0.219.
|
||
|
||
**The guarantee's precise shape**, stated because an overclaim is worse than no
|
||
claim: the framework adds the complement exactly once *given a correct `sources`
|
||
declaration*. A plugin that declares `{energy}` while its feed is really all-in
|
||
still comes out one markup high while touching no framework name, and no code
|
||
can catch that - the declaration is the plugin's unverifiable claim about
|
||
somebody else's tariff. What is mechanised is reachability; what stays human is
|
||
truthfulness.
|
||
|
||
**The product type is declared, not inferred from the plugin's name.**
|
||
`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage
|
||
thresholds; that reaches a consumer through `capabilities().product` and
|
||
`volatility_regime()`, never through a name comparison. `test_price.py` asserts
|
||
mechanically that no plugin id appears anywhere in the framework's own code,
|
||
and drives a sixth provider defined inside the test file to the correct regime
|
||
with `price.py` untouched.
|
||
|
||
**"No price data" is a type, not an empty list.** Following the same rework
|
||
`sim/riglock.py` took, a fetch returns `PriceRead.series(...)` or
|
||
`PriceRead.no_data(reason)`, and only the first carries fields. An empty series
|
||
is not constructible; `no_data` has no `.points` to iterate, no `.at()`, and
|
||
`.get("points", [])` raises rather than returning the default. **A day of free
|
||
electricity and a day with no prices are different instructions** - arbitrage is
|
||
pointless under the first and forbidden under the second - and an implementation
|
||
that returns `[]` on a failed fetch cannot tell them apart. Every site-level
|
||
failure returns `no_data`; nothing raises into the control tick.
|
||
|
||
**What is not verified, and cannot be by an offline suite:** every fixture is
|
||
synthetic, so the five feed URLs and JSON shapes are checked against a belief
|
||
about what those suppliers publish, not against the feeds. The parse layer is
|
||
exercised; the contract with each supplier is not. Expect the first live fetch
|
||
of each to need a shape fix, and note that a wrong shape degrades to
|
||
no-price-data rather than to a wrong price.
|
||
|
||
**`manual_csv` is the documented offline fallback** and needs no network, no
|
||
supplier account and no HTTP stack. See DOCS.md for the format. Its `start`
|
||
column must carry a UTC offset - a bare local timestamp is refused, because one
|
||
hour every October names two moments.
|
||
|
||
**Injection is priced separately** (`export_price_eur_kwh`), a scalar rather
|
||
than a second series, and negative values are accepted. Consumption plugins
|
||
cannot reach it.
|
||
|
||
**`price_provider` may not be combined with `price_now_entity` /
|
||
`price_avg_entity`** - the add-on refuses to start on that pair. The Home
|
||
Assistant sensors are the existing delegation of pricing to HA and still work
|
||
alone; with both configured they would silently keep deciding while the status
|
||
page named a supplier that decided nothing.
|
||
|
||
**No timezone database is used.** The shipped Alpine image has no `tzdata`, so
|
||
Brussels offsets are computed from the EU rule and every instant is carried as
|
||
UTC and rendered at the offset that applies to it. The checks drive both 2026
|
||
transitions - the 23-hour and 25-hour days - rather than a normal day, where a
|
||
fixed 24-slot implementation and a correct one agree.
|
||
|
||
**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.yaml` and
|
||
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_local` is covered** — it is the only transport
|
||
that sees the meter's own document. Everywhere else the status page says
|
||
**NOT COVERED** rather than "armed".
|
||
- `DOCS.md` now states the **capacity-tariff exposure** this does *not* close:
|
||
`peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying
|
||
meter can drive a 5 kW unattended import, and ≥15 minutes of that sets the
|
||
month's billed peak (measured: one 4998 W quarter moved a monthly peak from
|
||
4832 → 5013 W). That cap is SAFETY-07.
|
||
- `test_freeze.py` grades it in both directions: silent over 40 minutes of the
|
||
live house's real converged capture (committed as
|
||
`scenarios/p1-age-2026-08-26.jsonl.gz`), silent over an hour of TEL-05's
|
||
low-power case where a bare threshold is shown to fire, and alarming on a
|
||
genuinely frozen meter driven over real HTTP through the real
|
||
`HomeWizardLocalSource` — against the rig's own `hwsim` `freeze` fault when the
|
||
infra repo is reachable, an equivalent stub otherwise. Its negative twin is the
|
||
load-bearing one: the same railed command against a meter that is still moving
|
||
must stay silent at the same threshold.
|
||
|
||
## 0.5.1
|
||
|
||
**SAFETY-02's latch now survives an add-on restart, which is the difference
|
||
between a latch and a pause.** `auto` was persisted and `sign_wd.tripped` was
|
||
not, so reconstructing the `Controller` against the same store — exactly what a
|
||
container restart does — brought it back **armed, into the same fault, with no
|
||
operator involved**: `tripped=False`, banner back to `NOT READY`, and it resumed
|
||
commanding (measured: −1000 W, −2000 W) before re-tripping 180 s later. Under a
|
||
Supervisor that restarts a crashed add-on that is a loop of three-minute
|
||
wrong-direction bursts — and "restart the add-on" is the first thing an operator
|
||
looking at a `FAILSAFE` banner and a 0 W inverter will try.
|
||
|
||
`sign_tripped` and `sign_reason` are now `Store` keys beside the maintenance
|
||
state, written the moment the trip happens (reason first, flag second, so a crash
|
||
between the two can never persist a latch with no explanation) and restored in
|
||
`Controller.__init__`. Cleared only by the operator's start. `DOCS.md` said "it
|
||
does not clear itself"; that was false across a restart and now is not.
|
||
|
||
Also in this release, all of it review follow-up rather than new behaviour:
|
||
|
||
- **The arming gate is tested against the real arbiter.** It was the decision the
|
||
whole ticket turns on and it was asserted in three comment blocks and verified
|
||
by nothing — every case passed `following` in as a literal. `test_watchdog.py`
|
||
now derives it from `resolve()` across twelve claim configurations, including
|
||
the redirect case `winner == "loop"` alone would have got wrong and the `lo`
|
||
limit that raises magnitude without flipping sign.
|
||
- **The MQTT `status: "failsafe"` value is tested.** The check that claimed to
|
||
cover it restated the assertion three lines above it and never touched the
|
||
payload.
|
||
- **A forward hazard written down where it will be read, and made to bite.**
|
||
`winner == "loop"` disarms the watchdog under any higher-priority SET claim.
|
||
Today only maintenance and the safety stops emit one and neither follows the
|
||
meter; a peak-shaving strategy at `P_PEAK` **does**, and would blind the
|
||
watchdog for every peak event.
|
||
|
||
The gate is now `watchdog.loop_is_following()`, called by both `main.py` and
|
||
the test, for the same reason `disagrees()` was extracted: the test held a
|
||
*transcription* of the shipped expression, so editing the shipped gate alone to
|
||
`winner in ("loop", "peak")` left every suite green while the watchdog went
|
||
blind. Two copies, and the one that drifts is the one doing the grading.
|
||
|
||
⚠️ Extracting it was not sufficient on its own. The realistic P_PEAK row —
|
||
peak shaving asking for *more* than the loop — still could not detect the
|
||
widened gate, because the magnitude term rejects it regardless and masks the
|
||
winner term. A second row with peak asking for *less* isolates `winner` as the
|
||
only deciding term, and that is the one that turns red.
|
||
- **The worked example described a configuration that does not ship.** At the
|
||
defaults `maintenance_charge_w` (2500) exceeds `max_w` (2000), so that claim is
|
||
an arbiter *contradiction* resolving to 0 W, not a −2500 W charge. The example
|
||
is now written at 1500 W. (The contradiction is a real pre-existing bug —
|
||
a default cheap-window maintenance charge silently never happens — filed
|
||
separately, deliberately not fixed here.)
|
||
- **`disagrees()` is public**, so `sim/replay_sign.py` stops carrying its own copy
|
||
of the predicate it grades.
|
||
- `held_s` takes the clock instead of reading one, restoring the pure-function
|
||
property the module advertises.
|
||
- **The `sign_reason`-before-`sign_tripped` write ordering is enforced, not just
|
||
documented.** Swapping the two lines used to pass every check. A store that
|
||
fails the second write now proves the property the ordering exists for: what
|
||
survives a crash mid-trip is a reason with no latch, never a latch with no
|
||
explanation.
|
||
- Measured figures corrected throughout: 93.4 % / 4212 of 17280 (was 92.9 % /
|
||
4127 of 16349), per-cluster 0.3 / 0.9 / 10.5 / 0.0 %.
|
||
|
||
## 0.5.0
|
||
|
||
**SAFETY-02 — the sign-disagreement watchdog.** The add-on now watches whether
|
||
the setpoint it is emitting and the grid figure it is regulating on point the
|
||
same way. If they oppose each other, both over `sign_disagree_w` (300 W), for
|
||
`sign_disagree_s` (180 s) **continuously**, it files a failsafe claim at
|
||
`P_SAFETY`, commands **0 W**, and latches there until an operator starts control
|
||
again. New module `app/watchdog.py`; new options `sign_disagree_w` and
|
||
`sign_disagree_s`.
|
||
|
||
This is the gap the existing failsafes leave open. The stale-input watchdog asks
|
||
whether the meter is still talking, the saturation freeze asks whether the
|
||
inverter can follow, the output clamp asks whether the number is too big — a
|
||
loop can pass all three while charging the battery out of the grid, because the
|
||
only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly
|
||
that four times in one day, for up to 77 minutes at a stretch, and reached a
|
||
commanded −14 768 W against a ±5000 W inverter.
|
||
|
||
Validated against that day. `sim/replay_sign.py` in the infra repo drives this
|
||
module over the committed RS485 captures of both 2026-08-20 (the four episodes)
|
||
and 2026-08-23 (healthy): **18 trips on the fault day, every one inside a window
|
||
`NOTES.md` §4.7 accounts for, and 0 on the healthy day.** The four episodes do
|
||
share one signature rather than merely resembling each other — sign agreement
|
||
runs 0.3 / 0.9 / 10.5 / 0.0 % inside them against 93.4 % on the healthy day.
|
||
|
||
Three decisions worth knowing about:
|
||
|
||
- **It is disarmed whenever the grid-following loop is not what is driving.**
|
||
Sign agreement is a property of a loop that follows the meter. A maintenance
|
||
grid charge opposes the meter *by design* — 2500 W of charge into a house
|
||
drawing 500 W is +3000 W on the meter against a −2500 W command, for hours —
|
||
and armed unconditionally this would trip on every maintenance cycle. The gate
|
||
tests the emitted number, not the winning claim's name: a limit claim can let
|
||
the loop "win" and still replace its value with one pointing the other way.
|
||
- **The trip latches.** Commanding 0 W removes the command side of the
|
||
comparison, so the disagreement always clears the moment the trip works.
|
||
Un-latched, this would trip, clear, and hand the actuator back to the loop that
|
||
was driving the wrong way, every three minutes forever.
|
||
- **`sign_disagree_w` at or above `max_w` is refused at startup** (validator rule
|
||
5). The arbiter never lets a command exceed `max_w`, so such a threshold makes
|
||
the watchdog incapable of firing while the status page still reports it armed.
|
||
A disarmed guard that advertises itself as armed is the failure mode this
|
||
project keeps refusing to ship.
|
||
|
||
What it does **not** catch, stated so nobody assumes otherwise: an inverter that
|
||
acts opposite to what it was told moves the meter *further* the same way, so
|
||
command and meter agree while everything runs away. That is the saturation
|
||
freeze's job, which compares the command against the battery's actual power.
|
||
|
||
`test_validate.py`'s "the smallest sane max_w still starts" case now names a
|
||
reachable sign threshold, because a 1 W ceiling genuinely does disarm the 300 W
|
||
default and is now correctly refused.
|
||
|
||
## 0.4.0
|
||
|
||
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
|
||
instead of logging a line and carrying on. `validate_startup_config()` runs in
|
||
`amain()` before the broker, the web server or any task exists, so a refusal
|
||
cannot leave half a controller running behind a UI.
|
||
|
||
It refuses only where continuing produces one of two outcomes: **(a)** a stated
|
||
limit is untrue - the operator configured a bound and a lower layer silently
|
||
enforces a different one - or **(b)** the add-on is inert and indistinguishable
|
||
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
|
||
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
|
||
become unsafe, it becomes a convincing impression of a working controller.
|
||
|
||
Four things qualify:
|
||
|
||
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
|
||
that changed behaviour. It used to warn `the device wins` and run anyway, with
|
||
`write_setpoint()` silently clamping, so the configured ceiling was never the
|
||
real one and the only evidence had scrolled off the log. The refusal names the
|
||
configured value, the device's value, and which wins.
|
||
- **No `setpoint_entity`.** A controller with no actuator: every write is a
|
||
no-op, the device rating is never read, and the ESP32 heartbeat that holds off
|
||
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
|
||
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
|
||
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
|
||
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
|
||
`meter_max_age_s`. These were already caught, but they only *disabled
|
||
ingestion*, which leaves the grid reading None forever: permanent "inputs
|
||
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
|
||
has sent anything to yet.
|
||
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
|
||
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
|
||
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
|
||
reading as fresh and keeps commanding, while the firmware has already vetoed
|
||
and is holding the inverter at 0 W - healthy meter, running controller,
|
||
nothing moving, and no line anywhere saying the number you set was overruled.
|
||
Structurally the same fault as the `max_w` case. Only *above* the veto is
|
||
refused: below it the add-on gives up first, which is fail-safe and visible on
|
||
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
|
||
be imported from a compile-time YAML substitution, so it carries a note about
|
||
the one direction in which it can go stale.
|
||
|
||
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
|
||
`validate_startup_config()` is now also the simulation suite's entry point, and
|
||
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
|
||
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
|
||
than as the typo it is.
|
||
|
||
The predicates for the meter group still live in `p1.validate_meter_options()`,
|
||
which `build_source()` also calls - one definition, two callers, not two
|
||
competing validators. Two transports gained a check they never had: `ha_dsmr`
|
||
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
|
||
which previously built a transport that could never read.
|
||
|
||
Deliberately **not** refused, because it is untidy rather than unsafe:
|
||
`integrator_max_w` above `max_w` (the arbiter still bounds the wire to ±max_w,
|
||
so the cost is unwind latency, not amperage - warned), a missing `soc_entity` /
|
||
`batt_entity` (fail-safe, and already a red NOT READY banner naming it), a
|
||
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
|
||
late; refusing would make a slow boot need a human - re-checked once the real
|
||
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
|
||
|
||
A refusal is legible as a refusal: a banner that says in words that this is a
|
||
configuration problem and nothing is broken, the numbered problems, no traceback,
|
||
and exit status 78 (`EX_CONFIG`).
|
||
|
||
**SAFETY-12.** The control loop now evaluates on a clock as well as on a change.
|
||
`run_control()` ran `cycle()` only when the meter value differed from the
|
||
previous poll, so a meter that repeated a value ran **no control cycles at
|
||
all** - while the heartbeat went on re-emitting the last command and the
|
||
firmware went on putting it on the wire every 2 s. Layer 1 stayed fed, every
|
||
indicator read healthy, and a command computed from an old reading kept being
|
||
executed through whatever the load did next. Measured on the rig during TEL-05:
|
||
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||
|
||
**It was worse than a latency limit: it was a safety claim that could not
|
||
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
|
||
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
|
||
never called again - so that claim had no way to run. Constant meter, SoC entity
|
||
dying at t=4 s, same binary:
|
||
|
||
```
|
||
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
|
||
final target: 220.0 W reason: 'loop -> 220 W'
|
||
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
|
||
final target: 0.0 W reason: 'safety -> 0 W'
|
||
```
|
||
|
||
The pre-fix controller held 220 W through the entire input outage with its
|
||
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
|
||
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
|
||
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
|
||
behaviour was not "holds a stale command safely" - it was a failsafe that a
|
||
repeating meter switched off. `test_loop.py` asserts both halves.
|
||
|
||
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
|
||
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
|
||
meter it never elapses, so the cycle rate, the write sequence and therefore the
|
||
per-cycle gain the tuning was measured against are unchanged - asserted in
|
||
`test_loop.py` by driving the real loop twice on one binary (a huge
|
||
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
|
||
commanded values.
|
||
|
||
The reset is not the only thing holding that up, and the other argument is the
|
||
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
|
||
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
|
||
even a hypothetical floor racing the arrivals could not exceed the nominal
|
||
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
|
||
while `cycle_floor_s` stays above the meter's update interval, and the schema
|
||
minimum is 5 s - which is exactly the reference meter's interval. Nothing
|
||
enforces the relationship; `validate.py` is the obvious home for it and does not
|
||
have it yet.
|
||
|
||
Observed over six real minutes on the same rig, one binary, meter held at a
|
||
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
|
||
the command stuck at 220 W against a 350 W import for the rest of the window.
|
||
**After, 24 cycles**, worst gap 15.14 s, and the ordinary PI ramp doing its job -
|
||
220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W, one step per cycle,
|
||
rail at t=136 s and flat after. Three minutes with the meter parked at the
|
||
target instead: **1 cycle before, 12 after, and 19 writes either way - the same
|
||
19**. Three minutes on a normally-changing meter: **36 cycles and 49 writes on
|
||
both sides, the same values in the same order.** The heartbeat write count never
|
||
moves; the only writes the fix adds are the ten the loop was supposed to be
|
||
making all along.
|
||
|
||
15 s is bracketed, not round.
|
||
|
||
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
|
||
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
|
||
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
|
||
the poller steps over an update entirely - a ~10 s gap between changed values on
|
||
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
|
||
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
|
||
sits above the beat-skip so it does not fire on that alone; what it takes to
|
||
reach 15 s is a beat-skip *and* a failed poll coinciding.
|
||
|
||
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
|
||
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
|
||
guaranteed to have re-decided at least twice inside any window in which layer 1
|
||
might otherwise be the only thing deciding. ⚠️ The default is therefore not
|
||
independently justified at 15: if the firmware's veto ever moves, this moves
|
||
with it. Noted beside the constant in `main.py`.
|
||
|
||
It is also exactly `stale_input_s`, this system's existing answer to "how long
|
||
may we hold without re-deciding".
|
||
|
||
A floor cycle runs the **full** law on the same inputs, integrator included. In
|
||
P1 mode the freshness question is already answered upstream and answered harder
|
||
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
|
||
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
|
||
this system already calls fresh. ⚠️ That is transport-conditional: with
|
||
`meter_source: off`, `read_inputs()` takes the grid figure straight from
|
||
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
|
||
gone quiet reads as current on that path, with or without this change. Running
|
||
the full law is still right there - the alternative is a degraded second law
|
||
reasoning about an age nobody measured - but the floor does not make that path
|
||
fresh. The integrator advances per *cycle*, not per second,
|
||
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
|
||
that. A gentler second law for floor cycles would be a second set of dynamics
|
||
nobody can test on this hardware.
|
||
|
||
⚠️ **This is not a freeze detector**, and TEL-05 established one cannot be built
|
||
here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move
|
||
while the power figure legitimately repeats, so any threshold on `unchanged_s`
|
||
false-trips at the exact operating point we run at. Nothing in this change
|
||
decides whether a repeated reading is *true*. It stops "the number is the same"
|
||
from meaning "do not think". The consequence, stated plainly: against a meter
|
||
genuinely frozen at a wrong value the loop will now correct toward that wrong
|
||
value rather than hold a stale command, bounded by `max_w`, the arbiter and the
|
||
device rating. That was already what happened to a lying meter whose value
|
||
merely jittered; bit-exact repetition was never a safety property.
|
||
|
||
`DOCS.md`'s "Saturation is counted in cycles, not seconds" section documented
|
||
the old behaviour as an accepted deviation. It is **qualified, not deleted**:
|
||
the deviation survives (the window is still counted in cycles, and 3 x 15 s is
|
||
still not the specification's 10 s) but it is now bounded at 45 s instead of
|
||
open-ended, and the passage's second justification - "nothing accumulates while
|
||
it is stalled" - is called out as having covered integrator windup and not
|
||
staleness, which was the actual hazard. The same wrong argument in
|
||
`control.py`'s saturation comment is corrected too.
|
||
|
||
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
|
||
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
|
||
unsigned registers and refuse negatives, so neither fits the meter installed here.
|
||
|
||
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
|
||
directly. This exists because of something the HA paths cannot do: Home Assistant
|
||
fires no state change for a repeated reading and exposes no arrival signal at
|
||
all, so `sensor.p1_sample_age_s` on those transports measures time since the
|
||
*value changed*, not since the meter *reported*. Real captured data has gaps of
|
||
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
|
||
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
|
||
local API is a genuine arrival, so the age means what it says.
|
||
|
||
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
|
||
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
|
||
age never exceeding the poll cadence.
|
||
|
||
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
|
||
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
|
||
install that does not opt in.
|
||
|
||
Freeze detection is separate and deliberately not thresholded: a meter answering
|
||
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
|
||
energy registers carry that as `unchanged_s`, surfaced on the status line for a
|
||
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
|
||
about six minutes to move, so any threshold would false-trip at the target
|
||
operating point.
|
||
|
||
## Unreleased
|
||
|
||
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
|
||
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
|
||
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
|
||
(default 5 s, the meter's own update rate) sets the cadence.
|
||
|
||
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
|
||
the meter *reported*, and therefore the first one a firmware watchdog may
|
||
threshold.** Every HTTP response is an arrival: the meter answered, now, with
|
||
its current reading, and whether the *number* moved is not consulted. Home
|
||
Assistant cannot express that at all — a repeated reading emits no
|
||
`state_changed`, advances `last_reported` on neither serialiser, and
|
||
`state_reported` is not subscribable ("Event filter is required"). On
|
||
`ha_signed` that made a healthy meter under a flat load indistinguishable from
|
||
a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s
|
||
between changes — both past the default `meter_max_age_s` of 30, i.e. a false
|
||
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
|
||
mode.
|
||
|
||
Everything TEL-01 established is reused, not re-implemented: ingest
|
||
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
|
||
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
|
||
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
|
||
is a *missing* reading — never 0 W — and it does not reset the age.
|
||
|
||
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
|
||
freeze` injected. Still defaults to `off`.
|
||
|
||
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
|
||
detector can: a meter answering `200 OK` forever with a stale number is
|
||
arriving. The local API does expose what the HA path never had — the
|
||
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
|
||
`unchanged_s`, but that is deliberately **not** folded into the age and not
|
||
thresholded: this controller regulates grid power toward ~0 W, and at a
|
||
converged −10 W the export register needs six minutes to move by its 1 Wh
|
||
resolution while the power figure legitimately repeats. Thresholding it at 30 s
|
||
would rebuild the false-trip limit cycle at the exact operating point we aim
|
||
for. Freeze detection needs the low-power case solved first, separately. It is
|
||
shown on the status page's P1 line instead — leaving it unthresholded only
|
||
holds up if a human can read it, so now they can.
|
||
|
||
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
|
||
Assistant entity: positive = import, negative = export. That is the shape a
|
||
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
|
||
actually fitted here - which neither TEL-01 transport can read, because
|
||
`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every
|
||
exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the
|
||
per-phase capacity-tariff figures on a three-phase connection.
|
||
|
||
Everything TEL-01 established is inherited rather than re-implemented - the
|
||
new transport is a subclass of the `ha_dsmr` one overriding only which
|
||
entities it wants and how they become a sample. So ingest timestamping,
|
||
`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and
|
||
republished once a second, the plausibility bounds, and `unavailable` /
|
||
`unknown` treated as a *missing reading and never 0 W* all behave identically
|
||
across the three sources.
|
||
|
||
Still defaults to `off`; an existing install is unaffected until it opts in.
|
||
|
||
⚠️ **`sensor.p1_sample_age_s` is published on `ha_signed`, but must not yet be
|
||
thresholded by the ESP32 stale-input watchdog.** On the HA WebSocket paths the
|
||
age is stamped from `state_changed`, so it measures time since the value
|
||
*changed*, not since the meter *reported* - and Home Assistant exposes no
|
||
arrival signal for a repeated reading (no `state_changed`, no `last_reported`
|
||
movement on either serialiser, and `state_reported` is not subscribable over
|
||
the WebSocket). Measured on the ENV-01 rig against the real HomeWizard
|
||
integration. `ha_dsmr` mostly escapes it because a telegram moves several
|
||
entities at once; `ha_signed` has one, so a healthy meter under a flat load is
|
||
indistinguishable from a dead one. Our own capture has the house meter going
|
||
42.2 s and 97.0 s between changes. Raising `meter_max_age_s` does not fix that,
|
||
it only chooses which error you get; the fix is an arrival stamp from the meter
|
||
itself and is a separate ticket. Full detail in DOCS.md.
|
||
|
||
## 0.3.0
|
||
|
||
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||
bounded independently of the output clamp instead of inheriting whatever
|
||
headroom the clamp happened to leave. It also freezes while the inverter is
|
||
not tracking, rather than continuing to wind up against a command nothing is
|
||
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
|
||
"follow `max_w`", which is the existing behaviour.
|
||
|
||
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
|
||
against the previous control law confirms it decides identically at
|
||
`integrator_max_w: 0`.
|
||
|
||
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
|
||
(consumption, injection) no longer need a hand-written signed template
|
||
sensor: the subtraction moves into the add-on, done once and tested. Two
|
||
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
|
||
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
|
||
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
|
||
nothing changes for an install that does not opt in.
|
||
|
||
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
|
||
accepted telegram, recomputed against the clock and republished roughly once
|
||
a second rather than only when a telegram lands. That is deliberate - Home
|
||
Assistant only pushes a state on change, so a meter sitting at a genuinely
|
||
constant reading would otherwise look identical to a dead one. Watching the
|
||
age instead means a frozen meter shows a climbing age, not a flat line. The
|
||
firmware watchdog subscribes to this exact entity id.
|
||
|
||
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
|
||
republishing its last telegram still "arrives", so the age cannot detect
|
||
that particular failure - prefer `ha_dsmr` where both are available. And a
|
||
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
|
||
to call the reading stale, then 15 s of `stale_input_s` on top), which is
|
||
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
|
||
|
||
## 0.2.1
|
||
|
||
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
||
holds any resting point inside it indefinitely, and the meter bills import and
|
||
export on separate registers, so resting at +14 W import costs 0.34 kWh/day
|
||
with the loop behaving perfectly. Biasing the target slightly negative moves
|
||
that residue onto the export register. Configurable in the Configuration tab;
|
||
see DOCS.md for the trade-off table.
|
||
|
||
Behaviour is unchanged at `target_grid_w: 0`.
|
||
|
||
## 0.2.0
|
||
|
||
Precedence between strategies is now a first-class object instead of an if/else
|
||
ladder, ahead of there being more than three of them.
|
||
|
||
Every strategy returns a CLAIM each cycle - `set` ("I want X") or `limit` ("the
|
||
result must stay within these bounds") - and `arbiter.py` resolves them by one
|
||
rule:
|
||
|
||
1. Highest-priority `set` wins; no claim at all means 0 W.
|
||
2. Then every `limit` whose priority is >= that set's priority applies, most
|
||
restrictive first.
|
||
3. Contradictory limits are a BUG: command 0 W and say so.
|
||
|
||
Clause 2 is why "money outranks maintenance" is now a consequence of the
|
||
priorities rather than a special case in a Jinja template: the maintenance
|
||
charge-only limit binds the loop, but will not bind a higher-priority peak
|
||
shaving claim when one exists.
|
||
|
||
- Maintenance shaping (charge-only, cheap-window floor) moved out of the control
|
||
law. `control.py` is once again only a controller that tracks the meter.
|
||
- The loop computes from the ARBITER's last output, not its own last wish. If
|
||
something outranked it, that is what the hardware actually did, and tracking
|
||
anything else makes it jump when it regains control.
|
||
- Every decision is explainable: "loop -> 0 W, limited by maintenance
|
||
(charge-only)" now appears in the UI and the log, instead of a bare number.
|
||
- Safety limits (device rating, supervised max_w) bind every strategy including
|
||
the highest, and are still enforced a second time at the point of writing.
|
||
|
||
## 0.1.6
|
||
|
||
Findings from installing this on a live system, replacing a working YAML
|
||
implementation. Every one of these was silent - the add-on looked healthy while
|
||
being completely unable to do its job.
|
||
|
||
- **`run.sh` must use `#!/usr/bin/with-contenv sh`.** The HA base images run
|
||
s6-overlay, which starts services with a SANITISED environment. With a plain
|
||
shebang, SUPERVISOR_TOKEN is simply absent and every Home Assistant call
|
||
returns 401 - while `homeassistant_api: true` makes the permissions look
|
||
correctly granted. Startup now logs the token length and probes the Core API,
|
||
so the next person sees it in one line.
|
||
- **Bump `version:` for every change.** Supervisor keys the built image by
|
||
version, so editing source and rebuilding silently reuses the old image. Two
|
||
fixes appeared not to work because of this.
|
||
- **Dependencies come from apk, not pip.** Alpine is musl and there are no musl
|
||
wheels for aiohttp; pip would compile it on the client's Pi.
|
||
- **paho-mqtt 1.x and 2.x are both supported.** Alpine ships 1.x, which has no
|
||
`CallbackAPIVersion`; that raised and took the whole add-on down with it.
|
||
- **MQTT can no longer take down control.** Publisher construction is wrapped -
|
||
observability must never stop the controller.
|
||
- **MQTT discovery is published from `on_connect`.** paho drops QoS-0 publishes
|
||
issued before the CONNACK, so announcing straight after `connect()` published
|
||
nothing at all while logging "MQTT connected".
|
||
- **Repeated failures log at most once a minute.** The control loop retries every
|
||
second; unthrottled warnings rolled the log buffer and destroyed the startup
|
||
diagnostics needed to debug the 401 above.
|
||
- **`auto_start` works.** The store's defaults supplied `auto: False`, so the
|
||
fallback to the option could never fire.
|
||
|
||
Known issue: after deleting the MQTT entities from the registry during
|
||
development, Home Assistant would not re-adopt them from retained discovery -
|
||
not even after clearing the retained topics and reconnecting. The add-on
|
||
publishes correct discovery and live state (verified on the broker); this is an
|
||
HA-side adoption problem and affects status entities only, never control.
|
||
|
||
## 0.1.0
|
||
|
||
First packaged release. Ports the control loop and the monthly maintenance
|
||
cycle from the reference Home Assistant implementation into an add-on.
|
||
|
||
- Grid-following control: gain/slew/clamp/deadband with anti-windup, all tuned
|
||
against measured hardware behaviour (see FIELD-GUIDE.md §14).
|
||
- Saturation freeze **with the duration term** — three consecutive diverging
|
||
cycles, not one. The instantaneous test fires on every large correction,
|
||
because the plant itself needs 3-6 s to settle.
|
||
- Monthly maintenance cycle as an ownership state machine: drain / charge /
|
||
hold, with exactly one writer of the setpoint at any moment.
|
||
- Capacity-tariff awareness: the maintenance charge is capped by quarter-hour
|
||
peak headroom, and peak shaving outranks the maintenance schedule.
|
||
- Failsafe behaviour: commands 0 W on missing inputs, on stop, and on shutdown.
|
||
Never replays a stale setpoint - the reference implementation did, and the
|
||
hardware watchdog cannot catch that.
|
||
- Ingress UI with a commissioning checklist that names problems in words.
|
||
- Optional MQTT discovery for status entities.
|
||
|
||
Known limits:
|
||
|
||
- Home Assistant OS / Supervised only (add-ons cannot run on Container/Core).
|
||
- The inverter protocol is reverse-engineered; no vendor contract.
|
||
- Without the optional RS485 e-stop, nothing covers the host machine dying.
|