Compare commits
@@ -0,0 +1,10 @@
|
|||||||
|
# This add-on is deployed to a Linux container. core.autocrlf=true on the
|
||||||
|
# authoring box gave the checkout CRLF, so a plain copy shipped CRLF files -
|
||||||
|
# run.sh with CRLF is a "bad interpreter" failure, and any hash-based drift
|
||||||
|
# check between repo and deployment fails for a reason that has nothing to do
|
||||||
|
# with the code. Deploy with:
|
||||||
|
# git -c core.autocrlf=false archive release/1.0 goodwe_controller | tar -x
|
||||||
|
# which is how 0.3.0 went out, byte-identical to the blobs.
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.gz binary
|
||||||
+33
-2
@@ -12,7 +12,7 @@ a GoodWe GW5000S-BP.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| running | `local_goodwe_controller` **0.2.0**, `boot: auto`, holding grid at zero |
|
| running | `local_goodwe_controller` **0.4.0**, `boot: auto`, holding grid at zero |
|
||||||
| installed as | a **Local** add-on: `/addons/goodwe_controller` on HA OS at 192.168.2.158 |
|
| installed as | a **Local** add-on: `/addons/goodwe_controller` on HA OS at 192.168.2.158 |
|
||||||
| repo | Gitea `admin/goodwe-addon` (ssh://git@192.168.2.147:2222) — **private** |
|
| repo | Gitea `admin/goodwe-addon` (ssh://git@192.168.2.147:2222) — **private** |
|
||||||
| tuning | gain 0.6, `max_w` 5000, slew 1000, deadband 15, step 10, saturation 500/3, heartbeat 10 s |
|
| tuning | gain 0.6, `max_w` 5000, slew 1000, deadband 15, step 10, saturation 500/3, heartbeat 10 s |
|
||||||
@@ -52,9 +52,40 @@ Run the checks before deploying. No framework needed:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd goodwe_controller
|
cd goodwe_controller
|
||||||
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py
|
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py \
|
||||||
|
&& python3 test_validate.py && python3 test_p1.py && python3 test_watchdog.py \
|
||||||
|
&& python3 test_loop.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`test_watchdog.py` covers SAFETY-02's logic, its arming gate (derived from the
|
||||||
|
real arbiter, not passed in), its trip path, and the fact that the latch survives
|
||||||
|
an add-on restart. Its headline validation is not here at all: `sim/replay_sign.py`
|
||||||
|
in the infra repo drives the same module over the two captured RS485 days and must
|
||||||
|
fire on all four of the 2026-08-20 episodes and stay silent on 2026-08-23. That
|
||||||
|
needs both checkouts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 sim/replay_sign.py
|
||||||
|
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 sim/replay_sign.py --stub
|
||||||
|
```
|
||||||
|
|
||||||
|
The second run checks the grader still *discriminates* rather than merely still
|
||||||
|
passing — a watchdog that never trips and one that always trips must both be
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
⚠️ **If you are testing the failsafe by hand, note that stopping and starting the
|
||||||
|
add-on will not clear a trip.** That is the point of it; use the *Start / stop
|
||||||
|
control* button twice, or delete `sign_tripped` from `/data/state.json`.
|
||||||
|
|
||||||
|
⚠️ `test_loop.py` takes about **95 seconds** (measured 93 s) and that is not a bug. It
|
||||||
|
drives the real control loop, which sleeps 1 s a tick, eight times over, to prove a
|
||||||
|
wall-clock property about it (SAFETY-12); mocking the clock there would leave the
|
||||||
|
assertion testing the mock. The other six suites together are ~440 checks and finish in
|
||||||
|
seconds — `test_watchdog.py` spends ~3 s of that driving a real `Controller` through a
|
||||||
|
trip. If you shorten `test_loop.py`'s windows, re-measure and correct this figure —
|
||||||
|
an understated runtime is what makes a slow test look like a hung one, and a slow test
|
||||||
|
that looks hung gets deleted.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Known broken / unfinished
|
## 3. Known broken / unfinished
|
||||||
|
|||||||
@@ -1,5 +1,487 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
**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.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
|
## 0.3.0
|
||||||
|
|
||||||
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||||||
|
|||||||
+240
-28
@@ -24,6 +24,9 @@ The inverter **holds its last command forever** — it has no meter-timeout. So:
|
|||||||
commanding it.
|
commanding it.
|
||||||
- This add-on commands 0 W when its inputs go missing, when you stop control,
|
- This add-on commands 0 W when its inputs go missing, when you stop control,
|
||||||
and when it shuts down.
|
and when it shuts down.
|
||||||
|
- This add-on commands 0 W **and latches there** if what it is commanding and
|
||||||
|
what the meter reads point opposite ways for three minutes — see "The
|
||||||
|
sign-disagreement failsafe" below.
|
||||||
- The **optional RS485 e-stop** is the only thing that covers this machine
|
- The **optional RS485 e-stop** is the only thing that covers this machine
|
||||||
dying. Without it, a failed host leaves the battery latched at its last
|
dying. Without it, a failed host leaves the battery latched at its last
|
||||||
command until someone intervenes.
|
command until someone intervenes.
|
||||||
@@ -51,21 +54,67 @@ phase having charged nothing.
|
|||||||
### P1 meter ingestion
|
### P1 meter ingestion
|
||||||
|
|
||||||
`meter_entity` above expects one signed sensor, which usually means a template
|
`meter_entity` above expects one signed sensor, which usually means a template
|
||||||
someone wrote by hand. A Belgian P1 meter does not publish one: it publishes two
|
someone wrote by hand. Setting `meter_source` moves the whole derivation into
|
||||||
**unsigned** registers, consumption and injection. Setting `meter_source` moves
|
the add-on, where it is done once and tested, and replaces `meter_entity`
|
||||||
that subtraction into the add-on, where it is done once and tested, and replaces
|
entirely.
|
||||||
`meter_entity` entirely.
|
|
||||||
|
Which mode you want depends on what your P1 reader publishes, and there are two
|
||||||
|
shapes in the wild:
|
||||||
|
|
||||||
|
- **Two unsigned registers**, consumption and injection, which is what a Belgian
|
||||||
|
P1 read over DSMR gives you → `ha_dsmr`, or `mqtt_p1` for a bridge. The add-on
|
||||||
|
subtracts them.
|
||||||
|
- **One signed figure**, positive = import and negative = export, which is what
|
||||||
|
a HomeWizard P1 gives you (`sensor.p1_meter_active_power`) → `ha_signed`. The
|
||||||
|
add-on splits it. `ha_dsmr` **cannot** read this: it wants two registers and
|
||||||
|
rejects a negative one outright, which is every exporting telegram.
|
||||||
|
|
||||||
|
Either way, do not build the missing shape out of template sensors. The point of
|
||||||
|
`meter_source` is that the sign convention is derived in one tested place rather
|
||||||
|
than in YAML nobody reviews underneath a safety input.
|
||||||
|
|
||||||
|
> ✅ **If your meter is a HomeWizard P1, use `homewizard_local`, not
|
||||||
|
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
|
||||||
|
> polls the meter directly instead of watching a Home Assistant entity — and
|
||||||
|
> that is the difference between an age sensor a watchdog can threshold and one
|
||||||
|
> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for
|
||||||
|
> installs where the meter is only reachable through Home Assistant.
|
||||||
|
|
||||||
|
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
|
||||||
|
> hardware.** No meter in this installation uses either one. Both were written
|
||||||
|
> to the assumption in `specs.md` §5.2 that a Belgian P1 exposes two unsigned
|
||||||
|
> registers, and the meter actually fitted here does not — it is the HomeWizard
|
||||||
|
> P1 that `ha_signed` reads. They are covered by the unit checks in `test_p1.py`
|
||||||
|
> and by an end-to-end test against a fake Home Assistant, and nothing more.
|
||||||
|
>
|
||||||
|
> This is recorded because the realistic way it bites is someone debugging a
|
||||||
|
> meter problem months from now treating those two paths as proven and looking
|
||||||
|
> for the fault elsewhere. If you are the first person to point one at a real
|
||||||
|
> meter, expect to find something, and please update this note when you do.
|
||||||
|
|
||||||
| option | default | meaning |
|
| option | default | meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic |
|
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket; `homewizard_local` polls a HomeWizard P1's own local API, bypassing Home Assistant |
|
||||||
| `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
|
| `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
|
||||||
| `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average |
|
| `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average |
|
||||||
|
| `meter_poll_s` | 5 | `homewizard_local` only. Seconds between polls. **Must be well under `meter_max_age_s`** — see below |
|
||||||
|
| `p1_host` | | `homewizard_local` only. The meter's own address, `host` or `host:port` (e.g. `192.168.2.250`) |
|
||||||
| `meter_mqtt_topic` | | `mqtt_p1` only |
|
| `meter_mqtt_topic` | | `mqtt_p1` only |
|
||||||
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
|
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
|
||||||
| `p1_export_entity` | | The **unsigned** injection sensor |
|
| `p1_export_entity` | | The **unsigned** injection sensor |
|
||||||
| `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection |
|
| `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection |
|
||||||
| `p1_phase_export_entities` | `[]` | L1..L3, in order |
|
| `p1_phase_export_entities` | `[]` | L1..L3, in order |
|
||||||
|
| `p1_net_entity` | | `ha_signed` only. The **signed** net-power sensor: `+` import, `-` export |
|
||||||
|
| `p1_phase_net_entities` | `[]` | `ha_signed` only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection. **The list length must equal `meter_phases`** |
|
||||||
|
|
||||||
|
Both per-phase lists are checked against `meter_phases` **once at startup**: a
|
||||||
|
list of the wrong length disables P1 ingestion with an error in the log, rather
|
||||||
|
than letting every telegram fail its phase-count check one at a time. Leaving
|
||||||
|
the list empty is fine and is not an error — you simply get no per-phase
|
||||||
|
figures, and therefore no capacity-tariff peak. On a three-phase connection
|
||||||
|
that is a much bigger omission than it looks: on a surveyed reading here the
|
||||||
|
phases carried 2769 W of import while the connection netted 187 W, so the
|
||||||
|
billed quantity is understated roughly fifteenfold if the phases are missing.
|
||||||
|
|
||||||
#### How long a dead meter takes to reach 0 W
|
#### How long a dead meter takes to reach 0 W
|
||||||
|
|
||||||
@@ -106,6 +155,55 @@ tested, and guessing a key here means guessing a kilowatt:
|
|||||||
it is present it is used for the age, which is what stops a retained message
|
it is present it is used for the age, which is what stops a retained message
|
||||||
replayed on reconnect from presenting a ten-minute-old reading as current.
|
replayed on reconnect from presenting a ten-minute-old reading as current.
|
||||||
|
|
||||||
|
#### `homewizard_local` — polling the meter instead of Home Assistant
|
||||||
|
|
||||||
|
Set `p1_host` to the meter's address and the add-on does `GET /api/v1/data` on
|
||||||
|
it every `meter_poll_s` seconds, reading `active_power_w` (signed, same
|
||||||
|
convention as `ha_signed`) and the three `active_power_l{1,2,3}_w` fields. Home
|
||||||
|
Assistant is not involved: no entity, no WebSocket, no integration to
|
||||||
|
mis-configure. Per-phase figures are used only when the meter serves all
|
||||||
|
`meter_phases` of them — a single-phase meter returns `null` for L2/L3, and the
|
||||||
|
connection-level reading is still accepted on its own.
|
||||||
|
|
||||||
|
**Why this mode exists:** every HTTP response is an *arrival*. The meter
|
||||||
|
answered, now, with its current reading — whether or not the number moved. That
|
||||||
|
is the signal `sensor.p1_sample_age_s` needs and the one Home Assistant cannot
|
||||||
|
give it at all (see the note below). It is also simply fewer moving parts: the
|
||||||
|
five-second cadence is the meter's own, rather than an integration's polling of
|
||||||
|
it re-published as a state change.
|
||||||
|
|
||||||
|
**Cadence.** The age is never fresher than the poll interval, so:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| meter's own update rate | ~5.0 s (measured 4.97 s) |
|
||||||
|
| `meter_poll_s` default | 5 s — nothing to gain below the meter's own rate |
|
||||||
|
| `meter_max_age_s` default | 30 s, i.e. six polls of headroom |
|
||||||
|
| `meter_poll_s >= meter_max_age_s` | **refused at startup** — every reading would be stale before its successor arrived |
|
||||||
|
| `meter_poll_s > meter_max_age_s / 2` | warned — one missed poll makes the reading stale |
|
||||||
|
|
||||||
|
A failed poll — timeout, connection refused, non-200, unparseable body — is a
|
||||||
|
**missing** reading. It submits nothing, so the reading does not become 0 W, the
|
||||||
|
last good value and its timestamp are left alone, and the age goes on climbing.
|
||||||
|
That is exactly what a dead meter should look like.
|
||||||
|
|
||||||
|
**What it still cannot see: a frozen meter.** A meter that answers `200 OK`
|
||||||
|
forever with a stale number is arriving, so no arrival detector — this one
|
||||||
|
included — can tell it from a healthy one. The local API does expose the raw
|
||||||
|
material the HA path never had (the `total_power_*_kwh` registers stop
|
||||||
|
advancing), and the transport tracks it as `unchanged_s`, but it 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 that at 30 s would rebuild the false-trip limit cycle at
|
||||||
|
the exact operating point the controller aims for. Freeze detection is a
|
||||||
|
separate problem and needs the low-power case solved first.
|
||||||
|
|
||||||
|
You read it yourself instead: the add-on's status page shows it on the P1 line,
|
||||||
|
as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
|
||||||
|
power that figure stays in the seconds; minutes of it while the load is clearly
|
||||||
|
not near zero is the meter to go and look at.
|
||||||
|
|
||||||
#### `sensor.p1_sample_age_s`
|
#### `sensor.p1_sample_age_s`
|
||||||
|
|
||||||
Published over MQTT discovery whenever a broker is available: **seconds since the
|
Published over MQTT discovery whenever a broker is available: **seconds since the
|
||||||
@@ -123,13 +221,48 @@ The entity is only created when `meter_source` is not `off`. With P1 ingestion
|
|||||||
disabled there is nothing feeding it, and an age sensor climbing with no ingester
|
disabled there is nothing feeding it, and an age sensor climbing with no ingester
|
||||||
behind it would trip the firmware watchdog on a system that is working fine.
|
behind it would trip the firmware watchdog on a system that is working fine.
|
||||||
|
|
||||||
> **Known limit, `mqtt_p1` only.** The age measures *arrival*, not change. On the
|
> **Known limit, `mqtt_p1`.** The age measures *arrival*. On the MQTT path a
|
||||||
> `ha_dsmr` path that is exactly right: a frozen meter emits no `state_changed`,
|
> bridge that is stuck republishing its last telegram keeps arriving, so the age
|
||||||
> so nothing arrives and the age climbs. On the MQTT path a bridge that is stuck
|
> stays near zero and a frozen meter still looks fresh. Detecting *that* needs a
|
||||||
> republishing its last telegram keeps arriving, so the age stays near zero and a
|
> change-detector rather than an arrival-detector, and it is not in this version.
|
||||||
> frozen meter still looks fresh. Detecting *that* needs a change-detector rather
|
|
||||||
> than an arrival-detector, and it is not in this version. Prefer `ha_dsmr` where
|
> ⚠️ **Known limit, `ha_signed` — do not drive a watchdog off this age yet.**
|
||||||
> both are available.
|
> On the HA WebSocket paths the age is stamped when a `state_changed` arrives,
|
||||||
|
> which means it measures *time since the value last changed*, not time since
|
||||||
|
> the meter last reported. Home Assistant offers nothing better: a repeated
|
||||||
|
> reading produces no `state_changed`, does **not** advance `last_reported` on
|
||||||
|
> either the REST or the WebSocket serialiser, and `state_reported` cannot be
|
||||||
|
> subscribed to over the WebSocket at all (`Event filter is required for event
|
||||||
|
> state_reported`). All three measured on the ENV-01 rig against the real
|
||||||
|
> HomeWizard integration with the meter frozen: 0 `state_changed` in 70 s and no
|
||||||
|
> timestamp movement anywhere.
|
||||||
|
>
|
||||||
|
> `ha_dsmr` mostly escapes this because a DSMR telegram updates several entities
|
||||||
|
> and something in the set almost always moves. **`ha_signed` has exactly one
|
||||||
|
> entity, so a healthy meter under a flat load is indistinguishable from a dead
|
||||||
|
> one.** This is not hypothetical: in our own captures
|
||||||
|
> (`sim/scenarios/ha-p1_meter_active_power-2026-08-20.json`) the real house meter
|
||||||
|
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
|
||||||
|
> past the default `meter_max_age_s` of 30.
|
||||||
|
>
|
||||||
|
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
|
||||||
|
> correct whenever the value is moving, but it must not yet be thresholded by
|
||||||
|
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
|
||||||
|
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
|
||||||
|
> identical signal, so a bigger number only chooses which of the two errors you
|
||||||
|
> get. The real fix is an arrival stamp the meter itself provides — and that now
|
||||||
|
> exists: **`meter_source: homewizard_local`**. If you have a HomeWizard P1,
|
||||||
|
> switch to it. If your meter is only reachable through Home Assistant, this
|
||||||
|
> limit still applies to you and the watchdog threshold still must not be armed.
|
||||||
|
|
||||||
|
**Where the age is trustworthy:**
|
||||||
|
|
||||||
|
| mode | the age measures | safe to threshold from firmware |
|
||||||
|
|---|---|---|
|
||||||
|
| `homewizard_local` | time since the meter **answered** | **yes** — every HTTP response is an arrival |
|
||||||
|
| `ha_dsmr` | time since one of several entities changed | no — statistically usually fine, which is a masked bug, not an absent one |
|
||||||
|
| `ha_signed` | time since the one entity changed | **no** — see above |
|
||||||
|
| `mqtt_p1` | time since a message arrived | arrivals yes, but a stuck bridge republishing keeps arriving |
|
||||||
|
|
||||||
### Control
|
### Control
|
||||||
|
|
||||||
@@ -142,11 +275,69 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
|||||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||||
| `step_w` | 10 | Quantisation |
|
| `step_w` | 10 | Quantisation |
|
||||||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one *changed* meter reading, not a fixed period - see the note below. **Do not set to 1** |
|
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1** |
|
||||||
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||||||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||||||
|
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
|
||||||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
|
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
|
||||||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||||||
|
| `sign_disagree_w` | 300 | Both the command and the meter must exceed this before a sign difference counts. Must stay **below `max_w`** or the watchdog can never fire, and the add-on refuses to start |
|
||||||
|
| `sign_disagree_s` | 180 | How long they must stay opposed, continuously, before the failsafe trips |
|
||||||
|
|
||||||
|
#### The sign-disagreement failsafe
|
||||||
|
|
||||||
|
If the setpoint this add-on is emitting and the grid figure it is regulating on
|
||||||
|
point **opposite ways** — charging the battery while the house imports, or
|
||||||
|
discharging it while the house exports — with both over `sign_disagree_w`, for
|
||||||
|
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
|
||||||
|
there. The banner reads `FAILSAFE`, the commissioning list says which two
|
||||||
|
numbers disagreed and for how long, and the `Controller status` entity reports
|
||||||
|
`failsafe`.
|
||||||
|
|
||||||
|
**It does not clear itself, and restarting the add-on does not clear it either.**
|
||||||
|
Pressing *Start / stop control* twice — stop, then start — is the only thing that
|
||||||
|
does. That is deliberate on both counts:
|
||||||
|
|
||||||
|
- Commanding 0 W removes the command side of the comparison, so the disagreement
|
||||||
|
always vanishes the instant the trip works. A watchdog that took that as proof
|
||||||
|
the fault was over would hand the inverter straight back to the loop that was
|
||||||
|
driving the wrong way, three minutes later, forever.
|
||||||
|
- The trip is written to `/data/state.json` and restored on startup. Restarting
|
||||||
|
the add-on is the first thing most people try when they see a red banner, and
|
||||||
|
a latch that a restart clears would bring the controller back **armed, into the
|
||||||
|
same fault, unattended** — and under a Supervisor that restarts a crashed add-on
|
||||||
|
automatically, would do so in a loop. After a restart the banner, the 0 W and
|
||||||
|
the recorded reason are all still there.
|
||||||
|
|
||||||
|
**What it is for.** This is a loop-integrity check: it asks whether the loop's
|
||||||
|
output direction still matches the input state it was regulating. It catches a
|
||||||
|
polarity error anywhere between the meter and the wire, and an integrator so far
|
||||||
|
wound up that unwinding takes minutes. It is the check that would have caught all
|
||||||
|
four episodes of 2026-08-20, when a commercial controller charged the battery out
|
||||||
|
of the grid, four times, for up to 77 minutes at a time.
|
||||||
|
|
||||||
|
**What it is not for.** It does not detect an inverter that acts opposite to what
|
||||||
|
it was told — that moves the meter *further* the same way, so command and meter
|
||||||
|
agree while everything runs away. That is the saturation freeze's job
|
||||||
|
(`saturation_w` / `saturation_cycles`), which compares the command against the
|
||||||
|
battery's actual power. The two checks are complementary and neither replaces the
|
||||||
|
other.
|
||||||
|
|
||||||
|
**Why it will not false-trip on ordinary operation.** A battery that refuses a
|
||||||
|
command (empty, full, standby) leaves the meter where it was, so command and
|
||||||
|
meter agree. A frozen meter drives the loop into agreeing with it. Around the
|
||||||
|
resting point both signals are inside the 300 W gate, so the deadband can never
|
||||||
|
produce one. A load step does flip the meter's sign before the loop catches up —
|
||||||
|
measured on a full captured day, the longest such crossing is 30 s, against a
|
||||||
|
180 s window. And the watchdog is **disarmed whenever the grid-following loop is
|
||||||
|
not what is driving**: a maintenance grid charge opposes the meter by design and
|
||||||
|
must not be mistaken for a fault.
|
||||||
|
|
||||||
|
Both numbers are measured rather than chosen, and are exposed only because the
|
||||||
|
ticket requires defaults rather than constants. On the captured healthy day sign
|
||||||
|
agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
|
||||||
|
10.5 % and 0.0 %. There is no reason to move either value without a capture that
|
||||||
|
says so.
|
||||||
|
|
||||||
#### Saturation is counted in cycles, not seconds
|
#### Saturation is counted in cycles, not seconds
|
||||||
|
|
||||||
@@ -154,22 +345,43 @@ The specification states the saturation window as **"> 10 s"**. This add-on coun
|
|||||||
**cycles** instead, and that is a deliberate, accepted deviation rather than an
|
**cycles** instead, and that is a deliberate, accepted deviation rather than an
|
||||||
oversight - the acceptance criterion is not met as literally written.
|
oversight - the acceptance criterion is not met as literally written.
|
||||||
|
|
||||||
A cycle here is one *changed* meter reading: the controller only runs the loop when the
|
A cycle here is one meter update **or `cycle_floor_s` elapsing, whichever comes
|
||||||
meter value differs from the previous poll. At the reference P1's ~5 s update rate the
|
first**. At the reference P1's ~5 s update rate the default of 3 cycles is usually
|
||||||
default of 3 cycles is usually around 15 s, but there is **no guaranteed wall-clock
|
around 15 s, and the worst case is bounded at `3 x cycle_floor_s` = 45 s. It is still
|
||||||
window** - a meter that repeats the same value stalls the counter for as long as it
|
not the 10 s the specification asks for, so the deviation stands - but it is now a
|
||||||
repeats.
|
bounded number rather than an open one.
|
||||||
|
|
||||||
Two reasons that is acceptable:
|
⚠️ **This passage used to say something different, and the second half of it was
|
||||||
|
wrong.** Until SAFETY-12 the loop ran only when the meter value *changed*, so a meter
|
||||||
|
that repeated a value stalled the counter for as long as it repeated, with no upper
|
||||||
|
bound at all. That was defended here on the grounds that "a stalled counter is a
|
||||||
|
detection-latency limit and not a runaway risk - the condition that stalls it stops the
|
||||||
|
whole loop, so nothing accumulates while it is stalled". That argument covers
|
||||||
|
**integrator windup and nothing else**. The hazard it misses is **staleness**: while the
|
||||||
|
loop was stopped the heartbeat kept re-emitting the last command and the firmware kept
|
||||||
|
putting it on the wire every 2 s, so a command computed from an old reading went on
|
||||||
|
being executed through a real load change, with every indicator reading healthy.
|
||||||
|
Measured on the rig during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals,
|
||||||
|
zero control cycles.
|
||||||
|
|
||||||
- the control law is a pure function with no clock, which is what makes it testable
|
The half that survives is the first one: the control law is still a pure function with
|
||||||
without hardware, and a seconds-based window would have to live in the controller;
|
no clock, and the wall-clock window still lives in `Controller`, which has one - see
|
||||||
- a stalled counter is a detection-latency limit and not a runaway risk. The condition
|
`run_control()`. What changed is that the controller now enforces a floor on the gap
|
||||||
that stalls it - an unchanging meter - stops the whole loop, so nothing accumulates
|
between cycles, so `saturation_cycles` finally means a bounded number of seconds.
|
||||||
while it is stalled.
|
|
||||||
|
|
||||||
If a guaranteed window matters on your site, raise `saturation_cycles` for a fast meter,
|
If a tighter window matters on your site, lower `cycle_floor_s` (not below your meter's
|
||||||
and treat the figure as "N meter updates" rather than "N seconds".
|
update interval, or the floor starts firing on a healthy meter) or raise
|
||||||
|
`saturation_cycles` for a fast meter.
|
||||||
|
|
||||||
|
**This is not a freeze detector, and it deliberately cannot be one.** Nothing here
|
||||||
|
decides whether a repeated reading is true — see `unchanged_s` in the meter section for
|
||||||
|
why no threshold on that is safe at the converged operating point. The floor only stops
|
||||||
|
"the number is the same" from meaning "do not think". The consequence is worth stating
|
||||||
|
plainly: against a meter that is genuinely frozen at a wrong value, the loop will now
|
||||||
|
correct toward that wrong value instead of holding a stale command. It is bounded by
|
||||||
|
`max_w`, the arbiter and the device rating, and it is the same thing that already
|
||||||
|
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||||||
|
safety property.
|
||||||
|
|
||||||
#### Why `target_grid_w` is not zero
|
#### Why `target_grid_w` is not zero
|
||||||
|
|
||||||
@@ -202,8 +414,8 @@ help - see "Why the tuning is what it is".
|
|||||||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||||||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||||
| `maintenance_discharge_w` | 2500 | Drain rate (exports the surplus) |
|
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||||
| `maintenance_charge_w` | 2500 | Charge ceiling, capped again by peak headroom |
|
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor |
|
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor |
|
||||||
| `maintenance_soc_target` | 99 | Charge target |
|
| `maintenance_soc_target` | 99 | Charge target |
|
||||||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
||||||
|
|||||||
@@ -95,19 +95,23 @@ def compute(
|
|||||||
# lets slew be larger than saturation_w.
|
# lets slew be larger than saturation_w.
|
||||||
#
|
#
|
||||||
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
||||||
# "3 samples" (§10.3). This counts CYCLES, and a cycle is not a unit of
|
# "3 samples" (§10.3). This counts CYCLES, and a cycle is still not a unit
|
||||||
# time: run_control() calls cycle() only when the meter value CHANGES
|
# of time - but it is now a BOUNDED one. run_control() runs a cycle on a
|
||||||
# (`if self.grid != last_grid`), so three cycles is three distinct meter
|
# changed meter value OR on `cycle_floor_s` elapsing, whichever comes first
|
||||||
# readings and nothing more. At the reference P1's ~5 s update rate that is
|
# (SAFETY-12), so three cycles is at most 3 x cycle_floor_s = 45 s at the
|
||||||
# usually ~15 s, but there is no upper bound on it - a meter that repeats a
|
# defaults, and usually ~15 s at the reference P1's ~5 s update rate.
|
||||||
# value stalls the counter.
|
|
||||||
#
|
#
|
||||||
# That is a detection-latency limit, not a windup hazard: the same
|
# ⚠️ THIS COMMENT USED TO CLAIM there was no upper bound and that it did not
|
||||||
# condition that stalls the counter stalls the whole loop, so nothing
|
# matter, "because the same condition that stalls the counter stalls the
|
||||||
# accumulates in the meantime either. If a wall-clock window is ever
|
# whole loop, so nothing accumulates in the meantime". The first clause was
|
||||||
# required, it belongs in Controller (which has a clock) and not here.
|
# true and the second was the bug: a stalled loop does not stop commanding,
|
||||||
# ponytail: this function is worth keeping clockless; the ceiling is that
|
# it goes on emitting its last command through the heartbeat. Windup was
|
||||||
# saturation_cycles cannot express a guaranteed number of seconds.
|
# never the hazard; staleness was.
|
||||||
|
#
|
||||||
|
# The wall-clock window lives in Controller, which has a clock, exactly as
|
||||||
|
# this comment always said it should. ponytail: this function stays
|
||||||
|
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
||||||
|
# bound in seconds rather than an exact number of them.
|
||||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||||
frozen = sat_count >= tuning.saturation_cycles
|
frozen = sat_count >= tuning.saturation_cycles
|
||||||
|
|||||||
+290
-14
@@ -40,6 +40,8 @@ from .hass import HomeAssistant
|
|||||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||||
from .mqtt import MqttPublisher
|
from .mqtt import MqttPublisher
|
||||||
from .p1 import P1Ingest, build_source, is_enabled
|
from .p1 import P1Ingest, build_source, is_enabled
|
||||||
|
from .validate import ConfigRefused, refuse, validate_startup_config
|
||||||
|
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
||||||
from . import web
|
from . import web
|
||||||
|
|
||||||
OPTIONS_PATH = "/data/options.json"
|
OPTIONS_PATH = "/data/options.json"
|
||||||
@@ -56,11 +58,22 @@ def load_options() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class Controller:
|
class Controller:
|
||||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub):
|
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop):
|
||||||
self.o = opts
|
self.o = opts
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.store = store
|
self.store = store
|
||||||
self.mqtt = mqtt_pub
|
self.mqtt = mqtt_pub
|
||||||
|
# ⚠️ REQUIRED, AND KEYWORD-ONLY, AFTER A REVIEW CATCH. This started life
|
||||||
|
# as `stop=None`, which made a Controller that CANNOT STOP ITSELF the
|
||||||
|
# default one. On the late-refusal path in run_control() that default
|
||||||
|
# meant: log the banner, return, and leave the process alive forever
|
||||||
|
# with no control loop, no exit and no further output - the exact
|
||||||
|
# "inert but looks healthy" failure this whole module exists to refuse.
|
||||||
|
# amain() always passed a real Event, so it was latent, but test_p1.py
|
||||||
|
# was already constructing Controllers without one. No default now, so
|
||||||
|
# the broken shape does not typecheck.
|
||||||
|
self.stop_event = stop
|
||||||
|
self.refused: list[str] | None = None
|
||||||
|
|
||||||
self.tuning = Tuning(
|
self.tuning = Tuning(
|
||||||
gain=float(opts.get("gain", 0.6)),
|
gain=float(opts.get("gain", 0.6)),
|
||||||
@@ -91,12 +104,39 @@ class Controller:
|
|||||||
store,
|
store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SAFETY-02. One instance, one actuator, one cadence - see watchdog.py.
|
||||||
|
#
|
||||||
|
# ⚠️ RESTORED FROM THE STORE, and that is the difference between a latch
|
||||||
|
# and a pause. A trip held only in memory is cleared by the one action an
|
||||||
|
# operator staring at a FAILSAFE banner is most likely to take - restart
|
||||||
|
# the add-on - and the controller then comes back armed, into the fault,
|
||||||
|
# with nobody involved. Restored here, the banner and the 0 W survive the
|
||||||
|
# restart and only `auto_toggle` clears them.
|
||||||
|
self.sign_wd = SignWatchdog(
|
||||||
|
SignWatchdogConfig(
|
||||||
|
min_w=float(opts.get("sign_disagree_w", 300)),
|
||||||
|
hold_s=float(opts.get("sign_disagree_s", 180)),
|
||||||
|
),
|
||||||
|
tripped=bool(store.data.get("sign_tripped", False)),
|
||||||
|
reason=str(store.data.get("sign_reason", "") or ""),
|
||||||
|
)
|
||||||
|
if self.sign_wd.tripped:
|
||||||
|
_LOG.error("SAFETY-02 was TRIPPED before this restart and stays "
|
||||||
|
"tripped: %s. The inverter is held at 0 W. Restarting the "
|
||||||
|
"add-on does not clear this - start control from the UI "
|
||||||
|
"once you know why the command opposed the meter.",
|
||||||
|
self.sign_wd.reason or "(reason not recorded)")
|
||||||
|
|
||||||
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
||||||
# single-entity meter_entity path, so an existing install is unchanged
|
# single-entity meter_entity path, so an existing install is unchanged
|
||||||
# until it opts in.
|
# until it opts in.
|
||||||
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
||||||
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
||||||
self.p1_enabled = is_enabled(opts)
|
self.p1_enabled = is_enabled(opts)
|
||||||
|
# Set by amain() once the transport is built, so the status page can show
|
||||||
|
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||||
|
# None when P1 is off, or under a transport that has no such counter.
|
||||||
|
self.p1_source = None
|
||||||
|
|
||||||
# live state
|
# live state
|
||||||
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
|
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
|
||||||
@@ -104,6 +144,11 @@ class Controller:
|
|||||||
self.sat_count = 0
|
self.sat_count = 0
|
||||||
self.i_w = 0.0 # the loop's integrator, carried between cycles
|
self.i_w = 0.0 # the loop's integrator, carried between cycles
|
||||||
self.loop_w = None # what the loop asked for last cycle, or None
|
self.loop_w = None # what the loop asked for last cycle, or None
|
||||||
|
# Did the grid-following loop actually win the LAST arbitration? That is
|
||||||
|
# the question SAFETY-02 has to ask about the command standing on the
|
||||||
|
# wire right now, so it is recorded when the arbitration happens rather
|
||||||
|
# than re-derived a cycle later. Starts False: nothing is on the wire.
|
||||||
|
self.following = False
|
||||||
self.reason = "starting"
|
self.reason = "starting"
|
||||||
self.grid = self.soc = self.batt = None
|
self.grid = self.soc = self.batt = None
|
||||||
self.peak_fc = None
|
self.peak_fc = None
|
||||||
@@ -198,8 +243,38 @@ class Controller:
|
|||||||
for msg in result.events:
|
for msg in result.events:
|
||||||
self.log_event(msg)
|
self.log_event(msg)
|
||||||
|
|
||||||
|
# --- SAFETY-02, and it runs BEFORE the early return below -------------
|
||||||
|
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
|
||||||
|
# but not yet stale" hold, a run of held cycles would leave
|
||||||
|
# `disagree_since` untouched across the gap and the timer would resume
|
||||||
|
# from a start time that predates it. It is evaluated every cycle,
|
||||||
|
# unconditionally, so a missing meter CLEARS the window - see watchdog.py.
|
||||||
|
#
|
||||||
|
# The value compared is the command STANDING ON THE WIRE, which is what
|
||||||
|
# this cycle's fresh meter reading is a response to. `last_write` is that
|
||||||
|
# value after the device clamp and confirmed accepted; `target` covers
|
||||||
|
# the case where nothing has been written yet (and, deliberately, the
|
||||||
|
# case where the last write was REJECTED - the inverter is then still
|
||||||
|
# latched on `last_write`, which is exactly what `last_write` holds).
|
||||||
|
was_tripped = self.sign_wd.tripped
|
||||||
|
standing = self.last_write if self.last_write is not None else self.target
|
||||||
|
if self.sign_wd.evaluate(standing, self.grid, self.following, now) \
|
||||||
|
and not was_tripped:
|
||||||
|
# ⚠️ REASON FIRST, FLAG SECOND, and each `set` writes the file. The
|
||||||
|
# ordering is the whole atomicity story: a crash between the two
|
||||||
|
# persists a reason nothing reads, which is harmless, where the other
|
||||||
|
# order persists a latch with no explanation for the operator who has
|
||||||
|
# to decide whether to clear it.
|
||||||
|
self.store.set("sign_reason", self.sign_wd.reason)
|
||||||
|
self.store.set("sign_tripped", True)
|
||||||
|
self.log_event("FAILSAFE " + self.sign_wd.reason)
|
||||||
|
_LOG.error("SAFETY-02 TRIPPED - %s. Commanding 0 W and holding it "
|
||||||
|
"until control is started again. This survives an add-on "
|
||||||
|
"restart; restarting will not clear it.",
|
||||||
|
self.sign_wd.reason)
|
||||||
|
|
||||||
stale_after = float(self.o.get("stale_input_s", 15))
|
stale_after = float(self.o.get("stale_input_s", 15))
|
||||||
if not self.inputs_ok:
|
if not self.inputs_ok and not self.sign_wd.tripped:
|
||||||
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
|
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
|
||||||
if bad_for < stale_after:
|
if bad_for < stale_after:
|
||||||
# A single missed poll is not a fault. Hold, keep the heartbeat
|
# A single missed poll is not a fault. Hold, keep the heartbeat
|
||||||
@@ -219,6 +294,16 @@ class Controller:
|
|||||||
claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"))
|
claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"))
|
||||||
|
|
||||||
# --- safety stops ----------------------------------------------------
|
# --- safety stops ----------------------------------------------------
|
||||||
|
# ⚠️ SAFETY-02 is claimed FIRST and unconditionally while latched. It is
|
||||||
|
# a SET at P_SAFETY, so it outranks maintenance, peak shaving and the
|
||||||
|
# loop alike - a controller pushing power the wrong way is wrong
|
||||||
|
# whatever it thinks it is doing. Re-filed EVERY cycle: claims are not
|
||||||
|
# sticky, and one filed once would lapse the next cycle and hand the
|
||||||
|
# actuator silently back to the loop that was driving the wrong way.
|
||||||
|
if self.sign_wd.tripped:
|
||||||
|
claims.append(Claim.set("failsafe", P_SAFETY, 0.0,
|
||||||
|
"sign disagreement"))
|
||||||
|
|
||||||
if not self.auto:
|
if not self.auto:
|
||||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped"))
|
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped"))
|
||||||
elif not self.inputs_ok:
|
elif not self.inputs_ok:
|
||||||
@@ -290,6 +375,20 @@ class Controller:
|
|||||||
resolution = resolve(claims)
|
resolution = resolve(claims)
|
||||||
if resolution.contradiction:
|
if resolution.contradiction:
|
||||||
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
|
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
|
||||||
|
# --- SAFETY-02's arming gate ------------------------------------------
|
||||||
|
# Was the number about to go on the wire chosen by the grid-following
|
||||||
|
# loop, or by a strategy that commands against the meter on purpose?
|
||||||
|
# Only the former is expected to agree in sign with P1 - without this,
|
||||||
|
# every maintenance grid charge trips the failsafe within three minutes.
|
||||||
|
#
|
||||||
|
# ⚠️ THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not
|
||||||
|
# tidiness. It was inline, test_watchdog.py held a transcription of it,
|
||||||
|
# and the P_PEAK case meant to force whoever lands peak shaving to come
|
||||||
|
# and change an expectation did not: editing this line alone to
|
||||||
|
# `winner in ("loop", "peak")` left every suite green while the watchdog
|
||||||
|
# went blind for every peak event. Read loop_is_following() before
|
||||||
|
# touching the gate - the forward hazard and the reasoning are there.
|
||||||
|
self.following = loop_is_following(resolution, self.loop_w)
|
||||||
self.target = resolution.target_w
|
self.target = resolution.target_w
|
||||||
self.reason = resolution.explain()
|
self.reason = resolution.explain()
|
||||||
await self.write_setpoint(self.target)
|
await self.write_setpoint(self.target)
|
||||||
@@ -299,19 +398,112 @@ class Controller:
|
|||||||
entity = self.o.get("setpoint_entity", "")
|
entity = self.o.get("setpoint_entity", "")
|
||||||
if entity:
|
if entity:
|
||||||
self.dev_min, self.dev_max, _ = await self.hass.limits(entity)
|
self.dev_min, self.dev_max, _ = await self.hass.limits(entity)
|
||||||
if self.dev_max is not None and self.tuning.max_w > self.dev_max:
|
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
|
||||||
self.log_event(
|
# exists to remove: a max_w above the device's rating meant
|
||||||
f"clamp {self.tuning.max_w:.0f} W exceeds the device maximum "
|
# write_setpoint() silently clamped and the configured ceiling was
|
||||||
f"{self.dev_max:.0f} W - the device wins")
|
# never the real one.
|
||||||
|
#
|
||||||
|
# amain() already ran this gate before anything started. It runs
|
||||||
|
# AGAIN here for one case the gate cannot cover: ESPHome is often
|
||||||
|
# not up yet when the add-on boots, so hass.limits() returned
|
||||||
|
# (None, None) and the range checks were skipped rather than
|
||||||
|
# guessed at. This is the first moment the device's real rating is
|
||||||
|
# known. If it conflicts, refuse now - stopping deterministically
|
||||||
|
# through the normal shutdown, which commands 0 W on the way out.
|
||||||
|
problems = validate_startup_config(self.o, self.dev_min, self.dev_max)
|
||||||
|
if problems:
|
||||||
|
refuse(problems)
|
||||||
|
self.refused = problems
|
||||||
|
self.log_event("REFUSED: " + problems[0])
|
||||||
|
# Signal, then return WITHOUT entering the loop. amain() wakes
|
||||||
|
# on this, runs the normal wind-down (which commands 0 W) and
|
||||||
|
# re-raises ConfigRefused afterwards, so the exit code is 78 and
|
||||||
|
# the inverter is left at zero rather than latched.
|
||||||
|
self.stop_event.set()
|
||||||
|
return
|
||||||
|
|
||||||
last_grid = object()
|
last_grid = object()
|
||||||
heartbeat = float(self.o.get("heartbeat_s", 10))
|
heartbeat = float(self.o.get("heartbeat_s", 10))
|
||||||
last_beat = 0.0
|
last_beat = 0.0
|
||||||
|
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not a
|
||||||
|
# second scheduler running alongside the arrival trigger. Every cycle
|
||||||
|
# resets it, whichever trigger fired, so on a healthy changing meter it
|
||||||
|
# never elapses and not one existing cycle moves - and therefore no
|
||||||
|
# change to the per-cycle gain the tuning was measured against.
|
||||||
|
#
|
||||||
|
# That is true twice over, and the second reason is the sturdier one:
|
||||||
|
# the combined cycle rate is max(arrival rate, 1/floor), never the sum.
|
||||||
|
# At the defaults 1/15 s is strictly slower than the ~1/5 s arrival
|
||||||
|
# rate, so even a floor that raced the arrivals could not push the loop
|
||||||
|
# past its nominal healthy rate. The re-tune is unreachable by
|
||||||
|
# construction, not merely by the reset. ⚠️ That argument holds only
|
||||||
|
# while floor > the meter's update interval - which is why the schema
|
||||||
|
# minimum of 5 s deserves the second look validate.py has not given it
|
||||||
|
# yet: 5 s IS the reference meter's interval, i.e. the one configurable
|
||||||
|
# value that would double the cycle rate.
|
||||||
|
#
|
||||||
|
# Why it has to exist, and it is not only about latency: `self.grid !=
|
||||||
|
# last_grid` alone means a meter that repeats a value runs NO control
|
||||||
|
# cycles at all. The heartbeat below keeps re-emitting the last command
|
||||||
|
# and the firmware keeps putting it on the wire, so every indicator
|
||||||
|
# reads healthy - and, worse, the `inputs missing -> 0 W` claim lives
|
||||||
|
# INSIDE cycle(), so on a repeating meter THAT SAFETY CLAIM CANNOT FIRE.
|
||||||
|
# Demonstrated in test_loop.py: constant meter, SoC entity dies at t=4 s,
|
||||||
|
# and the pre-fix controller holds 220 W through the whole outage with
|
||||||
|
# the status line still reading "loop -> 220 W". Layer 1 does not cover
|
||||||
|
# it on a `meter_source: off` install either - no P1 age sensor is
|
||||||
|
# published, so the firmware has nothing to veto on. Measured on the rig
|
||||||
|
# during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals, zero
|
||||||
|
# cycles.
|
||||||
|
#
|
||||||
|
# This is NOT a freeze detector, and deliberately so - see
|
||||||
|
# HomeWizardLocalSource.unchanged_s for why one cannot be built here (at
|
||||||
|
# the converged -10 W target a 1 Wh register needs ~6 minutes to move,
|
||||||
|
# so any freeze threshold false-trips at the exact operating point we
|
||||||
|
# run at). Nothing here decides whether a repeated reading is true. It
|
||||||
|
# only stops "the number is the same" from meaning "do not think".
|
||||||
|
#
|
||||||
|
# ⚠️ THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. It is under
|
||||||
|
# validate.FIRMWARE_INGEST_VETO_S (30 s) and under meter_max_age_s on
|
||||||
|
# purpose, so the loop re-decides at least twice inside any window in
|
||||||
|
# which layer 1 might otherwise be the only thing deciding. If the
|
||||||
|
# firmware's veto ever moves, this default moves with it - it is not
|
||||||
|
# independently justified at 15.
|
||||||
|
floor = float(self.o.get("cycle_floor_s", 15))
|
||||||
|
last_cycle = None
|
||||||
while not self.stopping:
|
while not self.stopping:
|
||||||
await self.read_inputs()
|
await self.read_inputs()
|
||||||
# A cycle is one meter update, exactly as on the reference install.
|
now_mono = asyncio.get_running_loop().time()
|
||||||
if self.grid != last_grid:
|
# A cycle is one meter update, exactly as on the reference install -
|
||||||
|
# or the floor elapsing, whichever comes first.
|
||||||
|
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
|
||||||
|
if self.grid != last_grid or overdue:
|
||||||
last_grid = self.grid
|
last_grid = self.grid
|
||||||
|
last_cycle = now_mono
|
||||||
|
# ⚠️ The FULL cycle, not a degraded one. A floor cycle runs the
|
||||||
|
# same law on the same inputs as an arrival cycle, because in P1
|
||||||
|
# mode the freshness question is already answered upstream and
|
||||||
|
# answered harder: past `meter_max_age_s` (30 s) p1.net_w is
|
||||||
|
# None, which is "inputs missing", which becomes a P_SAFETY 0 W
|
||||||
|
# claim. So a floor cycle cannot run the PI on a 60 s old
|
||||||
|
# figure, because at 60 s there is no figure.
|
||||||
|
#
|
||||||
|
# ⚠️ THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so
|
||||||
|
# matters more than the guarantee: with `meter_source: off`,
|
||||||
|
# read_inputs() takes self.grid straight from hass.number() with
|
||||||
|
# NO age gate at all, so a Home Assistant entity that has gone
|
||||||
|
# quiet reads as a current value on that path with or without
|
||||||
|
# this change. Running the full law is still the right choice
|
||||||
|
# there - the alternative is a degraded second law reasoning
|
||||||
|
# about an age nobody measured - but the floor does not make
|
||||||
|
# that path fresh, and nothing here should be read as claiming
|
||||||
|
# it does. And the integrator
|
||||||
|
# advances per CYCLE, not per second (control.py), so bounding
|
||||||
|
# the gap bounds the accumulation: at most one extra
|
||||||
|
# gain x error per floor interval, under the same
|
||||||
|
# integrator_max_w as every other cycle. A second, gentler
|
||||||
|
# control law for floor cycles would be a second set of
|
||||||
|
# dynamics nobody can test on this hardware.
|
||||||
await self.cycle()
|
await self.cycle()
|
||||||
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
|
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
|
||||||
# longer than ~30 s as "the controller is gone" and zeroes the
|
# longer than ~30 s as "the controller is gone" and zeroes the
|
||||||
@@ -330,10 +522,18 @@ class Controller:
|
|||||||
"battery": self.batt,
|
"battery": self.batt,
|
||||||
"soc": self.soc,
|
"soc": self.soc,
|
||||||
"phase": self.maint.phase,
|
"phase": self.maint.phase,
|
||||||
"status": "running" if self.auto else "stopped",
|
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
|
||||||
|
# one. IF-01/IF-02 own `ems/event/failsafe` and the `failsafe active`
|
||||||
|
# binary sensor and neither is built yet; inventing a bespoke topic
|
||||||
|
# here would be a surface to migrate later. This sensor is already
|
||||||
|
# discovered, already on every dashboard, and "failsafe" is
|
||||||
|
# additive to its vocabulary rather than a change to it.
|
||||||
|
"status": ("failsafe" if self.sign_wd.tripped
|
||||||
|
else "running" if self.auto else "stopped"),
|
||||||
}
|
}
|
||||||
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
||||||
# watchdog subscribes to sensor.p1_sample_age_s and forces the layer-1
|
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
|
||||||
|
# note in mqtt.py; the composed name is not the object_id - and forces layer-1
|
||||||
# failsafe once it reaches max_age_s. With meter_source off there is no
|
# failsafe once it reaches max_age_s. With meter_source off there is no
|
||||||
# ingester feeding it, so published_age_s would be time-since-startup
|
# ingester feeding it, so published_age_s would be time-since-startup
|
||||||
# climbing without bound - i.e. every existing install would cross the
|
# climbing without bound - i.e. every existing install would cross the
|
||||||
@@ -365,10 +565,22 @@ class Controller:
|
|||||||
+ (f" - last error: {self.p1.last_error}"
|
+ (f" - last error: {self.p1.last_error}"
|
||||||
if self.p1.last_error else "")})
|
if self.p1.last_error else "")})
|
||||||
else:
|
else:
|
||||||
|
# ⚠️ unchanged_s is REPORTED, never thresholded and never folded
|
||||||
|
# into the age - see HomeWizardLocalSource.unchanged_s for why
|
||||||
|
# (at the converged -10 W this controller aims for, a 1 Wh
|
||||||
|
# register needs ~6 minutes to move, so any limit false-trips at
|
||||||
|
# the exact operating point we target). The whole argument for
|
||||||
|
# leaving it unthresholded is that a human interprets it, which
|
||||||
|
# requires a human being able to see it - so here it is. getattr:
|
||||||
|
# only homewizard_local has one, and p1_source is None until
|
||||||
|
# amain() builds the transport.
|
||||||
|
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
||||||
out.append({"ok": True, "warn": False,
|
out.append({"ok": True, "warn": False,
|
||||||
"text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, "
|
"text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, "
|
||||||
f"{age:.0f} s old, {self.p1.samples} telegrams, "
|
f"{age:.0f} s old, {self.p1.samples} telegrams, "
|
||||||
f"{self.p1.parse_errors} rejected"})
|
f"{self.p1.parse_errors} rejected"
|
||||||
|
+ (f", measurement unchanged for {unchanged:.0f} s"
|
||||||
|
if unchanged is not None else "")})
|
||||||
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
||||||
("battery power", self.batt, o.get("batt_entity"))]
|
("battery power", self.batt, o.get("batt_entity"))]
|
||||||
if not self.p1_enabled:
|
if not self.p1_enabled:
|
||||||
@@ -399,6 +611,23 @@ class Controller:
|
|||||||
out.append({"ok": False, "warn": False,
|
out.append({"ok": False, "warn": False,
|
||||||
"text": "last write to the inverter was REJECTED - check the log"})
|
"text": "last write to the inverter was REJECTED - check the log"})
|
||||||
|
|
||||||
|
# SAFETY-02. Reported in both states: a watchdog nobody can see is armed
|
||||||
|
# is a watchdog nobody trusts when it fires.
|
||||||
|
if self.sign_wd.tripped:
|
||||||
|
out.append({"ok": False, "warn": False,
|
||||||
|
"text": "SIGN-DISAGREEMENT FAILSAFE - " + self.sign_wd.reason
|
||||||
|
+ ". The inverter is held at 0 W. Find out why the "
|
||||||
|
"command opposed the meter before starting control "
|
||||||
|
"again; starting clears this. Restarting the "
|
||||||
|
"add-on does NOT clear it."})
|
||||||
|
else:
|
||||||
|
held = self.sign_wd.held_s(datetime.now(timezone.utc))
|
||||||
|
out.append({"ok": True, "warn": False,
|
||||||
|
"text": f"sign watchdog: armed (trips after "
|
||||||
|
f"{self.sign_wd.cfg.hold_s:.0f} s opposed, both over "
|
||||||
|
f"{self.sign_wd.cfg.min_w:.0f} W)"
|
||||||
|
+ (f" - opposed for {held:.0f} s now" if held else "")})
|
||||||
|
|
||||||
if not o.get("estop_fitted", False):
|
if not o.get("estop_fitted", False):
|
||||||
out.append({"ok": False, "warn": True,
|
out.append({"ok": False, "warn": True,
|
||||||
"text": "no e-stop fitted: if this host dies the battery stays "
|
"text": "no e-stop fitted: if this host dies the battery stays "
|
||||||
@@ -416,7 +645,11 @@ class Controller:
|
|||||||
def status_payload(self) -> dict:
|
def status_payload(self) -> dict:
|
||||||
checks = self.checks()
|
checks = self.checks()
|
||||||
bad = [c for c in checks if not c["ok"] and not c["warn"]]
|
bad = [c for c in checks if not c["ok"] and not c["warn"]]
|
||||||
if bad:
|
if self.sign_wd.tripped:
|
||||||
|
# Outranks "NOT READY": nothing is missing, something went wrong.
|
||||||
|
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
|
||||||
|
"Inverter held at 0 W.")
|
||||||
|
elif bad:
|
||||||
level, banner = "bad", f"NOT READY — {bad[0]['text']}"
|
level, banner = "bad", f"NOT READY — {bad[0]['text']}"
|
||||||
elif not self.auto:
|
elif not self.auto:
|
||||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||||
@@ -450,6 +683,17 @@ class Controller:
|
|||||||
self.auto = not self.auto
|
self.auto = not self.auto
|
||||||
self.store.set("auto", self.auto)
|
self.store.set("auto", self.auto)
|
||||||
self.log_event("control started" if self.auto else "control stopped")
|
self.log_event("control started" if self.auto else "control stopped")
|
||||||
|
if self.auto and self.sign_wd.tripped:
|
||||||
|
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
|
||||||
|
# It clears on a deliberate human start, not on the condition
|
||||||
|
# going away - the condition ALWAYS goes away, because the trip
|
||||||
|
# commands 0 W and a 0 W command cannot disagree with anything.
|
||||||
|
# An operator pressing start has, at minimum, looked at the
|
||||||
|
# banner that says why.
|
||||||
|
self.sign_wd.reset()
|
||||||
|
self.store.set("sign_tripped", False)
|
||||||
|
self.store.set("sign_reason", "")
|
||||||
|
self.log_event("sign-disagreement failsafe cleared by operator")
|
||||||
if not self.auto:
|
if not self.auto:
|
||||||
self.target = 0.0
|
self.target = 0.0
|
||||||
await self.write_setpoint(0.0, force=True)
|
await self.write_setpoint(0.0, force=True)
|
||||||
@@ -498,6 +742,26 @@ async def amain() -> None:
|
|||||||
except Exception as _e: # noqa: BLE001
|
except Exception as _e: # noqa: BLE001
|
||||||
_LOG.error("core api probe failed: %s", _e)
|
_LOG.error("core api probe failed: %s", _e)
|
||||||
|
|
||||||
|
# ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
|
||||||
|
# configuration validate.py rejects - specs §1.5: a config the system
|
||||||
|
# cannot honour is refused at startup, not survived at runtime.
|
||||||
|
#
|
||||||
|
# It sits HERE, before the broker, before the web server and before any
|
||||||
|
# task exists, so a refusal cannot leave half a controller running with
|
||||||
|
# a UI in front of it. Reading the device range is the one thing the
|
||||||
|
# validator cannot do for itself; (None, None) when ESPHome has not come
|
||||||
|
# up yet is not a refusal - run_control() re-checks once the real rating
|
||||||
|
# is known. See validate.py for where the line is drawn and why.
|
||||||
|
_sp = str(opts.get("setpoint_entity", "") or "").strip()
|
||||||
|
_dev_min = _dev_max = None
|
||||||
|
if _sp:
|
||||||
|
_dev_min, _dev_max, _ = await hass.limits(_sp)
|
||||||
|
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
|
||||||
|
_problems = validate_startup_config(opts, _dev_min, _dev_max)
|
||||||
|
if _problems:
|
||||||
|
refuse(_problems)
|
||||||
|
raise ConfigRefused(_problems)
|
||||||
|
|
||||||
# ⚠️ Status publishing must NEVER be able to stop the controller. A
|
# ⚠️ Status publishing must NEVER be able to stop the controller. A
|
||||||
# broken broker, a missing library, an API change in paho - all of it is
|
# broken broker, a missing library, an API change in paho - all of it is
|
||||||
# observability, and the battery does not care. Caught broadly and on
|
# observability, and the battery does not care. Caught broadly and on
|
||||||
@@ -523,10 +787,10 @@ async def amain() -> None:
|
|||||||
|
|
||||||
pub = _NoMqtt()
|
pub = _NoMqtt()
|
||||||
|
|
||||||
controller = Controller(opts, hass, store, pub)
|
stop = asyncio.Event()
|
||||||
|
controller = Controller(opts, hass, store, pub, stop=stop)
|
||||||
runner = await web.start(controller, port=8099)
|
runner = await web.start(controller, port=8099)
|
||||||
|
|
||||||
stop = asyncio.Event()
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
with contextlib.suppress(NotImplementedError):
|
with contextlib.suppress(NotImplementedError):
|
||||||
@@ -540,6 +804,7 @@ async def amain() -> None:
|
|||||||
# unit - would be computed from a fraction of the data.
|
# unit - would be computed from a fraction of the data.
|
||||||
p1_source = build_source(opts, controller.p1, session, broker)
|
p1_source = build_source(opts, controller.p1, session, broker)
|
||||||
if p1_source is not None:
|
if p1_source is not None:
|
||||||
|
controller.p1_source = p1_source # so checks() can report on it
|
||||||
tasks.append(asyncio.create_task(p1_source.run()))
|
tasks.append(asyncio.create_task(p1_source.run()))
|
||||||
|
|
||||||
await stop.wait()
|
await stop.wait()
|
||||||
@@ -552,6 +817,10 @@ async def amain() -> None:
|
|||||||
await task
|
await task
|
||||||
await runner.cleanup()
|
await runner.cleanup()
|
||||||
_LOG.info("stopped")
|
_LOG.info("stopped")
|
||||||
|
# The late-arriving-device refusal from run_control(). Re-raised only
|
||||||
|
# now, so the wind-down above still ran and the inverter is at 0 W.
|
||||||
|
if controller.refused:
|
||||||
|
raise ConfigRefused(controller.refused)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -559,6 +828,13 @@ def main() -> None:
|
|||||||
asyncio.run(amain())
|
asyncio.run(amain())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
except ConfigRefused:
|
||||||
|
# ⚠️ No traceback, on purpose. The banner refuse() already printed says
|
||||||
|
# everything an operator needs, and a stack trace on top of it would say
|
||||||
|
# "this crashed" - the exact confusion AC3 is about. 78 is sysexits(3)
|
||||||
|
# EX_CONFIG, so even a script reading the exit status can tell a bad
|
||||||
|
# config from a bug.
|
||||||
|
raise SystemExit(78)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ _LOG = logging.getLogger("goodwe.maint")
|
|||||||
|
|
||||||
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
|
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
|
||||||
|
|
||||||
# A drain of a 13.5 kWh usable pack at 2500 W is ~5 h even with no house load
|
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
|
||||||
# helping. 12 h means the battery is not following commands - a fault, not a
|
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
|
||||||
# slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
# house load drains alongside the export and soc_floor stops short of empty.
|
||||||
|
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||||
# it is expected to span a night; 36 h still bounds it.
|
# it is expected to span a night; 36 h still bounds it.
|
||||||
DRAIN_TIMEOUT_MIN = 720
|
DRAIN_TIMEOUT_MIN = 720
|
||||||
CHARGE_TIMEOUT_MIN = 2160
|
CHARGE_TIMEOUT_MIN = 2160
|
||||||
|
|||||||
@@ -45,13 +45,32 @@ SENSORS = [
|
|||||||
("soc", "goodwe_battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
("soc", "goodwe_battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
||||||
("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"),
|
("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"),
|
||||||
("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"),
|
("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"),
|
||||||
# ⚠️ This one deliberately breaks the goodwe_ prefix above: the entity id
|
# ⚠️ THE LIVE ENTITY IS `sensor.goodwe_p1_sample_age`, NOT what this
|
||||||
# must be exactly `sensor.p1_sample_age_s`, because SAFETY-01's firmware
|
# object_id spells. An earlier version of this comment said the id "must be
|
||||||
# watchdog subscribes to that literal id and the ENV-01 simulation rig
|
# exactly sensor.p1_sample_age_s" - that name has never existed on any HA,
|
||||||
# asserts on it. Renaming it silently disarms a safety layer. It is seconds
|
# and believing it is how a safety layer gets disarmed by someone tidying up.
|
||||||
# since the newest accepted P1 telegram, republished every second so that a
|
#
|
||||||
# meter frozen at a constant value still shows a climbing age - which is the
|
# On HA 2026.8 `object_id` in a discovery payload does NOT determine the
|
||||||
# false-trip that this entity exists to remove.
|
# entity_id. HA composes it from the device name plus the entity name:
|
||||||
|
# "GoodWe RS485 Controller" + "P1 sample age" -> sensor.goodwe_p1_sample_age.
|
||||||
|
# Verified against the live system 2026-08-26; the firmware subscribes to
|
||||||
|
# that composed name (goodwe-master-stage4-failsafe.yaml, homeassistant
|
||||||
|
# sensor p1_age_s), and its own comment records that sensor.p1_sample_age_s
|
||||||
|
# never existed.
|
||||||
|
#
|
||||||
|
# So: do NOT "fix" the firmware to match the object_id below, and do not
|
||||||
|
# rename the device or this entity's friendly name - either changes the
|
||||||
|
# composed id and silently disarms the ingest guard, because has_state()
|
||||||
|
# simply stays false forever and nothing reports it. The firmware calls this
|
||||||
|
# out as the failure that "FAILS SILENTLY AND LOOKS CORRECT".
|
||||||
|
#
|
||||||
|
# The value: seconds since the newest accepted P1 telegram, republished
|
||||||
|
# every second so a meter frozen at a constant value still shows a climbing
|
||||||
|
# age - the false-trip this entity exists to remove.
|
||||||
|
#
|
||||||
|
# ⚠️ Separately, and not fixed here: the firmware's ingest guard tests this
|
||||||
|
# sensor's VALUE rather than its arrival, so a dead publisher freezes the
|
||||||
|
# number and reads permanently fresh. Filed as TEL-06.
|
||||||
("p1_age", "p1_sample_age_s", "P1 sample age", "s", "duration", "measurement", None),
|
("p1_age", "p1_sample_age_s", "P1 sample age", "s", "duration", "measurement", None),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+503
-7
@@ -4,13 +4,22 @@ Everything downstream trusts this module: the safety checks, the capacity-tariff
|
|||||||
peak, the optimizer, the control loop's sign. So three things happen here and
|
peak, the optimizer, the control loop's sign. So three things happen here and
|
||||||
nowhere else.
|
nowhere else.
|
||||||
|
|
||||||
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 meter exposes two UNSIGNED
|
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 read over DSMR exposes two
|
||||||
registers - consumption and injection - never one signed figure. Net power
|
UNSIGNED registers - consumption and injection. Net power is
|
||||||
is `import_w - export_w`, positive = import, and that subtraction is done
|
`import_w - export_w`, positive = import, and that subtraction is done
|
||||||
exactly once, here (spec §5.2: "the derivation is the EMS's job, not a
|
exactly once, here (spec §5.2: "the derivation is the EMS's job, not a
|
||||||
template the user has to write"). A second copy of it somewhere else is a
|
template the user has to write"). A second copy of it somewhere else is a
|
||||||
second chance to invert the control loop.
|
second chance to invert the control loop.
|
||||||
|
|
||||||
|
Some P1 readers - the HomeWizard P1 among them - publish the OTHER shape:
|
||||||
|
one SIGNED figure, positive = import, and no unsigned registers at all.
|
||||||
|
`split_signed()` fans that back out into the same two magnitudes, so there
|
||||||
|
is still exactly one internal representation and one sign convention. ⚠️ It
|
||||||
|
lives here, next to the subtraction, for the same reason the subtraction
|
||||||
|
does: the moment a user is asked to write two template sensors that split a
|
||||||
|
signed value, the sign convention is back in unreviewed YAML underneath a
|
||||||
|
safety input, which is precisely what §5.2 moved into the EMS.
|
||||||
|
|
||||||
2. THE INGEST TIMESTAMP. Every accepted sample is stamped on arrival. A value
|
2. THE INGEST TIMESTAMP. Every accepted sample is stamped on arrival. A value
|
||||||
with no age is a value that cannot be trusted (§5.2), and staleness is the
|
with no age is a value that cannot be trusted (§5.2), and staleness is the
|
||||||
failsafe trigger (§11.2).
|
failsafe trigger (§11.2).
|
||||||
@@ -47,6 +56,8 @@ _LOG = logging.getLogger("goodwe.p1")
|
|||||||
|
|
||||||
SOURCE_HA = "ha_dsmr"
|
SOURCE_HA = "ha_dsmr"
|
||||||
SOURCE_MQTT = "mqtt_p1"
|
SOURCE_MQTT = "mqtt_p1"
|
||||||
|
SOURCE_HA_SIGNED = "ha_signed"
|
||||||
|
SOURCE_HOMEWIZARD = "homewizard_local"
|
||||||
|
|
||||||
QUARTER_S = 900
|
QUARTER_S = 900
|
||||||
|
|
||||||
@@ -78,7 +89,7 @@ class P1Sample:
|
|||||||
ingest_ts: datetime # tz-aware UTC, set at ingest
|
ingest_ts: datetime # tz-aware UTC, set at ingest
|
||||||
ingest_mono: float # time.monotonic() at ingest - see age_s()
|
ingest_mono: float # time.monotonic() at ingest - see age_s()
|
||||||
telegram_ts: datetime | None # from the telegram, where the source has one
|
telegram_ts: datetime | None # from the telegram, where the source has one
|
||||||
source: str # SOURCE_HA | SOURCE_MQTT
|
source: str # one of the SOURCE_* constants above
|
||||||
import_w: float # unsigned magnitude, as the meter reports it
|
import_w: float # unsigned magnitude, as the meter reports it
|
||||||
export_w: float # unsigned magnitude
|
export_w: float # unsigned magnitude
|
||||||
net_w: float # import_w - export_w (+ import, - export)
|
net_w: float # import_w - export_w (+ import, - export)
|
||||||
@@ -129,6 +140,29 @@ def _watts(value, what: str) -> float:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def split_signed(net_w) -> tuple[float, float]:
|
||||||
|
"""One signed figure -> the (import, export) magnitudes the module speaks.
|
||||||
|
|
||||||
|
The inverse of make_sample's subtraction, and the easy direction: no second
|
||||||
|
register to disagree with, so there is nothing to mix a fresh reading with a
|
||||||
|
stale one. `+` is import, `-` is export - verified in test_p1.py against real
|
||||||
|
captured readings from this house's own meter, not against a datasheet.
|
||||||
|
|
||||||
|
⚠️ Exactly one of the two comes out non-zero. Splitting into `(max(v,0),
|
||||||
|
max(-v,0))` rather than clamping keeps `import_w - export_w == v` exactly, so
|
||||||
|
the signed value the meter published survives the round trip bit for bit -
|
||||||
|
a control loop must not be steered by a number that changed on the way in.
|
||||||
|
|
||||||
|
⚠️ Validation is `_watts`, the same gate the unsigned path uses: NaN,
|
||||||
|
infinity, non-numbers and the §20 open-question-5 unsigned-decode
|
||||||
|
contamination (64954 for -582 W) are all refused here rather than believed.
|
||||||
|
A signed source makes that check MORE important, not less - on this path
|
||||||
|
64954 is not obviously wrong the way a negative "unsigned" register is.
|
||||||
|
"""
|
||||||
|
v = _watts(net_w, "net")
|
||||||
|
return (v, 0.0) if v >= 0 else (0.0, -v)
|
||||||
|
|
||||||
|
|
||||||
def make_sample(source: str, import_w, export_w, *, phases: int,
|
def make_sample(source: str, import_w, export_w, *, phases: int,
|
||||||
phase_import_w=None, phase_export_w=None,
|
phase_import_w=None, phase_export_w=None,
|
||||||
telegram_ts: datetime | None = None,
|
telegram_ts: datetime | None = None,
|
||||||
@@ -668,6 +702,333 @@ class MqttP1Source:
|
|||||||
self.ingest.reject(err)
|
self.ingest.reject(err)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# transport 3: Home Assistant WebSocket, one signed entity
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class HaSignedSource(HaDsmrSource):
|
||||||
|
"""The same websocket, subscribed to ONE signed power entity.
|
||||||
|
|
||||||
|
For readers that publish net power as a single signed figure - a HomeWizard
|
||||||
|
P1's `sensor.p1_meter_active_power`, positive = import - rather than the two
|
||||||
|
unsigned DSMR registers. This is the meter actually installed at the house,
|
||||||
|
and `ha_dsmr` cannot read it: it needs two registers and refuses a negative
|
||||||
|
one outright, which is every exporting telegram.
|
||||||
|
|
||||||
|
⚠️ A subclass, not a copy. The connect / auth / subscribe / reconnect /
|
||||||
|
`_absorb` machinery above is transport, not shape, and it has already been
|
||||||
|
debugged once - notably "prime the cache from get_states but never build a
|
||||||
|
sample out of it" and "a reconnect emits nothing". Only `_wanted` (which
|
||||||
|
entity ids) and `build` (how they become a sample) differ, so only those two
|
||||||
|
are overridden. Everything TEL-01 established therefore applies unchanged:
|
||||||
|
ingest timestamping, meter_max_age_s, the clock-recomputed age sensor, and
|
||||||
|
`unavailable` treated as a missing reading rather than 0 W.
|
||||||
|
|
||||||
|
⚠️ THE AGE ON THIS TRANSPORT MEASURES TIME SINCE THE VALUE CHANGED, not time
|
||||||
|
since the meter reported, and on one entity those are very different things.
|
||||||
|
Home Assistant offers no arrival signal for a repeated reading: it emits no
|
||||||
|
`state_changed`, it does not advance `last_reported` on either serialiser,
|
||||||
|
and `state_reported` cannot be subscribed to over the websocket at all
|
||||||
|
("Event filter is required for event state_reported"). All three measured on
|
||||||
|
the ENV-01 rig against the real HomeWizard integration with the meter frozen
|
||||||
|
- 0 state_changed in 70 s, no timestamp movement anywhere.
|
||||||
|
|
||||||
|
`ha_dsmr` mostly escapes it because a DSMR telegram moves several entities at
|
||||||
|
once. This transport has ONE, so a healthy meter under a flat load looks
|
||||||
|
exactly like a dead one - and our own capture has the real meter going 42.2 s
|
||||||
|
and 97.0 s between changes, both past the default max_age_s of 30. Hence
|
||||||
|
DOCS.md: sensor.p1_sample_age_s is correct while the value moves and must not
|
||||||
|
yet be thresholded by the firmware watchdog on this transport. Raising
|
||||||
|
meter_max_age_s does not fix it, it only chooses which of the two errors you
|
||||||
|
get. The fix is an arrival stamp from the meter itself - reading the
|
||||||
|
HomeWizard local API rather than an HA entity - which is a separate ticket.
|
||||||
|
|
||||||
|
⚠️ The debounce is inherited but does nothing useful here, and that is fine:
|
||||||
|
one telegram is one entity, so there is no burst of per-entity events to
|
||||||
|
coalesce and no window in which a new reading sits beside a stale one. It
|
||||||
|
costs one scheduled sleep per telegram at ~0.2 Hz. Left in place rather than
|
||||||
|
special-cased, because a second code path through build() is a second place
|
||||||
|
for the sign to go wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _wanted(self) -> set[str]:
|
||||||
|
out = set()
|
||||||
|
if self.entities.get("net"):
|
||||||
|
out.add(self.entities["net"])
|
||||||
|
out.update(e for e in self.entities.get("phase_net") or [] if e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def build(self) -> bool:
|
||||||
|
"""Assemble one sample from the cache. Returns True if one was accepted."""
|
||||||
|
net_id = self.entities.get("net")
|
||||||
|
if net_id not in self.cache:
|
||||||
|
return False
|
||||||
|
pn = [self.cache.get(e) for e in self.entities.get("phase_net") or []]
|
||||||
|
if pn and None in pn:
|
||||||
|
return False # incomplete phase set: wait, do not guess
|
||||||
|
try:
|
||||||
|
imp, exp = split_signed(self.cache[net_id])
|
||||||
|
pi = pe = None
|
||||||
|
if pn:
|
||||||
|
# ponytail: this split is arithmetically redundant today -
|
||||||
|
# make_sample subtracts the two lists again and does not
|
||||||
|
# sign-check per-phase figures, so handing it the signed values
|
||||||
|
# with a zero export list produces the identical tuple. Verified:
|
||||||
|
# mutating it that way leaves all 174 checks green, i.e. no test
|
||||||
|
# can tell the difference, and it is recorded here rather than
|
||||||
|
# left as a silent equivalent mutant for the next reviewer to
|
||||||
|
# rediscover. Kept because `phase_import_w` means a MAGNITUDE:
|
||||||
|
# a negative in it is the double-signing that make_sample refuses
|
||||||
|
# outright for the connection-level registers, and the day that
|
||||||
|
# check is extended per-phase the shortcut breaks the meter, not
|
||||||
|
# the test.
|
||||||
|
pairs = [split_signed(v) for v in pn]
|
||||||
|
pi = [a for a, _ in pairs]
|
||||||
|
pe = [b for _, b in pairs]
|
||||||
|
self.ingest.submit(make_sample(
|
||||||
|
SOURCE_HA_SIGNED, imp, exp,
|
||||||
|
phases=self.ingest.phases,
|
||||||
|
phase_import_w=pi, phase_export_w=pe,
|
||||||
|
# ⚠️ No telegram_ts, for the same reason as ha_dsmr: HA's
|
||||||
|
# last_changed is when the VALUE changed, which on a steady meter
|
||||||
|
# is minutes ago while the telegram is current. sensor.
|
||||||
|
# p1_sample_age_s is what covers a genuinely frozen meter.
|
||||||
|
))
|
||||||
|
return True
|
||||||
|
except P1Error as err:
|
||||||
|
self.ingest.reject(err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# transport 4: the HomeWizard P1's own local API
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
HOMEWIZARD_PATH = "/api/v1/data"
|
||||||
|
HOMEWIZARD_PORT = 80
|
||||||
|
|
||||||
|
|
||||||
|
def parse_homewizard(doc, phases: int, *, now: datetime | None = None,
|
||||||
|
ingest_mono: float | None = None) -> P1Sample:
|
||||||
|
"""One `GET /api/v1/data` document -> a sample. Raises P1Error.
|
||||||
|
|
||||||
|
The meter's own JSON, of which we read four keys:
|
||||||
|
|
||||||
|
{"active_power_w": -5710.0, # SIGNED, + import - export
|
||||||
|
"active_power_l1_w": ..., "active_power_l2_w": ..., "..._l3_w": ...,
|
||||||
|
"total_power_import_kwh": 1234.567, "total_power_export_kwh": 890.123}
|
||||||
|
|
||||||
|
Same shape as `ha_signed` - one signed connection figure plus signed
|
||||||
|
per-phase figures - so it goes through the same `split_signed` and the same
|
||||||
|
`make_sample`. Nothing about the sign convention is re-derived here.
|
||||||
|
|
||||||
|
⚠️ No telegram timestamp, because the API carries none, and that is correct
|
||||||
|
rather than a gap: the document was produced by the meter in the moment it
|
||||||
|
answered, so the ingest stamp IS the measurement time. This is the whole
|
||||||
|
reason the transport exists - see HomeWizardLocalSource.
|
||||||
|
|
||||||
|
⚠️ Per-phase figures are taken only when the meter serves ALL `phases` of
|
||||||
|
them. A single-phase HomeWizard returns `active_power_l2_w: null`, and a
|
||||||
|
partial set must not become a fabricated tuple. Unlike the HA transports
|
||||||
|
there is no "wait for the rest" case to worry about: every field here came
|
||||||
|
out of ONE response, so a missing phase cannot be a stale phase, and the
|
||||||
|
connection-level reading is still a genuine, complete measurement. Dropping
|
||||||
|
the whole sample over an absent per-phase field would turn a healthy meter
|
||||||
|
into a climbing age, which is the exact false-trip this ticket removes.
|
||||||
|
"""
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
raise P1Error(f"meter response is not a JSON object ({type(doc).__name__})")
|
||||||
|
|
||||||
|
imp, exp = split_signed(doc.get("active_power_w"))
|
||||||
|
|
||||||
|
pi = pe = None
|
||||||
|
# ⚠️ range(phases), not the legs the meter served: a 3-phase meter configured
|
||||||
|
# as meter_phases: 1 yields a per-phase tuple covering ONE leg of three.
|
||||||
|
# Control is unaffected (it uses the connection figure), but the
|
||||||
|
# capacity-tariff peak is understated. Filed separately - not fixed here.
|
||||||
|
legs = [doc.get(f"active_power_l{i + 1}_w") for i in range(phases)]
|
||||||
|
if all(v is not None for v in legs):
|
||||||
|
pairs = [split_signed(v) for v in legs]
|
||||||
|
pi = [a for a, _ in pairs]
|
||||||
|
pe = [b for _, b in pairs]
|
||||||
|
|
||||||
|
return make_sample(SOURCE_HOMEWIZARD, imp, exp, phases=phases,
|
||||||
|
phase_import_w=pi, phase_export_w=pe,
|
||||||
|
ingest_ts=now, ingest_mono=ingest_mono)
|
||||||
|
|
||||||
|
|
||||||
|
def measurement_fingerprint(doc) -> tuple:
|
||||||
|
"""The part of a response that a genuinely new measurement has to move.
|
||||||
|
|
||||||
|
Power plus both energy registers. A meter under any real load advances a
|
||||||
|
kWh counter (1 Wh resolution: ~10 s at 350 W), so this changes even when the
|
||||||
|
power figure happens to repeat. Used ONLY for `unchanged_s` - see there for
|
||||||
|
why it must not be allowed anywhere near the age.
|
||||||
|
"""
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return ()
|
||||||
|
return (doc.get("active_power_w"),
|
||||||
|
doc.get("total_power_import_kwh"),
|
||||||
|
doc.get("total_power_export_kwh"))
|
||||||
|
|
||||||
|
|
||||||
|
class HomeWizardLocalSource:
|
||||||
|
"""Polls the meter's own local HTTP API instead of a Home Assistant entity.
|
||||||
|
|
||||||
|
⚠️ THIS IS THE ONLY TRANSPORT WHOSE AGE MEASURES ARRIVAL. That is the entire
|
||||||
|
reason it exists, and it is not an optimisation - it is the difference
|
||||||
|
between an age sensor a firmware watchdog may threshold and one it may not.
|
||||||
|
|
||||||
|
Every HTTP response is a genuine arrival: the meter answered, now, with its
|
||||||
|
current reading. Whether the NUMBER moved is irrelevant, so a healthy meter
|
||||||
|
under a flat load resets the age exactly like a busy one. Home Assistant
|
||||||
|
cannot provide this at all - it emits `state_changed` only on a change, does
|
||||||
|
not advance `last_reported` on either serialiser for a repeat, and refuses a
|
||||||
|
`state_reported` subscription outright ("Event filter is required"). Measured
|
||||||
|
on the ENV-01 rig over 70 s of a frozen meter, and independently against the
|
||||||
|
live house over ten repeated readings. So on `ha_signed` the age means "time
|
||||||
|
since the value changed", and our own capture of this house's meter has
|
||||||
|
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 perfectly healthy meter.
|
||||||
|
|
||||||
|
Everything else is TEL-01's pipeline unchanged: ingest timestamping,
|
||||||
|
`meter_max_age_s`, the clock-recomputed age, the plausibility bounds, and a
|
||||||
|
failed read treated as a MISSING reading rather than 0 W.
|
||||||
|
|
||||||
|
⚠️ A failed or timed-out poll submits nothing. It is a rejection, so the
|
||||||
|
last good sample and its stamp stay exactly where they were and the age goes
|
||||||
|
on climbing - which is precisely the signal a dead meter should produce.
|
||||||
|
|
||||||
|
⚠️ POLL CADENCE vs `meter_max_age_s`. The age can only be as fresh as the
|
||||||
|
poll interval, so `meter_poll_s` must sit well under `meter_max_age_s` or the
|
||||||
|
reading flaps in and out of staleness on a healthy meter. The real meter
|
||||||
|
updates every ~5.0 s (NOTES.md:640 measures 4.97 s), so polling faster than
|
||||||
|
that buys nothing but re-reads. Default 5 s against a 30 s max age; the
|
||||||
|
startup check in build_source refuses `meter_poll_s >= meter_max_age_s` and
|
||||||
|
warns past half of it.
|
||||||
|
|
||||||
|
ponytail: a plain `asyncio.sleep` loop rather than a scheduler, and no
|
||||||
|
backoff. A poll that fails costs one rejection and is retried on the next
|
||||||
|
tick; a meter that is down stays down and the age reports it. The ceiling is
|
||||||
|
a meter so slow that polls overlap - the request timeout is held under the
|
||||||
|
poll interval so they cannot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session, ingest: P1Ingest, host: str, *,
|
||||||
|
port: int = HOMEWIZARD_PORT, poll_s: float = 5.0,
|
||||||
|
timeout_s: float | None = None):
|
||||||
|
self.session = session
|
||||||
|
self.ingest = ingest
|
||||||
|
self.host, self.port = host, int(port)
|
||||||
|
self.poll_s = float(poll_s)
|
||||||
|
# ⚠️ Held under the poll interval on purpose: a timeout longer than the
|
||||||
|
# cadence queues polls behind a hung meter, and a backlog of requests
|
||||||
|
# all landing at once would stamp several arrivals for one measurement.
|
||||||
|
self.timeout_s = float(timeout_s) if timeout_s else max(1.0, self.poll_s * 0.8)
|
||||||
|
self.url = f"http://{self.host}:{self.port}{HOMEWIZARD_PATH}"
|
||||||
|
self.connected = False
|
||||||
|
self.polls = 0
|
||||||
|
self.poll_errors = 0
|
||||||
|
self._fingerprint: tuple | None = None
|
||||||
|
self._changed_mono: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unchanged_s(self) -> float | None:
|
||||||
|
"""Seconds since the meter last served a DIFFERENT measurement.
|
||||||
|
|
||||||
|
⚠️ Diagnostic only, and deliberately NOT folded into
|
||||||
|
`sensor.p1_sample_age_s`. A frozen meter (SAFETY-01's six-minute
|
||||||
|
runaway) answers 200 OK with a stale document forever, so no arrival
|
||||||
|
detector can see it - only the document standing still can, and this is
|
||||||
|
that signal. It is exposed rather than acted on because it is NOT safe
|
||||||
|
as a 30 s watchdog input: this controller regulates grid power toward
|
||||||
|
~0 W, and at a converged -10 W the export register takes six minutes to
|
||||||
|
advance by its 1 Wh resolution while the power figure legitimately
|
||||||
|
repeats. Thresholding that would rebuild the very limit cycle the
|
||||||
|
arrival stamp just removed, at the exact operating point we aim for.
|
||||||
|
Whoever wires a freeze detector must handle the low-power case first.
|
||||||
|
"""
|
||||||
|
if self._changed_mono is None:
|
||||||
|
return None
|
||||||
|
return max(0.0, time.monotonic() - self._changed_mono)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Long-lived task: poll, submit, sleep, forever.
|
||||||
|
|
||||||
|
⚠️ Nothing but cancellation may end this loop. An escaping exception
|
||||||
|
would kill the poll task for the lifetime of the add-on, and it would do
|
||||||
|
it QUIETLY: the failure mode is safe (no submissions, the age climbs, the
|
||||||
|
watchdog holds the battery at 0 W) but it looks identical to a dead
|
||||||
|
meter, so the operator goes hunting the wrong device. Retry on the next
|
||||||
|
tick instead - poll_s is already the retry cadence, so no backoff.
|
||||||
|
"""
|
||||||
|
_LOG.info("P1 ingest: polling %s every %.1fs", self.url, self.poll_s)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.poll_once()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as err: # noqa: BLE001 - the task must outlive it
|
||||||
|
_LOG.warning("P1 meter poll %s: %s - retrying in %.1fs", self.url,
|
||||||
|
str(err) or type(err).__name__, self.poll_s)
|
||||||
|
await asyncio.sleep(self.poll_s)
|
||||||
|
|
||||||
|
async def poll_once(self) -> bool:
|
||||||
|
"""One GET. Returns True if a sample was accepted."""
|
||||||
|
self.polls += 1
|
||||||
|
try:
|
||||||
|
async with self.session.get(
|
||||||
|
self.url,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=self.timeout_s)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise P1Error(f"meter returned HTTP {resp.status}")
|
||||||
|
# content_type=None: the meter's own firmware is the authority on
|
||||||
|
# what it serves, and refusing a reading over a Content-Type
|
||||||
|
# header would be a fabricated outage.
|
||||||
|
# ⚠️ Kept because a real HomeWizard firmware that ever answers
|
||||||
|
# text/plain would otherwise read as a dead meter and hold the
|
||||||
|
# battery at 0 W. (This was once recorded here as an equivalent
|
||||||
|
# mutant - it is not. aiohttp's json_response always sets
|
||||||
|
# application/json, but web.Response(text=...) defaults to
|
||||||
|
# text/plain, so the fake meter CAN serve valid JSON under the
|
||||||
|
# wrong header, and test_p1.py now does.)
|
||||||
|
doc = await resp.json(content_type=None)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as err: # noqa: BLE001 - any read failure is a gap
|
||||||
|
self.connected = False
|
||||||
|
self.poll_errors += 1
|
||||||
|
# ⚠️ str() of a bare asyncio.TimeoutError is the EMPTY STRING, so
|
||||||
|
# f"...: {err}" renders "last error:" and then nothing - on the
|
||||||
|
# status page, on a hung meter, at the moment the battery has just
|
||||||
|
# dropped to 0 W and someone is reading that line to find out why.
|
||||||
|
# The class name is the only thing that says "it timed out".
|
||||||
|
# ⚠️ str(err), not `err or ...`: an exception object is ALWAYS truthy,
|
||||||
|
# empty message or not, so the `or` would never reach the fallback.
|
||||||
|
self.ingest.reject(
|
||||||
|
f"meter poll {self.url}: {str(err) or type(err).__name__}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.connected = True
|
||||||
|
try:
|
||||||
|
sample = parse_homewizard(doc, self.ingest.phases)
|
||||||
|
# ⚠️ Submitted unconditionally, INCLUDING a document identical to the
|
||||||
|
# last one. The response is the arrival; the number is not.
|
||||||
|
# Inside the try on purpose: submit() outside it would put a raise on
|
||||||
|
# a path with no handler at all, killing the poll task permanently -
|
||||||
|
# safely (the age climbs) but silently. run() backstops the rest.
|
||||||
|
self.ingest.submit(sample)
|
||||||
|
self._note(doc)
|
||||||
|
except P1Error as err:
|
||||||
|
self.ingest.reject(err)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _note(self, doc) -> None:
|
||||||
|
fp = measurement_fingerprint(doc)
|
||||||
|
if self._changed_mono is None or fp != self._fingerprint:
|
||||||
|
self._fingerprint = fp
|
||||||
|
self._changed_mono = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# selection
|
# selection
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -683,15 +1044,133 @@ def is_enabled(opts: dict) -> bool:
|
|||||||
return str(opts.get("meter_source", "off") or "off").strip() not in ("off", "")
|
return str(opts.get("meter_source", "off") or "off").strip() not in ("off", "")
|
||||||
|
|
||||||
|
|
||||||
|
def homewizard_host_port(opts: dict) -> tuple[str, str]:
|
||||||
|
"""`p1_host` split into host and (still unparsed) port text.
|
||||||
|
|
||||||
|
One definition because validate_meter_options() and build_source() must
|
||||||
|
agree byte for byte about what counts as a usable address - a pasted browser
|
||||||
|
URL is the obvious way to fill this in wrong.
|
||||||
|
"""
|
||||||
|
host = str(opts.get("p1_host", "") or "").strip()
|
||||||
|
host = host.removeprefix("http://").removeprefix("https://").rstrip("/")
|
||||||
|
host, _, port_s = host.partition(":")
|
||||||
|
return host, port_s
|
||||||
|
|
||||||
|
|
||||||
|
def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[str]:
|
||||||
|
"""Every way `meter_source` can name a meter that will never produce a reading.
|
||||||
|
|
||||||
|
⚠️ THIS IS THE ONLY PLACE THAT KNOWS THESE RULES. It used to be a run of
|
||||||
|
inline `return None`s inside build_source(); CFG-01 lifted them out because
|
||||||
|
the same predicates now have two callers with two different consequences:
|
||||||
|
|
||||||
|
validate.validate_startup_config() refuses to start the add-on
|
||||||
|
build_source() returns None, as it always did
|
||||||
|
|
||||||
|
They are one function and not two on purpose. A second copy of "what makes a
|
||||||
|
meter config wrong" would drift, and the half that drifted would be the one
|
||||||
|
that decides whether the process is allowed to run.
|
||||||
|
|
||||||
|
Checked ONCE at startup rather than per telegram, because a misconfigured
|
||||||
|
source otherwise fails in the one way indistinguishable from a healthy source
|
||||||
|
nobody has sent anything to yet: no samples, a climbing age, the firmware
|
||||||
|
watchdog holding the battery at 0 W, and nothing in the log saying why.
|
||||||
|
|
||||||
|
Returns a list of human-readable problems; empty means the config is usable.
|
||||||
|
`phases` and `max_age_s` are passed in rather than read from opts so the
|
||||||
|
caller decides whether it is validating the options or an already-built
|
||||||
|
P1Ingest - build_source() has an ingest whose values may not have come from
|
||||||
|
this dict.
|
||||||
|
"""
|
||||||
|
source = str(opts.get("meter_source", "off") or "off").strip()
|
||||||
|
if source in ("off", ""):
|
||||||
|
return []
|
||||||
|
problems: list[str] = []
|
||||||
|
|
||||||
|
if source == SOURCE_HA:
|
||||||
|
imp = str(opts.get("p1_import_entity", "") or "").strip()
|
||||||
|
exp = str(opts.get("p1_export_entity", "") or "").strip()
|
||||||
|
if not imp or not exp:
|
||||||
|
problems.append(
|
||||||
|
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
|
||||||
|
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
|
||||||
|
elif source == SOURCE_HA_SIGNED:
|
||||||
|
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||||
|
phase_net = [str(e).strip() for e in
|
||||||
|
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
|
||||||
|
if not net:
|
||||||
|
problems.append(
|
||||||
|
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
|
||||||
|
"(sensor.p1_sample_age_s would otherwise be announced with "
|
||||||
|
"nothing feeding it)")
|
||||||
|
if phase_net and len(phase_net) != phases:
|
||||||
|
problems.append(
|
||||||
|
f"p1_phase_net_entities has {len(phase_net)} entities but "
|
||||||
|
f"meter_phases is {phases} - every telegram would be rejected "
|
||||||
|
"on the phase-count check")
|
||||||
|
elif source == SOURCE_MQTT:
|
||||||
|
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
|
||||||
|
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
|
||||||
|
elif source == SOURCE_HOMEWIZARD:
|
||||||
|
host, port_s = homewizard_host_port(opts)
|
||||||
|
if not host:
|
||||||
|
problems.append(
|
||||||
|
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
|
||||||
|
"own address, e.g. 192.168.2.250)")
|
||||||
|
if port_s:
|
||||||
|
try:
|
||||||
|
int(port_s)
|
||||||
|
except ValueError:
|
||||||
|
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
|
||||||
|
"unparseable port")
|
||||||
|
try:
|
||||||
|
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
|
||||||
|
# cadence of 0 is a misconfiguration that must be reported, not
|
||||||
|
# quietly corrected into something that looks like it was asked for.
|
||||||
|
raw = opts.get("meter_poll_s", 5)
|
||||||
|
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
problems.append(f"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
|
||||||
|
else:
|
||||||
|
if poll_s <= 0:
|
||||||
|
problems.append("meter_poll_s must be positive")
|
||||||
|
# ⚠️ The age can never be fresher than the poll interval. At or past
|
||||||
|
# max_age_s every reading is stale before its successor arrives, so
|
||||||
|
# the controller would sit permanently on missing inputs while the
|
||||||
|
# meter is perfectly healthy - refuse it rather than ship that.
|
||||||
|
elif poll_s >= max_age_s:
|
||||||
|
problems.append(
|
||||||
|
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
|
||||||
|
f"{max_age_s:.1f} - every reading would go stale before the "
|
||||||
|
"next poll")
|
||||||
|
else:
|
||||||
|
problems.append(
|
||||||
|
f"meter_source {source!r} is not one of "
|
||||||
|
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
||||||
"""Return the transport named by `meter_source`, or None if disabled.
|
"""Return the transport named by `meter_source`, or None if disabled.
|
||||||
|
|
||||||
This is the whole of AC 1's "switchable": every consumer reads P1Ingest, so
|
This is the whole of AC 1's "switchable": every consumer reads P1Ingest, so
|
||||||
changing transport is a config edit, never a code path.
|
changing transport is a config edit, never a code path.
|
||||||
|
|
||||||
|
⚠️ In the add-on this is now unreachable with a bad config -
|
||||||
|
validate_startup_config() refuses to start on anything
|
||||||
|
validate_meter_options() rejects. The guard stays because this function is
|
||||||
|
also called directly by tests and by anything embedding P1Ingest, and
|
||||||
|
"returns None" is a safer default for those callers than a transport that
|
||||||
|
can never read.
|
||||||
"""
|
"""
|
||||||
source = str(opts.get("meter_source", "off") or "off").strip()
|
source = str(opts.get("meter_source", "off") or "off").strip()
|
||||||
if not is_enabled(opts):
|
if not is_enabled(opts):
|
||||||
return None
|
return None
|
||||||
|
problems = validate_meter_options(opts, ingest.phases, ingest.max_age_s)
|
||||||
|
if problems:
|
||||||
|
for problem in problems:
|
||||||
|
_LOG.error("%s - P1 ingestion disabled", problem)
|
||||||
|
return None
|
||||||
if source == SOURCE_HA:
|
if source == SOURCE_HA:
|
||||||
return HaDsmrSource(session, ingest, {
|
return HaDsmrSource(session, ingest, {
|
||||||
"import": opts.get("p1_import_entity", ""),
|
"import": opts.get("p1_import_entity", ""),
|
||||||
@@ -699,12 +1178,29 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
|||||||
"phase_import": opts.get("p1_phase_import_entities") or [],
|
"phase_import": opts.get("p1_phase_import_entities") or [],
|
||||||
"phase_export": opts.get("p1_phase_export_entities") or [],
|
"phase_export": opts.get("p1_phase_export_entities") or [],
|
||||||
})
|
})
|
||||||
|
if source == SOURCE_HA_SIGNED:
|
||||||
|
return HaSignedSource(session, ingest, {
|
||||||
|
"net": str(opts.get("p1_net_entity", "") or "").strip(),
|
||||||
|
"phase_net": [str(e).strip() for e in
|
||||||
|
(opts.get("p1_phase_net_entities") or []) if str(e).strip()],
|
||||||
|
})
|
||||||
|
if source == SOURCE_HOMEWIZARD:
|
||||||
|
host, port_s = homewizard_host_port(opts)
|
||||||
|
port = int(port_s) if port_s else HOMEWIZARD_PORT
|
||||||
|
raw = opts.get("meter_poll_s", 5)
|
||||||
|
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||||
|
# A warning, not a refusal: one missed poll costs a stale reading and a
|
||||||
|
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
|
||||||
|
# can NEVER be fresh.
|
||||||
|
if poll_s > ingest.max_age_s / 2:
|
||||||
|
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
|
||||||
|
"%.1f: one missed poll makes the reading stale. The meter "
|
||||||
|
"updates every ~5 s; 5 s against 30 s is the tested pair.",
|
||||||
|
poll_s, ingest.max_age_s)
|
||||||
|
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
|
||||||
if source == SOURCE_MQTT:
|
if source == SOURCE_MQTT:
|
||||||
broker = broker or {}
|
broker = broker or {}
|
||||||
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),
|
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),
|
||||||
broker.get("host"), broker.get("port", 1883),
|
broker.get("host"), broker.get("port", 1883),
|
||||||
broker.get("username"), broker.get("password"))
|
broker.get("username"), broker.get("password"))
|
||||||
if source:
|
|
||||||
_LOG.error("meter_source %r is not %s or %s - P1 ingestion disabled",
|
|
||||||
source, SOURCE_HA, SOURCE_MQTT)
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -22,11 +22,30 @@ _LOG = logging.getLogger("goodwe.store")
|
|||||||
# `auto_start` option only when the key is absent - if a default supplied False,
|
# `auto_start` option only when the key is absent - if a default supplied False,
|
||||||
# the fallback could never fire and auto_start would silently do nothing on
|
# the fallback could never fire and auto_start would silently do nothing on
|
||||||
# every fresh install.
|
# every fresh install.
|
||||||
|
#
|
||||||
|
# ⚠️ `sign_tripped` IS in here, and the difference from `auto` is the whole
|
||||||
|
# reason both decisions are written down. `auto` has an option to fall back to
|
||||||
|
# (`auto_start`), so a stored default would mask a fresh install. A latched
|
||||||
|
# failsafe has no such fallback: absent means "never tripped", which is exactly
|
||||||
|
# what a default of False says.
|
||||||
|
#
|
||||||
|
# It is persisted for the reason this module's docstring gives for maintenance
|
||||||
|
# state, applied to something with a much shorter fuse. SAFETY-02's latch is the
|
||||||
|
# only thing standing between a controller that has been caught driving the wrong
|
||||||
|
# way and its own restart, and "restart the add-on" is the FIRST thing an operator
|
||||||
|
# looking at a FAILSAFE banner will try. Held only in memory it came back armed,
|
||||||
|
# into the fault, unattended - measured: after reconstructing Controller against
|
||||||
|
# the same store, `tripped` was False, the banner read NOT READY, and it resumed
|
||||||
|
# commanding -1000, -2000 W before re-tripping 180 s later. Under a supervisor
|
||||||
|
# that restarts a crashed add-on that is a loop, three minutes of wrong-direction
|
||||||
|
# power at a time, forever.
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"phase": "idle",
|
"phase": "idle",
|
||||||
"phase_started": None,
|
"phase_started": None,
|
||||||
"last_completed": None,
|
"last_completed": None,
|
||||||
"last_start_attempt": None,
|
"last_start_attempt": None,
|
||||||
|
"sign_tripped": False,
|
||||||
|
"sign_reason": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -42,8 +61,10 @@ class Store:
|
|||||||
loaded = json.load(fh)
|
loaded = json.load(fh)
|
||||||
if isinstance(loaded, dict):
|
if isinstance(loaded, dict):
|
||||||
self.data = {**DEFAULTS, **loaded}
|
self.data = {**DEFAULTS, **loaded}
|
||||||
_LOG.info("state restored: phase=%s last_completed=%s",
|
_LOG.info("state restored: phase=%s last_completed=%s "
|
||||||
self.data.get("phase"), self.data.get("last_completed"))
|
"sign_tripped=%s",
|
||||||
|
self.data.get("phase"), self.data.get("last_completed"),
|
||||||
|
self.data.get("sign_tripped"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
_LOG.info("no saved state, starting fresh")
|
_LOG.info("no saved state, starting fresh")
|
||||||
except (json.JSONDecodeError, OSError) as err:
|
except (json.JSONDecodeError, OSError) as err:
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""Startup configuration validation - the gate before the control loop runs.
|
||||||
|
|
||||||
|
WHAT THIS IS FOR, in one sentence: some configurations are not "a bit off", they
|
||||||
|
are configurations under which the add-on's own safety machinery does not work,
|
||||||
|
and for those the correct behaviour is to refuse to start rather than to log a
|
||||||
|
line and carry on.
|
||||||
|
|
||||||
|
⚠️ THE FAILURE THIS REPLACES. `main.py` used to read the setpoint device's own
|
||||||
|
±5000 W rating, notice that a configured `max_w` of, say, 8000 W exceeded it,
|
||||||
|
write one WARNING to a log nobody reads, and then run anyway with
|
||||||
|
`write_setpoint()` quietly clamping. The operator's stated ceiling was simply not
|
||||||
|
the ceiling, and the only evidence was a line that had scrolled off. specs §1.5
|
||||||
|
is explicit that a configuration in which the system cannot honour what it was
|
||||||
|
told is refused at startup, not survived at runtime.
|
||||||
|
|
||||||
|
WHERE THE LINE IS DRAWN, and why it is not further out. A validator that refuses
|
||||||
|
on trivia gets switched off by the first person it inconveniences, so this one
|
||||||
|
refuses only where **continuing produces one of two outcomes:**
|
||||||
|
|
||||||
|
(a) **a stated limit is untrue** - the operator configured a bound, a lower
|
||||||
|
layer silently enforces a different one, and nothing says which won; or
|
||||||
|
(b) **the add-on is inert and indistinguishable from healthy** - it starts,
|
||||||
|
serves its UI, reports green, and moves no energy, with no symptom that
|
||||||
|
separates it from a correctly-idle system.
|
||||||
|
|
||||||
|
⚠️ (b) IS NOT A SPECIAL CASE OF (a), and an earlier draft of this file wrongly
|
||||||
|
implied it was by claiming every refusal here "makes a safety mechanism
|
||||||
|
inoperative". It does not: when `setpoint_entity` is blank, layer 1 pinning the
|
||||||
|
inverter at 0 W is the failsafe WORKING. The problem is not that the system
|
||||||
|
becomes unsafe, it is that it becomes a very convincing impression of a working
|
||||||
|
controller - and an operator has no way to tell. That is its own reason to
|
||||||
|
refuse, and stating it as one is what stops the rule being stretched later to
|
||||||
|
cover mere untidiness.
|
||||||
|
|
||||||
|
Both halves have to stay narrow. (a) is not "any two numbers that could
|
||||||
|
disagree"; it is a bound the operator SET being overridden by a layer they
|
||||||
|
cannot see. (b) is not "anything degraded"; it is inert AND silent - if a red
|
||||||
|
NOT READY banner already names the problem, (b) does not apply.
|
||||||
|
|
||||||
|
1. `max_w` outside the setpoint device's own rating. [(a)]
|
||||||
|
The stated limit is untrue. arbiter.py's `supervised` SAFETY claim says the
|
||||||
|
output is bounded by ±max_w; it is not, it is bounded by the device, and the
|
||||||
|
two numbers disagree. Which one wins is a coin toss to anyone reading the
|
||||||
|
config.
|
||||||
|
|
||||||
|
2. No `setpoint_entity`. [(b)]
|
||||||
|
A controller with no actuator, and nothing about it looks broken.
|
||||||
|
`write_setpoint()` returns immediately on a blank entity, so: no device
|
||||||
|
rating is ever read (the `device` SAFETY claim is never made at all), and
|
||||||
|
the ESP32 heartbeat - the thing that feeds LAYER 1 - never happens. The
|
||||||
|
add-on serves a UI, publishes MQTT, resolves claims, and moves nothing.
|
||||||
|
Layer 1 then holds the inverter at 0 W, which is the failsafe working
|
||||||
|
exactly as designed - this is (b), not (a). Nothing is unsafe; it is simply
|
||||||
|
impossible to tell from the outside that the thing is doing nothing.
|
||||||
|
|
||||||
|
3. A `meter_source` that can never produce a reading. [(b)]
|
||||||
|
§5.1 makes P1 the only authoritative measurement and deliberately provides
|
||||||
|
no fallback, so a transport that cannot be built means `grid` is None
|
||||||
|
forever: permanent "inputs missing", 0 W, and a status page that reports a
|
||||||
|
meter with a climbing age exactly as it would for a healthy meter nobody has
|
||||||
|
sent anything to yet. The predicates live in `p1.validate_meter_options()`,
|
||||||
|
which is also what `build_source()` uses - one definition, two callers.
|
||||||
|
|
||||||
|
4. `meter_max_age_s` above the firmware's own ingest veto. [(a)]
|
||||||
|
The purest example of (a) in the system, and structurally the same fault as
|
||||||
|
1. The add-on's schema accepts `int(5,300)`. The firmware's threshold is a
|
||||||
|
COMPILE-TIME SUBSTITUTION - `max_age_s: "30"` in
|
||||||
|
goodwe-master-stage4-failsafe.yaml - used by both its monotonic receipt
|
||||||
|
clock and its `ingest_stale` veto, which trips layer 1 on
|
||||||
|
`p1_age_s >= 30`. Set 120 and the add-on believes a 119 s reading is fresh
|
||||||
|
and keeps commanding into it, while the firmware vetoed at 30 s and is
|
||||||
|
holding the inverter at 0 W. Status page: healthy meter, running
|
||||||
|
controller, nothing moving, and no line anywhere saying the number you set
|
||||||
|
was overruled. That firmware file's own comment says a lower bound on this
|
||||||
|
"belongs in CFG-01, not here" - so here it is.
|
||||||
|
|
||||||
|
5. `sign_disagree_w` at or above `max_w`. [(a)]
|
||||||
|
The arbiter's `supervised` limit bounds every command to +-max_w, and
|
||||||
|
SAFETY-02's predicate needs `abs(command) > sign_disagree_w`. Set the
|
||||||
|
threshold at or above the ceiling and the command side of the comparison
|
||||||
|
is unsatisfiable BY CONSTRUCTION: the watchdog can never fire, on any
|
||||||
|
input, ever. Same shape as 1 and 4 - the operator set a number, a layer
|
||||||
|
they cannot see makes it mean something else, and nothing says so. Worse
|
||||||
|
here, because what the status page reports in that state is "sign
|
||||||
|
watchdog: armed". A disarmed guard that advertises itself as armed is the
|
||||||
|
one thing this project has decided repeatedly it will not ship.
|
||||||
|
|
||||||
|
6. `maintenance_charge_w` above `max_w`. [(a)]
|
||||||
|
The same shape as 5, and found the same way - by checking a worked example
|
||||||
|
against what actually ships. maintenance's cheap-window shaping files
|
||||||
|
`Claim.limit(hi=-maintenance_charge_w)` at P_MAINT_SHAPE, against the
|
||||||
|
`supervised` SAFETY limit's `lo=-max_w`. Both bind (P_SAFETY and
|
||||||
|
P_MAINT_SHAPE both outrank the loop that wins the SET), so a charge rate
|
||||||
|
above the ceiling gives `lo > hi` - the arbiter's clause 3, which it
|
||||||
|
correctly resolves to 0 W and logs as a CONTRADICTION. Measured against
|
||||||
|
the 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(...)
|
||||||
|
|
||||||
|
So MAINTENANCE SILENTLY NEVER CHARGES IN A CHEAP WINDOW until max_w is at
|
||||||
|
or above the charge rate, and the only evidence is a contradiction line
|
||||||
|
that reads like a bug in the arbiter rather than like the two numbers the
|
||||||
|
operator set. Equality is fine and deliberately not refused - at
|
||||||
|
max_w == charge_w the window is a single reachable point, -max_w.
|
||||||
|
|
||||||
|
⚠️ `maintenance_discharge_w` IS NOT THE SAME CASE, and this was checked
|
||||||
|
rather than assumed. THE SHAPING IS NOT SYMMETRIC: the drain phase does
|
||||||
|
not shape the loop, it OWNS the actuator - `Claim.set(P_MAINTENANCE,
|
||||||
|
+discharge_w)`. A SET is clamped by the limits above it, not contradicted
|
||||||
|
by them, so 2500 against a 2000 W ceiling emits +2000 W and the arbiter's
|
||||||
|
own explain() says `maintenance -> 2000 W, limited by supervised(max_w)`
|
||||||
|
on the status page every cycle. That is neither (a) - nothing is silent -
|
||||||
|
nor (b) - it moves energy and the cycle still completes, just slower
|
||||||
|
(13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT).
|
||||||
|
It is warned about below, not refused.
|
||||||
|
|
||||||
|
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||||
|
|
||||||
|
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||||
|
extra headroom buys is more wrong-direction power to unwind after
|
||||||
|
saturation - but the arbiter's `supervised` limit still binds the wire to
|
||||||
|
±max_w, so nothing out of range ever reaches the inverter. The cost is
|
||||||
|
latency, not amperage. Warned about, not refused.
|
||||||
|
- A missing `soc_entity` / `batt_entity`. `inputs_ok` goes False, the loop
|
||||||
|
commands 0 W, and `checks()` already renders a red NOT READY banner naming
|
||||||
|
the missing entity. Fail-safe AND already loud; a refusal adds nothing.
|
||||||
|
- A `setpoint_entity` that exists in the config but is not found on the Core
|
||||||
|
API at startup. This is the transient case - ESPHome is frequently not up
|
||||||
|
yet when the add-on boots - and refusing here would turn a device that
|
||||||
|
arrives thirty seconds late into a restart loop needing a human. It stays a
|
||||||
|
NOT READY banner.
|
||||||
|
- `meter_poll_s` above half `meter_max_age_s`. One missed poll costs a stale
|
||||||
|
reading and a 0 W cycle. Warned about in p1.py, not refused.
|
||||||
|
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
|
||||||
|
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
|
||||||
|
not refused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .p1 import is_enabled, validate_meter_options
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("goodwe.config")
|
||||||
|
|
||||||
|
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
|
||||||
|
# threshold is a compile-time YAML substitution baked into the ESP32 image
|
||||||
|
# (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at
|
||||||
|
# runtime can ask the board what value it was built with. This number is a copy,
|
||||||
|
# and a copy of a constant in another repo is a thing that goes stale.
|
||||||
|
#
|
||||||
|
# What makes that acceptable here: the copy can only ever be WRONG IN THE SAFE
|
||||||
|
# DIRECTION for the check below. If the firmware's veto is raised above 30, this
|
||||||
|
# refuses some configs that would in fact have worked - annoying, visible
|
||||||
|
# immediately, and fixed by editing one line. It cannot start accepting a config
|
||||||
|
# the firmware vetoes unless the firmware's number goes DOWN, which would be a
|
||||||
|
# tightening nobody does silently.
|
||||||
|
#
|
||||||
|
# ⚠️ TEL-06 IS EDITING `ingest_stale` DIRECTLY. If that ticket changes what the
|
||||||
|
# veto MEANS - e.g. thresholding arrival rather than the sensor's value - this
|
||||||
|
# constant's justification has to be re-read, not just its number. Do not assume
|
||||||
|
# a matching integer means a matching rule.
|
||||||
|
FIRMWARE_INGEST_VETO_S = 30.0
|
||||||
|
|
||||||
|
# ⚠️ The complete set of option keys through which this add-on can reach an
|
||||||
|
# actuator. specs §1.5 makes "any configuration in which two writers can reach
|
||||||
|
# one actuator" a non-goal, and the way that non-goal is ENFORCED here is by
|
||||||
|
# construction: there is exactly one such key, so a second writer is not
|
||||||
|
# expressible in the options schema at all. sim/inject.py's `two_writers`
|
||||||
|
# scenario asserts on this tuple - if a second write path is ever added, that
|
||||||
|
# scenario goes red and somebody has to decide how the two are arbitrated
|
||||||
|
# BEFORE the config that allows both can ship.
|
||||||
|
ACTUATOR_WRITE_KEYS = ("setpoint_entity",)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRefused(Exception):
|
||||||
|
"""The configuration is wrong. Not "something broke" - see main.py's handler.
|
||||||
|
|
||||||
|
Carries the list of problems so the caller can render them; str() is the
|
||||||
|
first one, so an unhandled traceback still names something useful.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, problems: list[str]):
|
||||||
|
self.problems = problems
|
||||||
|
super().__init__(problems[0] if problems else "configuration refused")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]:
|
||||||
|
"""Return the list of reasons this configuration must not be started.
|
||||||
|
|
||||||
|
Empty list means go. `dev_min`/`dev_max` are the setpoint device's own
|
||||||
|
reported range (`hass.limits()`), or None when the device could not be
|
||||||
|
reached - in which case the range checks are skipped rather than guessed at,
|
||||||
|
for the transient reason in this module's docstring.
|
||||||
|
|
||||||
|
Pure and side-effect free apart from two warnings, so it can be called from
|
||||||
|
a test or from sim/inject.py without a Home Assistant anywhere near it.
|
||||||
|
"""
|
||||||
|
problems: list[str] = []
|
||||||
|
|
||||||
|
# --- 1. an actuator to write to ------------------------------------------
|
||||||
|
writers = [k for k in ACTUATOR_WRITE_KEYS
|
||||||
|
if str(opts.get(k, "") or "").strip()]
|
||||||
|
if not writers:
|
||||||
|
problems.append(
|
||||||
|
"setpoint_entity is not set, so this add-on has no actuator: every "
|
||||||
|
"write is a no-op, the device's own rating is never read, and the "
|
||||||
|
"ESP32 heartbeat that holds off the layer-1 failsafe never happens. "
|
||||||
|
"It would run, look healthy, and move no energy at all. Set it to "
|
||||||
|
"the ESPHome number entity that carries the setpoint.")
|
||||||
|
|
||||||
|
# --- 2. the configured ceiling against the device's own rating -----------
|
||||||
|
max_w = _number(opts, "max_w", 2000.0)
|
||||||
|
if max_w is None:
|
||||||
|
problems.append(f"max_w {opts.get('max_w')!r} is not a number")
|
||||||
|
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
|
||||||
|
# the check is not for the Supervisor path - it is because
|
||||||
|
# validate_startup_config() is now also sim/inject.py's entry point and can
|
||||||
|
# be handed any dict at all. A max_w of 0 or below inverts the arbiter's
|
||||||
|
# `supervised` bounds (lo=-max_w becomes the HIGHER number), and a limit
|
||||||
|
# claim whose lo exceeds its hi is exactly the contradiction the arbiter
|
||||||
|
# resolves to 0 W - i.e. it would look like a safety trip rather than the
|
||||||
|
# nonsense config it is.
|
||||||
|
elif max_w <= 0:
|
||||||
|
problems.append(
|
||||||
|
f"max_w is {max_w:.0f} W and must be positive - it is a magnitude, "
|
||||||
|
"not a signed setpoint. At or below zero the arbiter's supervised "
|
||||||
|
f"limit becomes lo={-max_w:.0f} / hi={max_w:.0f}, which is a "
|
||||||
|
"contradiction it resolves to 0 W, so the controller would look "
|
||||||
|
"like it was tripping rather than misconfigured.")
|
||||||
|
elif dev_max is not None and max_w > dev_max:
|
||||||
|
problems.append(
|
||||||
|
f"max_w is {max_w:.0f} W but {opts.get('setpoint_entity') or 'the setpoint device'} accepts at "
|
||||||
|
f"most {dev_max:.0f} W - THE DEVICE WINS, so the {max_w:.0f} W you "
|
||||||
|
f"configured would silently never happen. Lower max_w to "
|
||||||
|
f"{dev_max:.0f} W or less, or point setpoint_entity at a device that "
|
||||||
|
f"can take {max_w:.0f} W.")
|
||||||
|
elif dev_min is not None and -max_w < dev_min:
|
||||||
|
problems.append(
|
||||||
|
f"max_w is {max_w:.0f} W, so this controller may command "
|
||||||
|
f"{-max_w:.0f} W, but {opts.get('setpoint_entity') or 'the setpoint device'} accepts no less "
|
||||||
|
f"than {dev_min:.0f} W - THE DEVICE WINS on the charge side. Lower "
|
||||||
|
f"max_w to {abs(dev_min):.0f} W or less.")
|
||||||
|
|
||||||
|
# --- 3. a meter that can actually read -----------------------------------
|
||||||
|
# ⚠️ Not a second copy of these rules - p1.py owns them, because
|
||||||
|
# build_source() has to apply the identical predicates and a drifted copy
|
||||||
|
# would be worse than no check.
|
||||||
|
phases = _number(opts, "meter_phases", 1.0)
|
||||||
|
max_age_s = _number(opts, "meter_max_age_s", 30.0)
|
||||||
|
if phases is None or max_age_s is None:
|
||||||
|
problems.append("meter_phases and meter_max_age_s must both be numbers "
|
||||||
|
f"(got {opts.get('meter_phases')!r} / "
|
||||||
|
f"{opts.get('meter_max_age_s')!r})")
|
||||||
|
else:
|
||||||
|
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
||||||
|
|
||||||
|
# --- 4. the staleness window the FIRMWARE will actually enforce -----------
|
||||||
|
# ⚠️ Only when P1 ingestion is on, and that is a real scope limit rather than
|
||||||
|
# caution: meter_max_age_s is read by nothing but P1Ingest, and the veto it
|
||||||
|
# collides with keys off `p1_age_s.has_state()`, which is only ever published
|
||||||
|
# when ingestion is running (see Controller.publish()). With meter_source off
|
||||||
|
# there is no sensor, the veto is inert, and the number governs nothing.
|
||||||
|
#
|
||||||
|
# Strictly greater, not >=: the firmware trips at `>= 30` and P1Ingest goes
|
||||||
|
# stale at `> max_age_s`, so 30 against 30 has the firmware fractionally
|
||||||
|
# stricter by one sample. That is the shipped default and the tested pair.
|
||||||
|
# BELOW 30 is fine and deliberately not refused - the add-on then gives up
|
||||||
|
# first, which is fail-safe and visible on its own status page. Only ABOVE is
|
||||||
|
# the silent case, where the add-on keeps commanding into a reading the board
|
||||||
|
# has already rejected.
|
||||||
|
if (is_enabled(opts) and max_age_s is not None
|
||||||
|
and max_age_s > FIRMWARE_INGEST_VETO_S):
|
||||||
|
problems.append(
|
||||||
|
f"meter_max_age_s is {max_age_s:.0f} s but the ESP32 firmware vetoes "
|
||||||
|
f"any P1 sample at {FIRMWARE_INGEST_VETO_S:.0f} s and forces the "
|
||||||
|
"layer-1 failsafe - THE FIRMWARE WINS, and it wins silently. This "
|
||||||
|
f"add-on would treat a {max_age_s - 1:.0f} s reading as fresh and "
|
||||||
|
"keep commanding, while the inverter sat at 0 W with a healthy-"
|
||||||
|
"looking meter on the status page. Lower meter_max_age_s to "
|
||||||
|
f"{FIRMWARE_INGEST_VETO_S:.0f} s or less (30 s is the tested value), "
|
||||||
|
"or rebuild the firmware with a matching max_age_s and update "
|
||||||
|
"validate.FIRMWARE_INGEST_VETO_S.")
|
||||||
|
|
||||||
|
# --- 5. a sign watchdog that can actually fire ---------------------------
|
||||||
|
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
|
||||||
|
# `abs(commanded_w) > min_w`, and the arbiter holds `abs(commanded_w)` at or
|
||||||
|
# below max_w, so threshold == max_w is already unsatisfiable. Anything
|
||||||
|
# BELOW max_w is reachable and therefore fine - this refuses the disarmed
|
||||||
|
# case only, not "close to the rail".
|
||||||
|
sign_w = _number(opts, "sign_disagree_w", 300.0)
|
||||||
|
if sign_w is None:
|
||||||
|
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
|
||||||
|
elif max_w is not None and max_w > 0 and sign_w >= max_w:
|
||||||
|
problems.append(
|
||||||
|
f"sign_disagree_w is {sign_w:.0f} W but max_w is {max_w:.0f} W, so "
|
||||||
|
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
|
||||||
|
"arbiter never lets a command exceed max_w, and the watchdog needs "
|
||||||
|
f"a command over {sign_w:.0f} W. It would sit there reporting "
|
||||||
|
"'armed' on the status page while being incapable of tripping - the "
|
||||||
|
f"one runaway failure mode this system has actually observed. Lower "
|
||||||
|
f"sign_disagree_w below {max_w:.0f} W (300 W is the measured "
|
||||||
|
"default), or raise max_w.")
|
||||||
|
|
||||||
|
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||||
|
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
||||||
|
# `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so
|
||||||
|
# charge_w == max_w leaves the single reachable point -max_w and works;
|
||||||
|
# only ABOVE is `lo > hi`, arbiter clause 3, 0 W and a contradiction.
|
||||||
|
#
|
||||||
|
# Not scoped to maintenance_enabled, deliberately. The pair is
|
||||||
|
# contradictory whether or not the schedule is on, and scoping it would
|
||||||
|
# leave the trap armed for the one operator action - ticking
|
||||||
|
# maintenance_enabled - that cannot possibly be expected to change what a
|
||||||
|
# charge rate means.
|
||||||
|
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, and it has to be kept
|
||||||
|
# equal to it - that is the whole point of AC3. It is reachable only when a
|
||||||
|
# caller hands over a dict with the key missing (sim/inject.py, a test); the
|
||||||
|
# Supervisor always supplies every key from config.yaml, so on a real
|
||||||
|
# install the argument is always present.
|
||||||
|
#
|
||||||
|
# ⚠️ KNOWN MISMATCH, LEFT DELIBERATELY AND FLAGGED RATHER THAN FIXED HERE:
|
||||||
|
# `main.py` still falls back to 2500 in two places
|
||||||
|
# (`opts.get("maintenance_charge_w", 2500)`), as does maintenance.py's
|
||||||
|
# MaintConfig dataclass. So a dict with the key ABSENT is accepted by this
|
||||||
|
# rule at 2000 and then built by main.py at 2500 - the contradiction this
|
||||||
|
# rule exists to catch, in the one case it cannot see. It is unreachable on
|
||||||
|
# a Supervisor install and those files are another ticket's right now.
|
||||||
|
# THE REAL FIX is one source of truth for option defaults; until then the
|
||||||
|
# divergence is PINNED BY A CHECK rather than by this comment - see
|
||||||
|
# test_validate.py, "the KNOWN cross-file default divergence". It asserts
|
||||||
|
# the mismatch as it stands and goes red if EITHER side moves, including
|
||||||
|
# when somebody closes it in main.py, at which point this block and that
|
||||||
|
# check both get deleted.
|
||||||
|
charge_w = _number(opts, "maintenance_charge_w", 2000.0)
|
||||||
|
if charge_w is None:
|
||||||
|
problems.append(
|
||||||
|
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
|
||||||
|
elif max_w is not None and max_w > 0 and charge_w > max_w:
|
||||||
|
problems.append(
|
||||||
|
f"maintenance_charge_w is {charge_w:.0f} W but max_w is "
|
||||||
|
f"{max_w:.0f} W, so a maintenance charge in a cheap window can "
|
||||||
|
"never happen: the shaping asks the arbiter for at most "
|
||||||
|
f"{-charge_w:.0f} W while the supervised limit allows no less than "
|
||||||
|
f"{-max_w:.0f} W, which is a contradiction it resolves to 0 W - "
|
||||||
|
"THE CEILING WINS, and what you would see is an ARBITER "
|
||||||
|
"CONTRADICTION line that reads like a bug rather than like these "
|
||||||
|
f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, "
|
||||||
|
f"or raise max_w to at least {charge_w:.0f} W.")
|
||||||
|
|
||||||
|
# --- warnings: real, but not worth refusing over -------------------------
|
||||||
|
integ = opts.get("integrator_max_w")
|
||||||
|
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
||||||
|
if integ_w is not None and max_w is not None and integ_w > max_w:
|
||||||
|
_LOG.warning(
|
||||||
|
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
|
||||||
|
"arbiter still bounds the wire to +-max_w - but the only thing the "
|
||||||
|
"extra headroom buys is more wrong-direction power to unwind after "
|
||||||
|
"saturation. 0 (follow max_w) is the recommended value.",
|
||||||
|
integ_w, max_w)
|
||||||
|
|
||||||
|
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
||||||
|
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
|
||||||
|
# clamped by the limits above it rather than contradicted by them. So this
|
||||||
|
# drains at max_w instead of the configured rate, the arbiter says so in
|
||||||
|
# words on the status page every cycle, and the cycle still completes -
|
||||||
|
# slower, and eating into maintenance.DRAIN_TIMEOUT_MIN. Loud and it moves
|
||||||
|
# energy, so it is neither (a) nor (b): warned, not refused.
|
||||||
|
disch_w = _number(opts, "maintenance_discharge_w", 2000.0)
|
||||||
|
if disch_w is not None and max_w is not None and max_w > 0 and disch_w > max_w:
|
||||||
|
_LOG.warning(
|
||||||
|
"maintenance_discharge_w %.0f W is above max_w %.0f W. Not refused - "
|
||||||
|
"the drain phase SETS the setpoint and the arbiter clamps it to "
|
||||||
|
"%.0f W and names the clamp ('limited by supervised(max_w)'), so "
|
||||||
|
"nothing is silent and the cycle still completes. But it drains at "
|
||||||
|
"%.0f W, not the %.0f W you configured, and the drain phase has a "
|
||||||
|
"720 min timeout. Lower it to %.0f W or raise max_w to remove the "
|
||||||
|
"surprise.",
|
||||||
|
disch_w, max_w, max_w, max_w, disch_w, max_w)
|
||||||
|
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _number(opts: dict, key, default):
|
||||||
|
raw = opts.get(key, default)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def refuse(problems: list[str]) -> None:
|
||||||
|
"""Log a refusal an operator can tell apart from a crash. Does not raise.
|
||||||
|
|
||||||
|
⚠️ THE POINT OF THE BANNER. "You configured this wrong" must not read like
|
||||||
|
"it broke". A Python traceback in an add-on log means the same thing to most
|
||||||
|
operators as a segfault - file a bug - whereas this block names the keys,
|
||||||
|
names the fix, and says in words that nothing is broken. main.py catches
|
||||||
|
ConfigRefused and exits 78 (sysexits EX_CONFIG), so no traceback is printed
|
||||||
|
at all and even the exit status distinguishes the two.
|
||||||
|
"""
|
||||||
|
_LOG.error("=" * 72)
|
||||||
|
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
|
||||||
|
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
|
||||||
|
_LOG.error("the add-on will start as soon as the settings below are fixed.")
|
||||||
|
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
|
||||||
|
_LOG.error("Configuration, then start the add-on again.")
|
||||||
|
_LOG.error("-" * 72)
|
||||||
|
for i, problem in enumerate(problems, 1):
|
||||||
|
_LOG.error(" %d. %s", i, problem)
|
||||||
|
_LOG.error("=" * 72)
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""SAFETY-02 - the sign-disagreement watchdog.
|
||||||
|
|
||||||
|
WHAT IT CATCHES, in one sentence: a grid-following loop that is pushing power in
|
||||||
|
the wrong direction, which every other guard in this system is blind to.
|
||||||
|
|
||||||
|
The stale-input watchdog (SAFETY-01) asks "is the meter still talking?". The
|
||||||
|
saturation freeze asks "can the inverter follow?". The output clamp asks "is the
|
||||||
|
number too big?". A loop can pass all three and still be charging the battery
|
||||||
|
out of the grid while the house imports - the meter is fresh, the inverter is
|
||||||
|
tracking beautifully, and the magnitude is inside the rating. The only thing
|
||||||
|
that separates that from healthy operation is the SIGN.
|
||||||
|
|
||||||
|
⚠️ THIS IS NOT A HYPOTHETICAL. On 2026-08-20 the vendor controller (Illusmart)
|
||||||
|
did exactly this four times in one day (NOTES.md §4.7). Both captures of that
|
||||||
|
bus are committed - `sim/scenarios/bus-2026-08-20.log.gz` and the healthy
|
||||||
|
`bus-2026-08-23.log.gz` in the infra repo - and `sim/replay_sign.py` drives this
|
||||||
|
module over both of them. Every number in this file was measured on those two
|
||||||
|
days, not chosen:
|
||||||
|
|
||||||
|
day samples gated in sign agreement
|
||||||
|
2026-08-23 4 212 / 17 280 93.4 % (healthy)
|
||||||
|
2026-08-20 7 182 / 17 280 56.4 % (the whole fault day)
|
||||||
|
cluster A 15:26-16:43 0.3 %
|
||||||
|
cluster B 17:11-18:28 0.9 %
|
||||||
|
cluster C 20:01-20:31 10.5 %
|
||||||
|
cluster D 20:55-21:11 0.0 %
|
||||||
|
|
||||||
|
⚠️ THOSE AGREEMENT FIGURES ARE THE WEAKEST EVIDENCE ON THIS PAGE, and an earlier
|
||||||
|
version of this comment leaned on them as though they were the strongest. The
|
||||||
|
clusters are found by looking for sustained opposed samples, so measuring "how
|
||||||
|
opposed is this cluster" over them is close to a tautology - and "the healthy day
|
||||||
|
contains no such cluster" is the same sentence as "the healthy day produces no
|
||||||
|
trip", not independent corroboration of it.
|
||||||
|
|
||||||
|
What actually carries the argument is two things neither the detector nor the
|
||||||
|
clustering assumes:
|
||||||
|
|
||||||
|
1. The RUN-LENGTH DISTRIBUTION. The longest unbroken opposed run on the healthy
|
||||||
|
day is 30 s. On the fault day there are 18 runs over 180 s and the longest is
|
||||||
|
1750 s. Nothing about that gap was chosen - it is what the two days measure.
|
||||||
|
2. The clusters land where a human independently wrote down onsets. NOTES.md
|
||||||
|
§4.7 records 15:01, 16:56, 19:57 and 20:23 from watching it happen, before
|
||||||
|
any of this existed, and cluster D coincides with the separately documented
|
||||||
|
inverter-off runaway of §4.7 item 2.
|
||||||
|
|
||||||
|
Be precise about (2), because the convenient overstatement is available and wrong:
|
||||||
|
the clustering does NOT reproduce four episodes. It yields THREE clusters over the
|
||||||
|
four documented onsets - C spans both 19:57 and 20:23 - plus a fourth for §4.7
|
||||||
|
item 2, which is not one of the four. Every trip does fall inside a documented
|
||||||
|
window, which is the claim worth making.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHY IT LIVES HERE AND NOT IN THE FIRMWARE OR THE WITNESS
|
||||||
|
|
||||||
|
Three vantage points see three different things. The firmware knows what it
|
||||||
|
WROTE, the witness Pi knows what was ON THE WIRE and whether the inverter ACKed
|
||||||
|
it, and the add-on knows what it INTENDED and holds the authoritative meter.
|
||||||
|
|
||||||
|
This check is not command-versus-wire; it is command-versus-physics. It needs
|
||||||
|
the emitted setpoint and the P1 net figure in the same place, and it needs the
|
||||||
|
authority to command zero when they fight:
|
||||||
|
|
||||||
|
- the witness is read-only by charter and cannot command anything, so it could
|
||||||
|
raise the alarm but never take the trip action the AC requires;
|
||||||
|
- the firmware has the wire but not the meter. §5.1 makes P1 the only
|
||||||
|
authoritative grid figure and the board only ever receives its AGE
|
||||||
|
(`sensor.goodwe_p1_sample_age`), never its value. Putting the check there
|
||||||
|
would mean shipping the meter to the ESP32 for no other reason, and it would
|
||||||
|
be C++ per install rather than one generic guard;
|
||||||
|
- the add-on has both halves already, plus an arbiter that can outrank every
|
||||||
|
strategy with a P_SAFETY claim.
|
||||||
|
|
||||||
|
The cost of standing here is real and worth stating: this watchdog compares what
|
||||||
|
we MEANT to put on the wire against the meter. If the firmware or the driver
|
||||||
|
inverts the sign on the way out, the add-on's own view still agrees with itself
|
||||||
|
and this check stays silent. That gap is a wire-versus-intent problem and it
|
||||||
|
belongs to the witness, not here.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHAT "DISAGREEMENT" MEANS, PRECISELY
|
||||||
|
|
||||||
|
disagree = following
|
||||||
|
and commanded_w * grid_w < 0
|
||||||
|
and abs(commanded_w) > min_w
|
||||||
|
and abs(grid_w) > min_w
|
||||||
|
|
||||||
|
Four gates, and each one exists to exclude a specific thing that is NOT a fault:
|
||||||
|
|
||||||
|
1. `following`. ⚠️ THE GATE THAT IS EASIEST TO FORGET AND MOST EXPENSIVE TO
|
||||||
|
OMIT. Sign agreement is a property of a GRID-FOLLOWING loop. It is not
|
||||||
|
expected of a strategy that deliberately commands against the meter -
|
||||||
|
maintenance is exactly that. A maintenance grid charge of 1500 W into a house
|
||||||
|
drawing 500 W puts +2000 W on the meter against a -1500 W command: opposed,
|
||||||
|
both far over any sane threshold, and sustained for HOURS. Armed
|
||||||
|
unconditionally this watchdog would trip on every single maintenance cycle,
|
||||||
|
which is not a tuning problem, it is the wrong question being asked. So the
|
||||||
|
caller says whether the grid-following loop actually won the arbitration this
|
||||||
|
cycle, and the timer only accumulates when it did.
|
||||||
|
|
||||||
|
⚠️ 1500 W, NOT THE DEFAULT 2500 W, AND THE REASON IS NOT COSMETIC. At the
|
||||||
|
shipped defaults `maintenance_charge_w` is 2500 and `max_w` is 2000, so the
|
||||||
|
cheap-window claim is `hi = -2500` against a supervised `lo = -2000`: lo > hi,
|
||||||
|
which arbiter.py resolves as a CONTRADICTION and 0 W, not as a -2500 W charge.
|
||||||
|
The example above is written at 1500 W because that is a combination the
|
||||||
|
schema and the arbiter both actually produce. (The contradiction at the
|
||||||
|
defaults is a real pre-existing bug - a default maintenance charge silently
|
||||||
|
never happens in a cheap window - but it is maintenance's, not this module's,
|
||||||
|
and it is filed separately. Do not "fix" it here.)
|
||||||
|
|
||||||
|
2. The strict `< 0` product. "Opposed" and "neither side is zero" in one
|
||||||
|
expression.
|
||||||
|
|
||||||
|
3. The 300 W magnitude gate ON BOTH SIDES. Near zero the sign is noise, and near
|
||||||
|
zero is where a healthy loop LIVES - the deadband holds the command still
|
||||||
|
while the meter wanders either side of `target_grid_w`. On the healthy day
|
||||||
|
only 4 212 of 17 280 samples (24 %) are even gated in; the other 76 % are the
|
||||||
|
converged band where sign carries no information. This is also why the
|
||||||
|
deadband cannot produce a false trip: inside it the meter is by definition
|
||||||
|
under 300 W.
|
||||||
|
|
||||||
|
4. The 180 s continuity requirement. This is what excludes the legitimate
|
||||||
|
transient. A load step reverses the meter's sign instantly while the command
|
||||||
|
is still where the last cycle left it; slew limiting then needs a few cycles
|
||||||
|
to cross over. Measured on the healthy day, the LONGEST such crossing is 30 s
|
||||||
|
- and that is with a vendor controller far cruder than ours. 180 s is six
|
||||||
|
times that. It is not a fitted threshold: at 60 s the healthy day still
|
||||||
|
produces zero trips, and at 300 s the fault day still produces 13.
|
||||||
|
|
||||||
|
⚠️ A BATTERY THAT REFUSES A COMMAND CANNOT TRIP THIS, and that is structural
|
||||||
|
rather than lucky. At SoC 10 % in Standby, a 1350 W discharge command into a
|
||||||
|
1350 W import is DECLINED: the battery does nothing, so the meter stays at
|
||||||
|
+1350 W and the command stays at +1350 W. Same sign. Agreement. A refusal leaves
|
||||||
|
the meter where it was; only the inverter ACTING moves it, and acting in the
|
||||||
|
commanded direction moves it toward zero, not through it. The failure mode this
|
||||||
|
check keys on is the opposite of a refusal - it is the inverter obediently doing
|
||||||
|
a wrong thing.
|
||||||
|
|
||||||
|
⚠️ A FROZEN METER cannot trip it either, for the same reason read the other way:
|
||||||
|
a stuck reading of +800 W makes the loop command discharge and keep commanding
|
||||||
|
discharge. Command and meter converge on AGREEING. Freezes are SAFETY-12's and
|
||||||
|
SAFETY-01's problem, and this watchdog neither helps nor hinders there.
|
||||||
|
|
||||||
|
⚠️ ALL OF THAT DEPENDS ON ONE PROPERTY OF THE CONTROL LAW, so it is an invariant
|
||||||
|
and not a happy accident: the saturation freeze in control.py must keep letting
|
||||||
|
the command FALL, CROSS ZERO AND REVERSE - "may not wind further in the direction
|
||||||
|
it is already pushing", not "only corrections that shrink |i_w|". control.py
|
||||||
|
records the shrink-only encoding as tried and rejected because it pins the command
|
||||||
|
at its last value indefinitely; a pinned command against a meter that has moved on
|
||||||
|
is precisely a sustained sign disagreement, so under that encoding this watchdog
|
||||||
|
WOULD false-trip on a plain load step. If anyone ever revisits that freeze, this
|
||||||
|
module's no-false-positive argument is downstream of the answer.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHAT IT DOES NOT CATCH
|
||||||
|
|
||||||
|
An inverter that ACTS opposite to what it was told. Tell it to discharge 2000 W
|
||||||
|
and have it charge instead, and the meter moves FURTHER the way it was already
|
||||||
|
going - so the command and the meter agree, all the way into the rail. This
|
||||||
|
watchdog compares the loop's output direction against the input state it was
|
||||||
|
regulating; it is blind to the plant lying about which way it went. That is the
|
||||||
|
saturation freeze's question (|command - the battery's actual power| > 500 W,
|
||||||
|
NOTES.md §4.7 row 4), and the two checks are complementary rather than
|
||||||
|
overlapping. Neither substitutes for the other.
|
||||||
|
|
||||||
|
Nor does it see a sign inversion introduced DOWNSTREAM of this add-on - in the
|
||||||
|
firmware, on the wire, or in a future driver. Our view stays self-consistent and
|
||||||
|
this stays silent. That is a wire-versus-intent problem and it belongs to the
|
||||||
|
witness, which is the only vantage point that sees both.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
STATE, TRIP AND LATCH
|
||||||
|
|
||||||
|
One timestamp, not a sample counter. The cycle rate is not fixed - a meter
|
||||||
|
arrival, or `cycle_floor_s` elapsing, whichever comes first - so N samples is a
|
||||||
|
different wall-clock window on every path, and the AC is written in minutes.
|
||||||
|
|
||||||
|
The latch is not optional and not a nicety. Commanding 0 W removes the command
|
||||||
|
side of the comparison (|0| is not > 300), so the disagreement disappears the
|
||||||
|
instant the trip works. Un-latched, this watchdog would trip, clear itself, and
|
||||||
|
hand the actuator straight back to the loop that was driving the wrong way -
|
||||||
|
three minutes later, forever. `tripped` therefore stays true until an operator
|
||||||
|
`reset()`s it by starting control again, which is a deliberate human act after
|
||||||
|
looking at why.
|
||||||
|
|
||||||
|
⚠️ AND THE LATCH HAS TO OUTLIVE THE PROCESS. Held only in memory it is not a
|
||||||
|
latch at all - a container restart clears it and the add-on comes back armed,
|
||||||
|
into the same fault, unattended. Worse than merely losing state: "restart the
|
||||||
|
add-on" is the first thing an operator confronted with a FAILSAFE banner and a
|
||||||
|
0 W inverter will try, and under a supervisor that restarts a crashed add-on it
|
||||||
|
is a loop of three-minute wrong-direction bursts. `tripped` and `reason` are
|
||||||
|
therefore constructor arguments, restored from `Store` in `Controller.__init__`
|
||||||
|
and written back the moment the trip happens. See store.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SignWatchdogConfig:
|
||||||
|
"""Defaults, not constants - config.yaml carries both (SAFETY-02 AC 5)."""
|
||||||
|
|
||||||
|
min_w: float = 300.0
|
||||||
|
hold_s: float = 180.0
|
||||||
|
|
||||||
|
|
||||||
|
def disagrees(commanded_w, grid_w, following: bool,
|
||||||
|
cfg: SignWatchdogConfig) -> bool:
|
||||||
|
"""The predicate, and the ONLY place it is written down.
|
||||||
|
|
||||||
|
⚠️ Public because `sim/replay_sign.py` needs it too - to describe episodes, to
|
||||||
|
measure agreement, to sweep the clock-skew guard - and it had reimplemented it
|
||||||
|
inline. Two copies of a safety predicate is one copy plus a future
|
||||||
|
disagreement about what the check means, and the copy that drifts is always
|
||||||
|
the one in the harness that grades it.
|
||||||
|
"""
|
||||||
|
return (following
|
||||||
|
and commanded_w is not None and grid_w is not None
|
||||||
|
and commanded_w * grid_w < 0
|
||||||
|
and abs(commanded_w) > cfg.min_w
|
||||||
|
and abs(grid_w) > cfg.min_w)
|
||||||
|
|
||||||
|
|
||||||
|
def loop_is_following(resolution, loop_asked) -> bool:
|
||||||
|
"""Did the grid-following loop actually choose the number going on the wire?
|
||||||
|
|
||||||
|
Gate 1 of the predicate above, and it lives here rather than inline in
|
||||||
|
`cycle()` for the reason `disagrees()` does: the test suite has to be able to
|
||||||
|
assert on THE SHIPPED EXPRESSION, not on a transcription of it that drifts
|
||||||
|
apart from it in silence.
|
||||||
|
|
||||||
|
⚠️ IT WAS A TRANSCRIPTION, AND THAT WAS MEASURED, NOT FEARED. The P_PEAK case
|
||||||
|
in test_watchdog.py exists to make whoever lands peak shaving come here and
|
||||||
|
change an expectation. With the expression copied into the test it did not do
|
||||||
|
that: editing the shipped gate alone to `winner in ("loop", "peak")` left every
|
||||||
|
suite green while the watchdog went blind for the whole of every peak event.
|
||||||
|
One definition, two callers, and that test now bites.
|
||||||
|
|
||||||
|
`resolution` the arbiter's Resolution. Duck-typed on `.winner` and
|
||||||
|
`.target_w` so this module still imports nothing from arbiter.
|
||||||
|
`loop_asked` what the grid-following loop claimed this cycle, or None if it
|
||||||
|
did not claim at all.
|
||||||
|
|
||||||
|
"The loop won" is NOT the question, and the difference is a whole class of
|
||||||
|
false trip. A LIMIT claim can survive the loop winning and still replace its
|
||||||
|
number: maintenance's cheap-window shaping is `hi = -maintenance_charge_w`, so
|
||||||
|
at 1500 W the loop can ask for +500 W of discharge, win the arbitration, and
|
||||||
|
have the emitted value come out at -1500 W of grid charge. Winner says "loop";
|
||||||
|
the wire carries maintenance's intent.
|
||||||
|
|
||||||
|
So the test is on the NUMBER: the loop is following only while the emitted
|
||||||
|
value points the way the loop asked AND is no larger than it asked - limits
|
||||||
|
reduced it, they did not redirect it. The magnitude half is load-bearing on
|
||||||
|
its own, because a `lo` limit can raise magnitude without flipping sign, and
|
||||||
|
that is not the loop following either. Zero on either side falls out as
|
||||||
|
not-following, which costs nothing: a 0 W command cannot be a
|
||||||
|
wrong-direction command.
|
||||||
|
|
||||||
|
⚠️ FORWARD HAZARD, and the claim block in cycle() already anticipates it.
|
||||||
|
`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, so the gate costs nothing. A peak-shaving strategy claiming SET at
|
||||||
|
P_PEAK **is** grid-following in sign, and the moment it lands this goes blind
|
||||||
|
for the duration of every peak event. Widening it means asking "does the
|
||||||
|
winning strategy follow the meter", not adding another name to a comparison.
|
||||||
|
"""
|
||||||
|
return (resolution.winner == "loop" and loop_asked is not None
|
||||||
|
and resolution.target_w * loop_asked > 0
|
||||||
|
and abs(resolution.target_w) <= abs(loop_asked))
|
||||||
|
|
||||||
|
|
||||||
|
class SignWatchdog:
|
||||||
|
"""One instance per actuator, evaluated once per control cycle.
|
||||||
|
|
||||||
|
⚠️ NOT SHARED ACROSS CADENCES. `disagree_since` is a wall-clock start time,
|
||||||
|
so driving one instance from two loops at two rates would have each one
|
||||||
|
resetting the other's window. If a fast on-device path ever wants this
|
||||||
|
check, it gets its own instance - the same exactly-one-writer discipline the
|
||||||
|
arbiter applies to the actuator, applied to the detector's own state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg: SignWatchdogConfig | None = None, *,
|
||||||
|
tripped: bool = False, reason: str = ""):
|
||||||
|
self.cfg = cfg or SignWatchdogConfig()
|
||||||
|
self.disagree_since: datetime | None = None
|
||||||
|
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED. A latch that lives only in
|
||||||
|
# memory is not a latch: a container restart clears it, and the add-on
|
||||||
|
# comes back armed, into the same fault, with no operator involved. See
|
||||||
|
# store.py's note on `sign_tripped` for the measured behaviour.
|
||||||
|
self.tripped = bool(tripped)
|
||||||
|
self.reason = reason if self.tripped else ""
|
||||||
|
|
||||||
|
# -- the whole check, and it is a pure function of its arguments ----------
|
||||||
|
def evaluate(self, commanded_w, grid_w, following: bool,
|
||||||
|
now: datetime) -> bool:
|
||||||
|
"""Advance the detector one cycle. Returns True while tripped.
|
||||||
|
|
||||||
|
`commanded_w` the EMITTED setpoint - what is standing on the wire now,
|
||||||
|
after deadband, slew, quantisation and the arbiter. Not
|
||||||
|
the optimizer's raw wish: the wire carries the emitted
|
||||||
|
value, so anything else can disagree with the meter for a
|
||||||
|
reason that is not a fault.
|
||||||
|
`grid_w` net P1, + = importing. None when the meter is stale or
|
||||||
|
missing, which CLEARS the timer rather than accumulating
|
||||||
|
- a frozen figure held against a moving command would
|
||||||
|
otherwise manufacture a disagreement in about three
|
||||||
|
minutes, on top of SAFETY-01 having already commanded
|
||||||
|
zero for the same cause.
|
||||||
|
`following` did the grid-following loop actually win this cycle? See
|
||||||
|
gate 1 in the module docstring.
|
||||||
|
`now` the caller's clock, so this stays testable and replayable.
|
||||||
|
"""
|
||||||
|
if self.tripped:
|
||||||
|
# Latched. Do not re-evaluate: the trip removed the evidence.
|
||||||
|
return True
|
||||||
|
|
||||||
|
cfg = self.cfg
|
||||||
|
if not disagrees(commanded_w, grid_w, following, cfg):
|
||||||
|
self.disagree_since = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.disagree_since is None:
|
||||||
|
self.disagree_since = now
|
||||||
|
return False
|
||||||
|
|
||||||
|
held = (now - self.disagree_since).total_seconds()
|
||||||
|
if held < cfg.hold_s:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.tripped = True
|
||||||
|
self.reason = (
|
||||||
|
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against a meter "
|
||||||
|
f"reading {grid_w:+.0f} W for {held:.0f} s continuously "
|
||||||
|
f"(limits: both over {cfg.min_w:.0f} W, held over {cfg.hold_s:.0f} s)"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def held_s(self, now: datetime) -> float:
|
||||||
|
"""How long the current disagreement has been standing, for the UI.
|
||||||
|
|
||||||
|
⚠️ Takes the clock rather than reading one. It was a property calling
|
||||||
|
`datetime.now()`, which quietly broke the property this module's header
|
||||||
|
advertises - every other observable here is a pure function of what it
|
||||||
|
was handed - and an unclocked function is one nothing can test, which is
|
||||||
|
exactly what happened to it.
|
||||||
|
"""
|
||||||
|
if self.disagree_since is None:
|
||||||
|
return 0.0
|
||||||
|
return (now - self.disagree_since).total_seconds()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.disagree_since = None
|
||||||
|
self.tripped = False
|
||||||
|
self.reason = ""
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: GoodWe RS485 Controller
|
name: GoodWe RS485 Controller
|
||||||
version: "0.3.0"
|
version: "0.5.1"
|
||||||
slug: goodwe_controller
|
slug: goodwe_controller
|
||||||
description: >-
|
description: >-
|
||||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||||
@@ -42,11 +42,24 @@ options:
|
|||||||
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||||
# `off` keeps the original single meter_entity path above, so an existing
|
# `off` keeps the original single meter_entity path above, so an existing
|
||||||
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
||||||
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below.
|
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
|
||||||
|
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
|
||||||
|
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
|
||||||
|
# homewizard_local skips Home Assistant entirely and polls the meter's own
|
||||||
|
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
|
||||||
|
# the meter REPORTED rather than when the value last CHANGED - HA emits
|
||||||
|
# nothing at all for a repeated reading - so it is the only mode a firmware
|
||||||
|
# watchdog may threshold. See DOCS.md.
|
||||||
meter_source: "off"
|
meter_source: "off"
|
||||||
meter_phases: 1
|
meter_phases: 1
|
||||||
meter_max_age_s: 30
|
meter_max_age_s: 30
|
||||||
meter_mqtt_topic: ""
|
meter_mqtt_topic: ""
|
||||||
|
# homewizard_local only. The meter's own address, `host` or `host:port`.
|
||||||
|
p1_host: ""
|
||||||
|
# homewizard_local only. Seconds between polls. Must be well under
|
||||||
|
# meter_max_age_s - the age is never fresher than this interval - and there is
|
||||||
|
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
|
||||||
|
meter_poll_s: 5
|
||||||
# The two UNSIGNED Belgian registers. The EMS derives net power from them
|
# The two UNSIGNED Belgian registers. The EMS derives net power from them
|
||||||
# (import - export); do NOT point these at a signed template sensor.
|
# (import - export); do NOT point these at a signed template sensor.
|
||||||
p1_import_entity: ""
|
p1_import_entity: ""
|
||||||
@@ -55,6 +68,15 @@ options:
|
|||||||
# three-phase connection; the list length must equal meter_phases.
|
# three-phase connection; the list length must equal meter_phases.
|
||||||
p1_phase_import_entities: []
|
p1_phase_import_entities: []
|
||||||
p1_phase_export_entities: []
|
p1_phase_export_entities: []
|
||||||
|
# ha_signed only. ONE signed net-power sensor: positive = import from the
|
||||||
|
# grid, negative = export to it. Do NOT split it into two template sensors -
|
||||||
|
# the split is done in the add-on (p1.split_signed) precisely so the sign
|
||||||
|
# convention is tested rather than living in unreviewed YAML.
|
||||||
|
p1_net_entity: ""
|
||||||
|
# Optional, in L1..L3 order, each one signed the same way. Same role as
|
||||||
|
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
|
||||||
|
# connection. The list length must equal meter_phases.
|
||||||
|
p1_phase_net_entities: []
|
||||||
|
|
||||||
# --- control ---------------------------------------------------------------
|
# --- control ---------------------------------------------------------------
|
||||||
max_w: 2000
|
max_w: 2000
|
||||||
@@ -68,14 +90,51 @@ options:
|
|||||||
integrator_max_w: 0
|
integrator_max_w: 0
|
||||||
heartbeat_s: 10
|
heartbeat_s: 10
|
||||||
stale_input_s: 15
|
stale_input_s: 15
|
||||||
|
cycle_floor_s: 15
|
||||||
auto_start: false
|
auto_start: false
|
||||||
|
|
||||||
|
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
|
||||||
|
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
||||||
|
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
||||||
|
# a failsafe that latches until an operator starts control again.
|
||||||
|
#
|
||||||
|
# Both defaults are measured, not chosen. On the captured healthy day
|
||||||
|
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
||||||
|
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
||||||
|
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
||||||
|
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
||||||
|
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
||||||
|
# fault day still trips 13 times. There is no reason to move either number
|
||||||
|
# without a capture that says so. They are options because the AC says
|
||||||
|
# thresholds are defaults rather than constants - not because they are knobs
|
||||||
|
# anyone is expected to turn.
|
||||||
|
#
|
||||||
|
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
||||||
|
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
||||||
|
sign_disagree_w: 300
|
||||||
|
sign_disagree_s: 180
|
||||||
|
|
||||||
# --- maintenance -----------------------------------------------------------
|
# --- maintenance -----------------------------------------------------------
|
||||||
maintenance_enabled: false
|
maintenance_enabled: false
|
||||||
maintenance_interval_days: 28
|
maintenance_interval_days: 28
|
||||||
maintenance_start_hour: 10
|
maintenance_start_hour: 10
|
||||||
maintenance_discharge_w: 2500
|
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||||
maintenance_charge_w: 2500
|
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||||
|
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
||||||
|
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
||||||
|
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
||||||
|
# default install. validate.py refuses that config (rule 6), so these
|
||||||
|
# defaults had to become self-consistent with the max_w above rather than
|
||||||
|
# ship a configuration the validator rejects.
|
||||||
|
#
|
||||||
|
# The discharge side is clamped rather than contradicted - it drains at max_w
|
||||||
|
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
||||||
|
# symmetry and to keep the shipped config warning-free.
|
||||||
|
#
|
||||||
|
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
||||||
|
# faster maintenance cycle. Raise max_w FIRST, then these.
|
||||||
|
maintenance_discharge_w: 2000
|
||||||
|
maintenance_charge_w: 2000
|
||||||
maintenance_soc_floor: 11
|
maintenance_soc_floor: 11
|
||||||
maintenance_soc_target: 99
|
maintenance_soc_target: 99
|
||||||
maintenance_hold_min: 120
|
maintenance_hold_min: 120
|
||||||
@@ -98,20 +157,25 @@ schema:
|
|||||||
batt_invert: bool
|
batt_invert: bool
|
||||||
setpoint_entity: str
|
setpoint_entity: str
|
||||||
|
|
||||||
meter_source: list(off|ha_dsmr|mqtt_p1)
|
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
||||||
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
||||||
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
||||||
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
||||||
# quietly wrong number.
|
# quietly wrong number.
|
||||||
meter_phases: int(1,3)
|
meter_phases: int(1,3)
|
||||||
meter_max_age_s: int(5,300)
|
meter_max_age_s: int(5,300)
|
||||||
|
meter_poll_s: int(1,60)
|
||||||
meter_mqtt_topic: str?
|
meter_mqtt_topic: str?
|
||||||
|
p1_host: str?
|
||||||
p1_import_entity: str?
|
p1_import_entity: str?
|
||||||
p1_export_entity: str?
|
p1_export_entity: str?
|
||||||
p1_phase_import_entities:
|
p1_phase_import_entities:
|
||||||
- str
|
- str
|
||||||
p1_phase_export_entities:
|
p1_phase_export_entities:
|
||||||
- str
|
- str
|
||||||
|
p1_net_entity: str?
|
||||||
|
p1_phase_net_entities:
|
||||||
|
- str
|
||||||
|
|
||||||
max_w: int(100,5000)
|
max_w: int(100,5000)
|
||||||
gain: float(0.05,1.0)
|
gain: float(0.05,1.0)
|
||||||
@@ -124,8 +188,14 @@ schema:
|
|||||||
integrator_max_w: int(0,15000)
|
integrator_max_w: int(0,15000)
|
||||||
heartbeat_s: int(2,25)
|
heartbeat_s: int(2,25)
|
||||||
stale_input_s: int(5,120)
|
stale_input_s: int(5,120)
|
||||||
|
cycle_floor_s: int(5,60)
|
||||||
auto_start: bool
|
auto_start: bool
|
||||||
|
|
||||||
|
# No "off" value, deliberately: this is a P0 safety check and the schema is
|
||||||
|
# not the place to make disarming it a supported configuration.
|
||||||
|
sign_disagree_w: int(50,5000)
|
||||||
|
sign_disagree_s: int(30,1800)
|
||||||
|
|
||||||
maintenance_enabled: bool
|
maintenance_enabled: bool
|
||||||
maintenance_interval_days: int(1,90)
|
maintenance_interval_days: int(1,90)
|
||||||
maintenance_start_hour: int(0,23)
|
maintenance_start_hour: int(0,23)
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Runnable check for the control loop's cadence. `python3 test_loop.py`
|
||||||
|
|
||||||
|
SAFETY-12: the loop must evaluate on a clock as well as on a change. Before the
|
||||||
|
fix, `run_control()` ran `cycle()` only when the meter value differed from the
|
||||||
|
previous poll, so a meter that repeats a value ran NO control cycles while the
|
||||||
|
heartbeat kept re-emitting the last command - measured on the rig during TEL-05
|
||||||
|
as 70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||||||
|
|
||||||
|
⚠️ THIS TEST TAKES ~95 SECONDS OF WALL CLOCK (measured 93 s), on purpose, and
|
||||||
|
that figure is the only thing standing between it and a future developer who
|
||||||
|
decides a slow test is a broken one. The thing under test is a wall-clock floor
|
||||||
|
inside a loop that sleeps 1 s; mocking the clock would leave the assertion
|
||||||
|
testing the mock. It drives the real `run_control()` and counts what actually
|
||||||
|
happened. Eight runs of the real loop is what the eight scenarios cost.
|
||||||
|
|
||||||
|
⚠️ If you shorten the windows, re-measure and update this number and the one in
|
||||||
|
HANDOVER.md. An understated runtime reads as a hang.
|
||||||
|
|
||||||
|
`cycle_floor_s` is read from options with no lower bound in code, so a huge
|
||||||
|
value reproduces the pre-fix behaviour exactly. Every claim below is therefore
|
||||||
|
an A/B on one binary, not a comparison against a remembered old one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.main import Controller
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
NEVER = 1e9 # cycle_floor_s so large the floor can never elapse: pre-fix
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHass:
|
||||||
|
"""A scripted meter and a plant that tracks the command instantly."""
|
||||||
|
|
||||||
|
def __init__(self, meter, soc_dies_at=None):
|
||||||
|
self.meter = meter
|
||||||
|
self.soc_dies_at = soc_dies_at
|
||||||
|
self.batt = 0.0
|
||||||
|
self.writes = []
|
||||||
|
self.t0 = time.monotonic()
|
||||||
|
|
||||||
|
async def limits(self, entity):
|
||||||
|
return (-5000.0, 5000.0, 1.0)
|
||||||
|
|
||||||
|
async def number(self, entity, invert=False):
|
||||||
|
now = time.monotonic() - self.t0
|
||||||
|
if entity == "sensor.grid":
|
||||||
|
return self.meter(now)
|
||||||
|
if entity == "sensor.batt":
|
||||||
|
return self.batt
|
||||||
|
if entity == "sensor.soc":
|
||||||
|
if self.soc_dies_at is not None and now >= self.soc_dies_at:
|
||||||
|
return None
|
||||||
|
return 50.0
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_number(self, entity, value):
|
||||||
|
self.writes.append(float(value))
|
||||||
|
self.batt = float(value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
def set(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Pub:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
def publish(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 1000,
|
||||||
|
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
|
||||||
|
"heartbeat_s": 10, "stale_input_s": 15}
|
||||||
|
|
||||||
|
|
||||||
|
def drive(meter, floor, seconds, soc_dies_at=None, **over):
|
||||||
|
"""Run the real control loop for `seconds`; return (cycle times, writes)."""
|
||||||
|
async def go():
|
||||||
|
hass = FakeHass(meter, soc_dies_at)
|
||||||
|
c = Controller(dict(BASE, cycle_floor_s=floor, **over), hass, _Store(),
|
||||||
|
_Pub(), stop=asyncio.Event())
|
||||||
|
c.auto = True
|
||||||
|
stamps = []
|
||||||
|
real = c.cycle
|
||||||
|
|
||||||
|
async def counted():
|
||||||
|
stamps.append(time.monotonic() - hass.t0)
|
||||||
|
await real()
|
||||||
|
|
||||||
|
c.cycle = counted
|
||||||
|
task = asyncio.create_task(c.run_control())
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
c.stopping = True
|
||||||
|
await asyncio.wait_for(task, timeout=10)
|
||||||
|
return stamps, hass.writes, c.target, c.reason
|
||||||
|
return asyncio.run(go())
|
||||||
|
|
||||||
|
|
||||||
|
def worst_gap(stamps, seconds):
|
||||||
|
"""Longest stretch with no cycle, including the tail after the last one."""
|
||||||
|
edges = [0.0] + list(stamps) + [seconds]
|
||||||
|
return max(b - a for a, b in zip(edges, edges[1:]))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("a meter that repeats the same value")
|
||||||
|
FLAT = lambda t: 350.0 # noqa: E731
|
||||||
|
FLOOR, SECONDS = 3.0, 12.0
|
||||||
|
|
||||||
|
pre_stamps, pre_writes, _, _ = drive(FLAT, NEVER, SECONDS)
|
||||||
|
# The defect itself, demonstrated rather than described: one cycle at startup
|
||||||
|
# and then nothing for the rest of the window, while writes keep happening.
|
||||||
|
check("pre-fix: a constant meter runs exactly one cycle, at startup",
|
||||||
|
len(pre_stamps) == 1)
|
||||||
|
check("...while the heartbeat keeps writing anyway, so nothing looks wrong",
|
||||||
|
len(pre_writes) >= 2)
|
||||||
|
|
||||||
|
post_stamps, post_writes, _, _ = drive(FLAT, FLOOR, SECONDS)
|
||||||
|
check("post-fix: the same constant meter runs cycles throughout",
|
||||||
|
len(post_stamps) >= int(SECONDS / FLOOR) - 1)
|
||||||
|
# The actual acceptance criterion: a BOUND on the gap, not a count.
|
||||||
|
gap = worst_gap(post_stamps, SECONDS)
|
||||||
|
check(f"...with no gap longer than the floor ({gap:.1f}s <= {FLOOR + 1.5:.1f}s)",
|
||||||
|
gap <= FLOOR + 1.5)
|
||||||
|
check("...and the loop actually corrected, instead of holding a stale command",
|
||||||
|
len(post_writes) > len(pre_writes))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("a meter that changes normally - nothing may move")
|
||||||
|
# ⚠️ THE REGRESSION GUARD, and the more important half. The floor is a deadline
|
||||||
|
# on the gap between cycles, reset by every cycle whichever trigger fired - not
|
||||||
|
# a second scheduler. On a healthy meter it must never elapse, so the cycle
|
||||||
|
# count, the write count and the commanded values must be IDENTICAL either side
|
||||||
|
# of the fix. Anything else is a new write cadence, which SAFETY-12 forbids:
|
||||||
|
# the deadband, step_w and the 10 s force-write decide what reaches the wire,
|
||||||
|
# and this change is only allowed to alter when the loop thinks.
|
||||||
|
STEPS = [0.0, 300.0, 850.0, -400.0, 120.0, -900.0, 60.0]
|
||||||
|
LIVE = lambda t: STEPS[int(t // 2) % len(STEPS)] # noqa: E731
|
||||||
|
|
||||||
|
a_stamps, a_writes, _, _ = drive(LIVE, NEVER, 12.0)
|
||||||
|
b_stamps, b_writes, _, _ = drive(LIVE, FLOOR, 12.0)
|
||||||
|
check("a changing meter runs the same number of cycles as before the fix",
|
||||||
|
len(a_stamps) == len(b_stamps))
|
||||||
|
check("...commanding exactly the same sequence of values", a_writes == b_writes)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("a meter parked at the target - the converged operating point")
|
||||||
|
# The case TEL-05 warned about: at the converged target the power figure
|
||||||
|
# legitimately repeats for minutes. The loop must still run, and must still
|
||||||
|
# write nothing but the heartbeat, because the deadband - not the arrival
|
||||||
|
# trigger - is what holds the command still.
|
||||||
|
AT_TARGET = lambda t: -10.0 # noqa: E731
|
||||||
|
c_stamps, c_writes, _, _ = drive(AT_TARGET, NEVER, 12.0)
|
||||||
|
d_stamps, d_writes, _, _ = drive(AT_TARGET, FLOOR, 12.0)
|
||||||
|
check("the loop evaluates at the converged point too", len(d_stamps) > len(c_stamps))
|
||||||
|
check("...and the deadband still holds the command, so writes are unchanged",
|
||||||
|
d_writes == c_writes)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("an input dies while the meter repeats - the safety claim must still fire")
|
||||||
|
# ⚠️ THE STRONGEST PROPERTY IN SAFETY-12, and the one that makes it a safety fix
|
||||||
|
# rather than a latency fix. The `inputs missing -> 0 W` claim lives INSIDE
|
||||||
|
# cycle() (main.py, the stale_input_s branch and the P_SAFETY claim below it).
|
||||||
|
# Pre-fix, on a meter whose value happened to repeat, cycle() was never called
|
||||||
|
# again - so that claim could not fire AT ALL. The controller held its last
|
||||||
|
# command through an input outage of unbounded length with the status line still
|
||||||
|
# reading "loop -> N W".
|
||||||
|
#
|
||||||
|
# Layer 1 does not cover this either on a `meter_source: off` install: no P1 age
|
||||||
|
# sensor is published (see Controller.publish), so the firmware has nothing to
|
||||||
|
# veto on, and the heartbeat keeps its watchdog fed the whole time.
|
||||||
|
#
|
||||||
|
# stale_input_s is shortened to 3 s here so the outage resolves inside a test
|
||||||
|
# rather than inside a quarter of a minute. The mechanism is the default one.
|
||||||
|
c2_stamps, c2_writes, pre_target, pre_reason = drive(
|
||||||
|
FLAT, NEVER, 10.0, soc_dies_at=4.0, stale_input_s=3)
|
||||||
|
check("pre-fix: a dead input during a constant meter never reaches the safety claim",
|
||||||
|
pre_target != 0.0 and "safety" not in pre_reason)
|
||||||
|
check(f"...so it holds its last command instead ({pre_target:g} W, {pre_reason!r})",
|
||||||
|
pre_target == 220.0)
|
||||||
|
|
||||||
|
d2_stamps, d2_writes, post_target, post_reason = drive(
|
||||||
|
FLAT, FLOOR, 10.0, soc_dies_at=4.0, stale_input_s=3)
|
||||||
|
check("post-fix: the same outage commands 0 W", post_target == 0.0)
|
||||||
|
check(f"...through the safety claim, not by accident ({post_reason!r})",
|
||||||
|
"safety" in post_reason)
|
||||||
|
check("...and the 0 W actually reached the device", 0.0 in d2_writes)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
@@ -11,6 +11,7 @@ computes the wrong quarter-hour figure whenever the telegram cadence changes.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -18,8 +19,11 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import aiohttp # already required by app.p1, so this adds no new dependency
|
import aiohttp # already required by app.p1, so this adds no new dependency
|
||||||
|
|
||||||
from app.p1 import (
|
from app.p1 import (
|
||||||
P1Error, P1Ingest, HaDsmrSource, QuarterAverager, SOURCE_HA, SOURCE_MQTT,
|
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, HomeWizardLocalSource,
|
||||||
make_sample, parse_mqtt_payload,
|
QuarterAverager,
|
||||||
|
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD, SOURCE_MQTT,
|
||||||
|
build_source, is_enabled, make_sample, parse_homewizard, parse_mqtt_payload,
|
||||||
|
split_signed,
|
||||||
)
|
)
|
||||||
|
|
||||||
fails = []
|
fails = []
|
||||||
@@ -56,6 +60,25 @@ def raises(name, fn):
|
|||||||
fails.append(name)
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def built(src):
|
||||||
|
"""`src.build()`, with any escaping exception turned into a visible value.
|
||||||
|
|
||||||
|
⚠️ Legibility of a RED, not leniency. build() is contracted to return a bool
|
||||||
|
and to funnel every bad telegram through ingest.reject() - a guard that goes
|
||||||
|
missing (say the "is the net entity cached at all" one) makes it raise
|
||||||
|
instead. That still fails the suite, but by aborting it with a traceback at
|
||||||
|
whichever check happened to run first, which costs the next person ten
|
||||||
|
minutes deciding whether the suite is broken or the code is. Returning the
|
||||||
|
exception makes it compare unequal to True/False, so the NAMED check goes red
|
||||||
|
and says which rule died.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return src.build()
|
||||||
|
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
|
||||||
|
print(f" build() raised {type(err).__name__}: {err}")
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None):
|
def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None):
|
||||||
return make_sample(SOURCE_HA, net_import, net_export, phases=phases,
|
return make_sample(SOURCE_HA, net_import, net_export, phases=phases,
|
||||||
phase_import_w=pi, phase_export_w=pe,
|
phase_import_w=pi, phase_export_w=pe,
|
||||||
@@ -548,6 +571,726 @@ check("the averager integrated the live stream", live.averager.elapsed_s > 0.2)
|
|||||||
check("the primed cache let the first telegram build immediately",
|
check("the primed cache let the first telegram build immediately",
|
||||||
live.samples == 2 and live.last.import_w == 0.0)
|
live.samples == 2 and live.last.import_w == 0.0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed: one signed entity -> the same two magnitudes")
|
||||||
|
# The meter actually fitted at this house is a HomeWizard P1 publishing ONE
|
||||||
|
# signed sensor. ha_dsmr cannot read it - it wants two unsigned registers and
|
||||||
|
# refuses a negative one, which is every exporting telegram.
|
||||||
|
|
||||||
|
check("a positive reading is import", split_signed(1500.0) == (1500.0, 0.0))
|
||||||
|
check("a negative reading is export", split_signed(-900.0) == (0.0, 900.0))
|
||||||
|
check("zero is a balanced reading, not a missing one",
|
||||||
|
split_signed(0.0) == (0.0, 0.0))
|
||||||
|
check("exactly one magnitude is ever non-zero",
|
||||||
|
all(a == 0.0 or b == 0.0 for a, b in
|
||||||
|
(split_signed(v) for v in (-5710.0, -1.0, 0.0, 1.0, 4384.0))))
|
||||||
|
# ⚠️ The split must not change the number. A control loop steered by a value
|
||||||
|
# that was rounded or clamped on the way in is steered by a different meter.
|
||||||
|
check("the split round-trips the signed value exactly",
|
||||||
|
all(make_sample(SOURCE_HA_SIGNED, *split_signed(v), phases=1).net_w == v
|
||||||
|
for v in (-11763.0, -5710.0, -0.5, 0.0, 0.5, 775.0, 4384.0)))
|
||||||
|
|
||||||
|
raises("a non-numeric signed reading is rejected", lambda: split_signed("n/a"))
|
||||||
|
raises("a signed None is rejected, not read as zero", lambda: split_signed(None))
|
||||||
|
raises("a signed NaN is rejected", lambda: split_signed(float("nan")))
|
||||||
|
raises("a signed infinity is rejected", lambda: split_signed(float("inf")))
|
||||||
|
# ⚠️ This one matters MORE on the signed path than on the unsigned one. On
|
||||||
|
# ha_dsmr the §20 contamination is also caught by "unsigned cannot be negative";
|
||||||
|
# here 64954 arrives as a perfectly well-formed positive signed reading and the
|
||||||
|
# plausibility ceiling is the only thing standing in front of it.
|
||||||
|
raises("the 64954 signed-decode contamination is still rejected",
|
||||||
|
lambda: split_signed(64954.0))
|
||||||
|
raises("...and its negative twin too", lambda: split_signed(-64954.0))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed: the sign convention, against real captured readings")
|
||||||
|
# ⚠️ Not a datasheet claim. These are literal values out of
|
||||||
|
# sim/scenarios/ha-p1_meter_active_power-2026-08-{20,23}.json, HA recorder
|
||||||
|
# exports of sensor.p1_meter_active_power at this house, copied here rather than
|
||||||
|
# read from that repo so this file still runs on a laptop with nothing installed
|
||||||
|
# (§17). If the convention were inverted, the physics below would be absurd.
|
||||||
|
|
||||||
|
# 2026-08-23T11:46:52Z - the day's most negative reading, 13:46 local, full sun.
|
||||||
|
s = make_sample(SOURCE_HA_SIGNED, *split_signed(-5710.0), phases=1)
|
||||||
|
check("the midday PV peak (-5710 W) is EXPORT, not a 5.7 kW draw",
|
||||||
|
s.net_w == -5710.0 and s.export_w == 5710.0 and s.import_w == 0.0)
|
||||||
|
# 2026-08-19T22:00:00Z - midnight local, 20 Aug's file starts here. No sun.
|
||||||
|
s = make_sample(SOURCE_HA_SIGNED, *split_signed(775.0), phases=1)
|
||||||
|
check("the overnight base load (+775 W) is IMPORT",
|
||||||
|
s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0)
|
||||||
|
# 2026-08-20T12:20:58Z - 14:20 local, the largest export in either capture.
|
||||||
|
s = make_sample(SOURCE_HA_SIGNED, *split_signed(-11763.0), phases=1)
|
||||||
|
check("the -11763 W midday extreme is export and survives the ceiling",
|
||||||
|
s.net_w == -11763.0 and s.export_w == 11763.0)
|
||||||
|
# 2026-08-23T10:18:28Z - the largest import in the healthy capture.
|
||||||
|
s = make_sample(SOURCE_HA_SIGNED, *split_signed(4384.0), phases=1)
|
||||||
|
check("the +4384 W peak is import", s.net_w == 4384.0 and s.import_w == 4384.0)
|
||||||
|
# The whole convention in one line: night draws, midday feeds back.
|
||||||
|
check("night is positive and midday is negative, which is the convention",
|
||||||
|
split_signed(775.0)[0] > 0 and split_signed(-5710.0)[1] > 0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed transport: building a sample out of one entity state")
|
||||||
|
|
||||||
|
NET = {"net": "sensor.p1_meter_active_power", "phase_net": []}
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
sig = HaSignedSource(None, ing, NET, token="x")
|
||||||
|
|
||||||
|
check("nothing cached yet builds nothing", built(sig) is False and ing.last is None)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "1000")
|
||||||
|
check("one signed entity is a complete telegram on its own",
|
||||||
|
built(sig) is True and ing.net_w == 1000.0)
|
||||||
|
check("the sample is tagged with its own transport",
|
||||||
|
ing.last.source == SOURCE_HA_SIGNED)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "-2500")
|
||||||
|
built(sig)
|
||||||
|
check("a negative state lands as a negative net", ing.net_w == -2500.0)
|
||||||
|
|
||||||
|
before = ing.last
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "unavailable")
|
||||||
|
check("an unavailable signed entity is a parse error", ing.parse_errors == 1)
|
||||||
|
check("an unavailable entity does not build a sample", built(sig) is False)
|
||||||
|
# ⚠️ The rule the whole ticket turns on: a missing reading is MISSING. Resolving
|
||||||
|
# it to 0 W would read as a perfectly balanced house and defeat the staleness
|
||||||
|
# trigger that FW-01's watchdog is built on.
|
||||||
|
check("an unavailable entity leaves the last good sample untouched, not 0 W",
|
||||||
|
ing.last is before and ing.net_w == -2500.0)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "unknown")
|
||||||
|
check("an unknown signed entity is treated the same way", ing.parse_errors == 2)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "banana")
|
||||||
|
check("a non-numeric signed state is a parse error, not 0 W",
|
||||||
|
ing.parse_errors == 3 and ing.net_w == -2500.0)
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "64954")
|
||||||
|
check("64954 is refused at the signed transport too",
|
||||||
|
built(sig) is False and ing.parse_errors == 4)
|
||||||
|
sig._absorb("sensor.not_ours", "123")
|
||||||
|
check("an unsubscribed entity is never cached by the signed transport",
|
||||||
|
"sensor.not_ours" not in sig.cache)
|
||||||
|
|
||||||
|
# A rejected reading must not make the age look fresh - the age is what the
|
||||||
|
# firmware watchdog reads, and a rejection is exactly when it must keep climbing.
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
sig = HaSignedSource(None, ing, dict(NET), token="x")
|
||||||
|
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
|
||||||
|
ingest_mono=time.monotonic() - 20.0))
|
||||||
|
sig._absorb("sensor.p1_meter_active_power", "unavailable")
|
||||||
|
built(sig)
|
||||||
|
check("a rejected reading does not reset the published age",
|
||||||
|
ing.published_age_s > 19 and ing.net_w == 1200.0)
|
||||||
|
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
|
||||||
|
ingest_mono=time.monotonic() - 40.0))
|
||||||
|
check("...and the age keeps climbing past max_age_s on its own",
|
||||||
|
ing.stale is True and ing.net_w is None)
|
||||||
|
|
||||||
|
# The three-phase reading the TEL-04 survey recorded at this house: L1 +2301 W,
|
||||||
|
# L2 +468 W, L3 -2582 W, netting +187 W. A signed per-phase set splits the same
|
||||||
|
# way, and the exporting phase must still clamp out of the billed figure.
|
||||||
|
ing3 = P1Ingest(phases=3, max_age_s=30.0)
|
||||||
|
NET3 = {"net": "sensor.p1_meter_active_power",
|
||||||
|
"phase_net": ["sensor.p1_l1", "sensor.p1_l2", "sensor.p1_l3"]}
|
||||||
|
sig3 = HaSignedSource(None, ing3, NET3, token="x")
|
||||||
|
for eid, val in (("sensor.p1_meter_active_power", "187"), ("sensor.p1_l1", "2301")):
|
||||||
|
sig3._absorb(eid, val)
|
||||||
|
check("an incomplete signed phase set waits instead of guessing", built(sig3) is False)
|
||||||
|
sig3._absorb("sensor.p1_l2", "468")
|
||||||
|
sig3._absorb("sensor.p1_l3", "-2582")
|
||||||
|
check("a complete signed three-phase set builds", built(sig3) is True)
|
||||||
|
check("signed per-phase entities keep the exporting phase negative",
|
||||||
|
ing3.last.per_phase_w == (2301.0, 468.0, -2582.0))
|
||||||
|
check("per-phase IMPORT clamps the exporting phase to zero",
|
||||||
|
ing3.last.per_phase_import_w == (2301.0, 468.0, 0.0))
|
||||||
|
check("the phase import sum is 2769 W while the connection nets 187 W",
|
||||||
|
sum(ing3.last.per_phase_import_w) == 2769.0 and ing3.last.net_w == 187.0)
|
||||||
|
check("the signed per-phase tuple length matches meter_phases",
|
||||||
|
len(ing3.last.per_phase_w) == ing3.phases == 3)
|
||||||
|
|
||||||
|
sig_bad = HaSignedSource(None, P1Ingest(phases=3, max_age_s=30.0),
|
||||||
|
{"net": "sensor.net", "phase_net": ["sensor.a", "sensor.b"]},
|
||||||
|
token="x")
|
||||||
|
for eid in ("sensor.net", "sensor.a", "sensor.b"):
|
||||||
|
sig_bad._absorb(eid, "100")
|
||||||
|
check("two phases delivered against meter_phases 3 is rejected, not padded",
|
||||||
|
built(sig_bad) is False and sig_bad.ingest.last is None
|
||||||
|
and sig_bad.ingest.parse_errors == 1)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed transport: end to end against a fake Home Assistant")
|
||||||
|
# The transport is a subclass, so this is what proves the INHERITED machinery -
|
||||||
|
# auth, subscribe, the get_states priming rule, the reconnect-emits-nothing
|
||||||
|
# rule - still behaves when only _wanted() and build() were replaced.
|
||||||
|
|
||||||
|
|
||||||
|
async def _e2e_signed():
|
||||||
|
from aiohttp import web
|
||||||
|
import app.p1 as p1mod
|
||||||
|
|
||||||
|
done = asyncio.Event()
|
||||||
|
eid = "sensor.p1_meter_active_power"
|
||||||
|
|
||||||
|
async def fake_ha(request):
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
await ws.send_json({"type": "auth_required", "ha_version": "2026.8"})
|
||||||
|
auth = await ws.receive_json()
|
||||||
|
assert auth["type"] == "auth" and auth["access_token"] == "tok"
|
||||||
|
await ws.send_json({"type": "auth_ok"})
|
||||||
|
sub = await ws.receive_json()
|
||||||
|
assert sub["type"] == "subscribe_events"
|
||||||
|
await ws.send_json({"id": sub["id"], "type": "result", "success": True})
|
||||||
|
get = await ws.receive_json()
|
||||||
|
assert get["type"] == "get_states"
|
||||||
|
await ws.send_json({"id": get["id"], "type": "result", "success": True, "result": [
|
||||||
|
{"entity_id": eid, "state": "775.0"},
|
||||||
|
{"entity_id": "sensor.something_else", "state": "hello"},
|
||||||
|
]})
|
||||||
|
# Two real telegrams, both literal captured values: overnight import,
|
||||||
|
# then the midday export peak.
|
||||||
|
for val in ("775.0", "-5710.0"):
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await ws.send_json({"type": "event", "event": {"data": {
|
||||||
|
"entity_id": eid,
|
||||||
|
"new_state": {"entity_id": eid, "state": val}}}})
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await ws.send_json({"type": "event", "event": {"data": {
|
||||||
|
"entity_id": eid,
|
||||||
|
"new_state": {"entity_id": eid, "state": "unavailable"}}}})
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
done.set()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
srv = web.Application()
|
||||||
|
srv.router.add_get("/ws", fake_ha)
|
||||||
|
runner = web.AppRunner(srv)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||||
|
await site.start()
|
||||||
|
port = site._server.sockets[0].getsockname()[1]
|
||||||
|
p1mod.WS_URL = f"http://127.0.0.1:{port}/ws"
|
||||||
|
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
async with aiohttp.ClientSession() as sess:
|
||||||
|
src = HaSignedSource(sess, ing, dict(NET), token="tok")
|
||||||
|
task = asyncio.get_running_loop().create_task(src.run())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(done.wait(), 20)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await runner.cleanup()
|
||||||
|
return ing, src
|
||||||
|
|
||||||
|
|
||||||
|
live, wire = asyncio.run(_e2e_signed())
|
||||||
|
# ⚠️ TWO, not three - the same rule as the ha_dsmr e2e. get_states primes the
|
||||||
|
# cache but must never become a sample: after a Core restart it is a
|
||||||
|
# RestoreEntity value of unknown age, and stamping it with ingest_ts=now reports
|
||||||
|
# a fresh meter that may have been dead for an hour.
|
||||||
|
check("ha_signed does not manufacture a sample from cached HA state",
|
||||||
|
live.samples == 2)
|
||||||
|
check("the signed telegrams arrived over a real websocket",
|
||||||
|
live.last.source == SOURCE_HA_SIGNED)
|
||||||
|
check("the final export telegram nets negative, over the wire",
|
||||||
|
live.last.net_w == -5710.0 and live.last.export_w == 5710.0)
|
||||||
|
check("ha_signed subscribes to the one entity and caches nothing else",
|
||||||
|
wire.ids == {"sensor.p1_meter_active_power"}
|
||||||
|
and "sensor.something_else" not in wire.cache)
|
||||||
|
check("a mid-stream unavailable signed state is a parse error, not a sample",
|
||||||
|
live.parse_errors == 1 and live.samples == 2)
|
||||||
|
# ⚠️ And the cached half is DROPPED, so no later telegram can be assembled out
|
||||||
|
# of a value that stopped reporting.
|
||||||
|
check("an unavailable entity is evicted from the cache", wire.cache == {})
|
||||||
|
check("the last good reading survives the unavailable, and is not 0 W",
|
||||||
|
live.net_w == -5710.0)
|
||||||
|
check("the averager integrated the live signed stream", live.averager.elapsed_s > 0.2)
|
||||||
|
# ⚠️ The entity FW-01 waits on. It must be a number here exactly as it is on the
|
||||||
|
# other transports - the house P1 went 51.1 s and 36.2 s between state changes
|
||||||
|
# overnight, and without this the watchdog false-trips the battery to 0 W.
|
||||||
|
check("sensor.p1_sample_age_s is a live number on this transport too",
|
||||||
|
isinstance(live.published_age_s, float) and live.published_age_s >= 0.0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("ha_signed: selection by config")
|
||||||
|
|
||||||
|
check("ha_signed is enabled", is_enabled({"meter_source": SOURCE_HA_SIGNED}) is True)
|
||||||
|
# ⚠️ `sel`, not `built` - that name is the build() wrapper defined at the top of
|
||||||
|
# this file, and rebinding it here silently disarms every check appended below
|
||||||
|
# this line. Caught in review: an added check went `TypeError: 'HaSignedSource'
|
||||||
|
# object is not callable` and aborted the suite, which is the exact failure the
|
||||||
|
# wrapper exists to prevent, reintroduced by a name collision.
|
||||||
|
sel = build_source({"meter_source": SOURCE_HA_SIGNED,
|
||||||
|
"p1_net_entity": "sensor.p1_meter_active_power"},
|
||||||
|
P1Ingest(), None, None)
|
||||||
|
check("meter_source ha_signed selects the signed transport",
|
||||||
|
isinstance(sel, HaSignedSource))
|
||||||
|
check("...wired to p1_net_entity, and subscribed to exactly that one entity",
|
||||||
|
sel.ids == {"sensor.p1_meter_active_power"})
|
||||||
|
# ⚠️ The three modes must not bleed into each other: ha_dsmr must keep ignoring
|
||||||
|
# p1_net_entity, or a half-configured install silently reads the wrong sensor.
|
||||||
|
plain = build_source({"meter_source": SOURCE_HA,
|
||||||
|
"p1_import_entity": "sensor.i", "p1_export_entity": "sensor.e",
|
||||||
|
"p1_net_entity": "sensor.p1_meter_active_power"},
|
||||||
|
P1Ingest(), None, None)
|
||||||
|
check("ha_dsmr still selects the unsigned transport and ignores p1_net_entity",
|
||||||
|
type(plain) is HaDsmrSource and plain.ids == {"sensor.i", "sensor.e"})
|
||||||
|
check("meter_source off still selects nothing",
|
||||||
|
build_source({"meter_source": "off"}, P1Ingest(), None, None) is None)
|
||||||
|
check("an unrecognised meter_source selects nothing rather than guessing",
|
||||||
|
build_source({"meter_source": "ha_signd"}, P1Ingest(), None, None) is None)
|
||||||
|
|
||||||
|
# ⚠️ Caught once at startup, not once per telegram. A source that is wired up
|
||||||
|
# wrong otherwise fails in the one way indistinguishable from a healthy source
|
||||||
|
# nobody has sent anything to yet: no samples, a climbing age, the watchdog
|
||||||
|
# holding the battery at 0 W, and nothing in the log saying why.
|
||||||
|
check("a blank p1_net_entity is refused rather than silently never receiving",
|
||||||
|
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": ""},
|
||||||
|
P1Ingest(), None, None) is None)
|
||||||
|
check("...and whitespace does not sneak past it",
|
||||||
|
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": " "},
|
||||||
|
P1Ingest(), None, None) is None)
|
||||||
|
check("a phase list that disagrees with meter_phases is refused at startup",
|
||||||
|
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
|
||||||
|
"p1_phase_net_entities": ["sensor.a", "sensor.b"]},
|
||||||
|
P1Ingest(phases=3), None, None) is None)
|
||||||
|
check("a phase list that agrees with meter_phases is accepted",
|
||||||
|
isinstance(build_source(
|
||||||
|
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
|
||||||
|
"p1_phase_net_entities": ["sensor.a", "sensor.b", "sensor.c"]},
|
||||||
|
P1Ingest(phases=3), None, None), HaSignedSource))
|
||||||
|
check("no phase list at all is still fine - per-phase billing is optional",
|
||||||
|
isinstance(build_source(
|
||||||
|
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"},
|
||||||
|
P1Ingest(phases=3), None, None), HaSignedSource))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("homewizard_local: parsing the meter's own /api/v1/data document")
|
||||||
|
# The same JSON `sim/hwsim.py` serves and the same JSON the real meter at
|
||||||
|
# 192.168.2.250 serves. One SIGNED connection figure plus three signed legs, so
|
||||||
|
# it goes through the same split_signed as ha_signed - the sign convention is
|
||||||
|
# not re-derived on this transport, it is reused.
|
||||||
|
|
||||||
|
|
||||||
|
def hw_doc(w, l1=None, l2=None, l3=None, imp_kwh=1234.567, exp_kwh=890.123):
|
||||||
|
return {"wifi_ssid": "sim", "smr_version": 50, "meter_model": "SIM-P1",
|
||||||
|
"total_power_import_kwh": imp_kwh, "total_power_export_kwh": exp_kwh,
|
||||||
|
"active_power_w": w,
|
||||||
|
"active_power_l1_w": l1, "active_power_l2_w": l2,
|
||||||
|
"active_power_l3_w": l3, "total_gas_m3": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
s = parse_homewizard(hw_doc(775.0, l1=775.0), 1)
|
||||||
|
check("the overnight base load (+775 W) is import on this transport too",
|
||||||
|
s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0)
|
||||||
|
check("the sample is tagged homewizard_local", s.source == SOURCE_HOMEWIZARD)
|
||||||
|
s = parse_homewizard(hw_doc(-5710.0, l1=-5710.0), 1)
|
||||||
|
check("the midday PV peak (-5710 W) is export, not a 5.7 kW draw",
|
||||||
|
s.net_w == -5710.0 and s.export_w == 5710.0)
|
||||||
|
check("a single-phase document still yields its one leg", s.per_phase_w == (-5710.0,))
|
||||||
|
|
||||||
|
# The TEL-04 three-phase survey reading, served the HomeWizard way.
|
||||||
|
s = parse_homewizard(hw_doc(187.0, 2301.0, 468.0, -2582.0), 3)
|
||||||
|
check("signed legs keep the exporting phase negative",
|
||||||
|
s.per_phase_w == (2301.0, 468.0, -2582.0))
|
||||||
|
check("per-phase import clamps the exporting leg out of the billed figure",
|
||||||
|
s.per_phase_import_w == (2301.0, 468.0, 0.0))
|
||||||
|
check("the legs sum to 2769 W while the connection nets 187 W",
|
||||||
|
sum(s.per_phase_import_w) == 2769.0 and s.net_w == 187.0)
|
||||||
|
|
||||||
|
# ⚠️ A single-phase HomeWizard serves `active_power_l2_w: null`. Unlike the HA
|
||||||
|
# transports there is no "wait for the rest" case - every field came out of ONE
|
||||||
|
# response, so a missing leg cannot be a STALE leg. Dropping the whole sample
|
||||||
|
# over it would turn a healthy meter into a climbing age, which is the exact
|
||||||
|
# false trip this transport exists to remove.
|
||||||
|
def parsed(doc, phases=1):
|
||||||
|
"""parse_homewizard(), with an escaping exception turned into a visible value.
|
||||||
|
|
||||||
|
Same reason as `built()` above: if the "all legs or none" guard goes missing,
|
||||||
|
parsing raises, and without this the suite aborts on a traceback instead of
|
||||||
|
reddening the NAMED check that says which rule died.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return parse_homewizard(doc, phases)
|
||||||
|
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
|
||||||
|
print(f" parse_homewizard raised {type(err).__name__}: {err}")
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
s = parsed(hw_doc(500.0, l1=500.0), 3)
|
||||||
|
check("a meter serving fewer legs than meter_phases still gives a reading",
|
||||||
|
getattr(s, "net_w", None) == 500.0 and getattr(s, "per_phase_w", "?") is None)
|
||||||
|
check("...and does not fabricate a partial phase tuple",
|
||||||
|
getattr(s, "per_phase_import_w", "?") is None)
|
||||||
|
|
||||||
|
raises("a response that is not a JSON object is rejected",
|
||||||
|
lambda: parse_homewizard([1, 2, 3], 1))
|
||||||
|
raises("a document with no active_power_w is rejected, not read as 0 W",
|
||||||
|
lambda: parse_homewizard({"total_gas_m3": 0.0}, 1))
|
||||||
|
raises("a null active_power_w is rejected", lambda: parse_homewizard(hw_doc(None), 1))
|
||||||
|
raises("a string active_power_w is rejected", lambda: parse_homewizard(hw_doc("775"), 1))
|
||||||
|
raises("a NaN active_power_w is rejected",
|
||||||
|
lambda: parse_homewizard(hw_doc(float("nan")), 1))
|
||||||
|
raises("the 64954 signed-decode contamination is refused here too",
|
||||||
|
lambda: parse_homewizard(hw_doc(64954.0), 1))
|
||||||
|
raises("a non-numeric leg is rejected rather than quietly dropped",
|
||||||
|
lambda: parse_homewizard(hw_doc(600.0, "2000", 0.0, 100.0), 3))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("homewizard_local: every HTTP response is an arrival")
|
||||||
|
# THE WHOLE TICKET. sensor.p1_sample_age_s has to mean "time since the meter
|
||||||
|
# REPORTED", not "time since the value CHANGED". Home Assistant cannot express
|
||||||
|
# the first: a repeated reading emits no state_changed, advances last_reported
|
||||||
|
# on neither serialiser, and state_reported cannot be subscribed to at all
|
||||||
|
# ("Event filter is required"). Measured on the ENV-01 rig over 70 s of a frozen
|
||||||
|
# meter and again against the live house over ten repeated readings. 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. the age sensor would command 0 W on a
|
||||||
|
# perfectly healthy meter. Polling the meter itself removes that: the response is
|
||||||
|
# the arrival, and the number in it is not consulted.
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMeter:
|
||||||
|
"""hwsim in eight lines: serves whatever document it is told to, or a fault."""
|
||||||
|
|
||||||
|
def __init__(self, doc=None):
|
||||||
|
self.doc = doc
|
||||||
|
self.status = 200
|
||||||
|
self.body = None # set to raw text to serve something unparseable
|
||||||
|
self.delay = 0.0 # set to seconds to imitate a meter that hangs
|
||||||
|
self.hits = 0
|
||||||
|
|
||||||
|
async def handle(self, request):
|
||||||
|
from aiohttp import web
|
||||||
|
self.hits += 1
|
||||||
|
if self.delay:
|
||||||
|
await asyncio.sleep(self.delay)
|
||||||
|
# ⚠️ web.Response(text=...) defaults to text/plain. That is not just the
|
||||||
|
# "unparseable body" path: fed VALID json it serves a good document under
|
||||||
|
# the wrong mimetype, which is the only way to reach the content_type
|
||||||
|
# guard in poll_once - json_response can never produce it.
|
||||||
|
if self.body is not None:
|
||||||
|
return web.Response(text=self.body, status=self.status)
|
||||||
|
return web.json_response(self.doc, status=self.status)
|
||||||
|
|
||||||
|
|
||||||
|
async def _hw_rig(fn):
|
||||||
|
"""Run `fn(make_source, meter)` against a real HTTP server on localhost."""
|
||||||
|
from aiohttp import web
|
||||||
|
meter = _FakeMeter(hw_doc(350.0, l1=350.0))
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_get("/api/v1/data", meter.handle)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||||
|
await site.start()
|
||||||
|
port = site._server.sockets[0].getsockname()[1]
|
||||||
|
async with aiohttp.ClientSession() as sess:
|
||||||
|
try:
|
||||||
|
return await fn(sess, port, meter)
|
||||||
|
finally:
|
||||||
|
await runner.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def _arrivals(sess, port, meter):
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
|
||||||
|
out = {}
|
||||||
|
out["first"] = await src.poll_once()
|
||||||
|
out["stamp1"] = ing.last.ingest_mono
|
||||||
|
# Four more polls of the IDENTICAL document - the meter has not moved a watt.
|
||||||
|
for _ in range(4):
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
await src.poll_once()
|
||||||
|
out["ing"], out["src"], out["meter"] = ing, src, meter
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
r = asyncio.run(_hw_rig(_arrivals))
|
||||||
|
ing, src = r["ing"], r["src"]
|
||||||
|
check("a poll of the meter's own API builds a sample",
|
||||||
|
r["first"] is True and ing.net_w == 350.0)
|
||||||
|
# ⚠️ THE acceptance criterion. Five identical readings, five arrivals. On
|
||||||
|
# ha_signed this whole sequence produces exactly ONE state_changed and then
|
||||||
|
# silence, and the age climbs to 30 s on a meter that is answering perfectly.
|
||||||
|
check("five identical readings are five arrivals, not one",
|
||||||
|
ing.samples == 5 and r["meter"].hits == 5 and src.polls == 5)
|
||||||
|
check("an unchanged value still stamps a NEW arrival time",
|
||||||
|
ing.last.ingest_mono > r["stamp1"])
|
||||||
|
check("...so the age resets on a response carrying an unchanged number",
|
||||||
|
ing.published_age_s < 1.0 and ing.stale is False)
|
||||||
|
check("no arrival was ever a parse error", ing.parse_errors == 0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("homewizard_local: frozen vs steady, the pair HA cannot separate")
|
||||||
|
# ⚠️ Read this before changing anything here. A frozen meter (hwsim --fault
|
||||||
|
# freeze) answers 200 OK forever with a stale document. It is ARRIVING. So the
|
||||||
|
# age - an arrival detector, correctly - reads fresh on both, and that is not a
|
||||||
|
# defect in the age, it is the definition of the signal. What the local API adds
|
||||||
|
# that Home Assistant never had is the ENERGY REGISTERS: a meter under real load
|
||||||
|
# advances total_power_import_kwh (1 Wh resolution, ~10 s at 350 W) even when the
|
||||||
|
# power figure repeats, and a frozen one does not. That is the discriminator, and
|
||||||
|
# it is exposed as `unchanged_s` - deliberately NOT folded into the age, because
|
||||||
|
# this controller regulates grid power toward ~0 W and at a converged -10 W the
|
||||||
|
# export register needs six minutes to move. Thresholding unchanged_s at 30 s
|
||||||
|
# would rebuild the false-trip limit cycle at the exact operating point we aim
|
||||||
|
# for. See DOCS.md and HomeWizardLocalSource.unchanged_s.
|
||||||
|
|
||||||
|
|
||||||
|
async def _freeze_vs_steady(sess, port, meter):
|
||||||
|
ing_f = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
frozen = HomeWizardLocalSource(sess, ing_f, "127.0.0.1", port=port, poll_s=1.0)
|
||||||
|
meter.doc = hw_doc(350.0, l1=350.0) # --fault freeze: never moves
|
||||||
|
for i in range(4):
|
||||||
|
if i:
|
||||||
|
await asyncio.sleep(0.1) # sleep BEFORE, so unchanged_s
|
||||||
|
await frozen.poll_once() # is read the instant a poll lands
|
||||||
|
|
||||||
|
ing_s = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
steady = HomeWizardLocalSource(sess, ing_s, "127.0.0.1", port=port, poll_s=1.0)
|
||||||
|
kwh = 1234.567
|
||||||
|
for i in range(4):
|
||||||
|
if i:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
# A steady 350 W house: the power figure repeats, the register climbs.
|
||||||
|
kwh += 0.001
|
||||||
|
meter.doc = hw_doc(350.0, l1=350.0, imp_kwh=round(kwh, 3))
|
||||||
|
await steady.poll_once()
|
||||||
|
return (ing_f, frozen), (ing_s, steady)
|
||||||
|
|
||||||
|
|
||||||
|
(ing_f, frozen), (ing_s, steady) = asyncio.run(_hw_rig(_freeze_vs_steady))
|
||||||
|
check("a frozen meter keeps arriving, so both read the same power",
|
||||||
|
ing_f.net_w == 350.0 and ing_s.net_w == 350.0)
|
||||||
|
# ⚠️ Recorded as a rule, not a shortcoming: the age is an ARRIVAL detector and a
|
||||||
|
# frozen meter genuinely is arriving. Anyone tempted to make the age catch freeze
|
||||||
|
# is about to reintroduce the false trip on a steady house.
|
||||||
|
check("the age cannot separate them, and is fresh on both",
|
||||||
|
ing_f.published_age_s < 1.0 and ing_s.published_age_s < 1.0)
|
||||||
|
check("the frozen meter's measurement stands still", frozen.unchanged_s > 0.25)
|
||||||
|
check("...while the steady meter's energy register keeps advancing",
|
||||||
|
steady.unchanged_s < 0.05)
|
||||||
|
check("so the two ARE separable on the local API, which HA could not do",
|
||||||
|
frozen.unchanged_s > steady.unchanged_s * 3)
|
||||||
|
check("unchanged_s is None before the first response ever lands",
|
||||||
|
HomeWizardLocalSource(None, P1Ingest(), "h").unchanged_s is None)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("homewizard_local: a failed poll is a missing reading, never 0 W")
|
||||||
|
|
||||||
|
|
||||||
|
async def _failures(sess, port, meter):
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
|
||||||
|
await src.poll_once()
|
||||||
|
# Age the good sample by hand so a reset would be unmistakable.
|
||||||
|
ing.submit(make_sample(SOURCE_HOMEWIZARD, 350.0, 0.0, phases=1,
|
||||||
|
ingest_mono=time.monotonic() - 90.0))
|
||||||
|
out = {}
|
||||||
|
meter.status = 500
|
||||||
|
out["http500"] = await src.poll_once()
|
||||||
|
out["conn_after_500"] = src.connected
|
||||||
|
meter.status, meter.body = 200, "<html>gateway</html>"
|
||||||
|
out["notjson"] = await src.poll_once()
|
||||||
|
meter.body = None
|
||||||
|
meter.doc = {"total_gas_m3": 0.0} # 200 OK, no power in it
|
||||||
|
out["nopower"] = await src.poll_once()
|
||||||
|
out["ing"], out["src"] = ing, src
|
||||||
|
|
||||||
|
# And a meter that is not listening at all: `--fault down`.
|
||||||
|
dead = HomeWizardLocalSource(sess, P1Ingest(phases=1, max_age_s=30.0),
|
||||||
|
"127.0.0.1", port=1, poll_s=1.0)
|
||||||
|
out["down"] = await dead.poll_once()
|
||||||
|
out["dead"] = dead
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
r = asyncio.run(_hw_rig(_failures))
|
||||||
|
ing, src = r["ing"], r["src"]
|
||||||
|
check("an HTTP 500 is not a reading", r["http500"] is False)
|
||||||
|
# A source that was connected and then failed must SAY it is disconnected -
|
||||||
|
# otherwise the diagnostic reads healthy while the age climbs, which is exactly
|
||||||
|
# the "connected: True, parse_errors: 0, samples: 0" state the rig recorded.
|
||||||
|
check("a poll that fails clears the connected flag", r["conn_after_500"] is False)
|
||||||
|
check("a 200 OK carrying something that is not JSON is not a reading",
|
||||||
|
r["notjson"] is False)
|
||||||
|
check("a 200 OK with no active_power_w in it is not a reading", r["nopower"] is False)
|
||||||
|
check("a meter that refuses the connection is not a reading",
|
||||||
|
r["down"] is False and r["dead"].connected is False)
|
||||||
|
check("every failure was counted as a parse error", ing.parse_errors == 3)
|
||||||
|
check("...and the transport records the transport-level ones separately",
|
||||||
|
src.poll_errors == 2 and src.polls == 4)
|
||||||
|
# ⚠️ The rule the safety chain rests on. A failed poll must not manufacture a
|
||||||
|
# balanced house, and must not reset the clock the watchdog reads.
|
||||||
|
check("a failed poll does not reset the age", ing.published_age_s > 89)
|
||||||
|
check("a failed poll leaves the last good value in place, and it is not 0 W",
|
||||||
|
ing.last.net_w == 350.0 and ing.stale is True and ing.net_w is None)
|
||||||
|
check("no failed poll ever became a sample", ing.samples == 2)
|
||||||
|
check("a 200 OK marks the transport connected even when its body is refused",
|
||||||
|
src.connected is True)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("homewizard_local: a wrong header, a hung meter, a submit that throws")
|
||||||
|
# Three failure shapes that all end the same way if they are mishandled - no
|
||||||
|
# sample, a climbing age, the battery at 0 W - and each of which would send the
|
||||||
|
# operator hunting the wrong device.
|
||||||
|
|
||||||
|
|
||||||
|
async def _header_and_timeout(sess, port, meter):
|
||||||
|
out = {}
|
||||||
|
# Valid JSON under text/plain: exactly what content_type=None is for.
|
||||||
|
meter.body = json.dumps(hw_doc(350.0, l1=350.0))
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
|
||||||
|
out["mimetype"] = await src.poll_once()
|
||||||
|
out["ing"] = ing
|
||||||
|
meter.body = None
|
||||||
|
|
||||||
|
# A meter that takes the connection and then does not answer.
|
||||||
|
meter.delay = 0.6
|
||||||
|
ing_t = P1Ingest(phases=1, max_age_s=30.0)
|
||||||
|
slow = HomeWizardLocalSource(sess, ing_t, "127.0.0.1", port=port,
|
||||||
|
poll_s=1.0, timeout_s=0.05)
|
||||||
|
out["timeout"] = await slow.poll_once()
|
||||||
|
out["timeout_err"] = ing_t.last_error
|
||||||
|
meter.delay = 0.0
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
r = asyncio.run(_hw_rig(_header_and_timeout))
|
||||||
|
# ⚠️ A real firmware answering text/plain must not read as a dead meter. Drop
|
||||||
|
# content_type=None from poll_once and this goes red with "unexpected mimetype".
|
||||||
|
check("valid JSON under the wrong Content-Type is still a reading",
|
||||||
|
r["mimetype"] is True and r["ing"].samples == 1 and r["ing"].net_w == 350.0)
|
||||||
|
# ⚠️ str(asyncio.TimeoutError()) is the EMPTY STRING. Without the class-name
|
||||||
|
# fallback the status page reads "last error:" and then nothing, on a hung
|
||||||
|
# meter, at the moment someone is reading that line to find out why the battery
|
||||||
|
# went to 0 W.
|
||||||
|
check("a timed-out poll names the fault instead of logging an empty reason",
|
||||||
|
r["timeout"] is False and "TimeoutError" in (r["timeout_err"] or ""))
|
||||||
|
|
||||||
|
|
||||||
|
async def _submit_rejects(sess, port, meter):
|
||||||
|
"""submit() raising P1Error must be a rejection, not an escaping exception."""
|
||||||
|
class _P1Boom(P1Ingest):
|
||||||
|
def submit(self, sample):
|
||||||
|
raise P1Error("register went backwards")
|
||||||
|
|
||||||
|
ing = _P1Boom(phases=1, max_age_s=30.0)
|
||||||
|
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
|
||||||
|
try:
|
||||||
|
ok = await src.poll_once()
|
||||||
|
except Exception as err: # noqa: BLE001 - an escape IS the failure
|
||||||
|
ok = err
|
||||||
|
return ok, ing.parse_errors
|
||||||
|
|
||||||
|
|
||||||
|
ok, errs = asyncio.run(_hw_rig(_submit_rejects))
|
||||||
|
check("a submit that rejects the sample is handled, not left to escape",
|
||||||
|
ok is False and errs == 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _submit_explodes(sess, port, meter):
|
||||||
|
"""And an UNEXPECTED raise must not kill the poll task for good."""
|
||||||
|
class _Boom(P1Ingest):
|
||||||
|
def submit(self, sample):
|
||||||
|
raise RuntimeError("kaboom")
|
||||||
|
|
||||||
|
src = HomeWizardLocalSource(sess, _Boom(phases=1, max_age_s=30.0),
|
||||||
|
"127.0.0.1", port=port, poll_s=0.05)
|
||||||
|
task = asyncio.create_task(src.run())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
alive = not task.done()
|
||||||
|
task.cancel()
|
||||||
|
# ⚠️ BaseException, and the same reason built() exists: if run() loses its
|
||||||
|
# guard the task is already dead HOLDING the RuntimeError, and awaiting it
|
||||||
|
# re-raises - aborting the whole suite with a traceback instead of reddening
|
||||||
|
# the check that names the rule. The death is what `alive` records; catching
|
||||||
|
# it here is bookkeeping, not leniency.
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except BaseException: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return alive, src.polls
|
||||||
|
|
||||||
|
|
||||||
|
# ⚠️ The silent-death case. A dead poll task fails SAFE - the age climbs and the
|
||||||
|
# controller commands 0 W - but it looks exactly like a dead meter, so the
|
||||||
|
# operator spends the outage power-cycling hardware that was never at fault.
|
||||||
|
alive, polls = asyncio.run(_hw_rig(_submit_explodes))
|
||||||
|
check("a raise inside a poll does not permanently kill the polling task",
|
||||||
|
alive is True and polls > 1)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("homewizard_local: selection by config")
|
||||||
|
|
||||||
|
check("homewizard_local is enabled", is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True)
|
||||||
|
sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "192.168.2.250"},
|
||||||
|
P1Ingest(), None, None)
|
||||||
|
check("meter_source homewizard_local selects the polling transport",
|
||||||
|
isinstance(sel, HomeWizardLocalSource))
|
||||||
|
# ⚠️ getattr, not attribute access, for the same reason `built()` exists: a
|
||||||
|
# startup guard that goes wrong returns None here, and `None.url` would abort the
|
||||||
|
# suite with a traceback instead of reddening the check that names the rule.
|
||||||
|
check("...pointed at the meter's own local API on the default port",
|
||||||
|
getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data")
|
||||||
|
check("the default cadence is the meter's own ~5 s update rate",
|
||||||
|
getattr(sel, "poll_s", None) == 5.0)
|
||||||
|
# ⚠️ The request must not outlive the poll interval: a backlog of queued requests
|
||||||
|
# behind a slow meter would land several arrivals for one measurement.
|
||||||
|
check("the request timeout is held under the poll interval",
|
||||||
|
getattr(sel, "timeout_s", 99) < getattr(sel, "poll_s", 0))
|
||||||
|
|
||||||
|
sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "10.0.0.9:8080"},
|
||||||
|
P1Ingest(), None, None)
|
||||||
|
check("an explicit host:port is honoured, which is how the sim is reached",
|
||||||
|
getattr(sel, "url", None) == "http://10.0.0.9:8080/api/v1/data")
|
||||||
|
sel = build_source({"meter_source": SOURCE_HOMEWIZARD,
|
||||||
|
"p1_host": "http://192.168.2.250/"}, P1Ingest(), None, None)
|
||||||
|
check("a pasted browser URL still resolves to the right host",
|
||||||
|
getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data")
|
||||||
|
|
||||||
|
# ⚠️ Caught once at startup, not once per poll - a source wired up wrong
|
||||||
|
# otherwise fails in the one way indistinguishable from a healthy one nobody has
|
||||||
|
# polled yet: no samples, a climbing age, the watchdog at 0 W, nothing in the log.
|
||||||
|
check("a blank p1_host is refused rather than silently never polling",
|
||||||
|
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": ""},
|
||||||
|
P1Ingest(), None, None) is None)
|
||||||
|
check("...and whitespace does not sneak past it",
|
||||||
|
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": " "},
|
||||||
|
P1Ingest(), None, None) is None)
|
||||||
|
check("an unparseable port is refused rather than guessed",
|
||||||
|
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "meter:eighty"},
|
||||||
|
P1Ingest(), None, None) is None)
|
||||||
|
# ⚠️ The age can never be fresher than the poll interval, so a cadence at or past
|
||||||
|
# max_age_s means every reading is stale before its successor arrives: the
|
||||||
|
# controller would sit permanently on missing inputs while the meter is fine.
|
||||||
|
check("a poll cadence at meter_max_age_s is refused",
|
||||||
|
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||||
|
"meter_poll_s": 30}, P1Ingest(max_age_s=30), None, None) is None)
|
||||||
|
check("...and one past it too",
|
||||||
|
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||||
|
"meter_poll_s": 45}, P1Ingest(max_age_s=30), None, None) is None)
|
||||||
|
check("a cadence with headroom is accepted",
|
||||||
|
isinstance(build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||||
|
"meter_poll_s": 5}, P1Ingest(max_age_s=30),
|
||||||
|
None, None), HomeWizardLocalSource))
|
||||||
|
check("a zero cadence is refused rather than spinning",
|
||||||
|
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||||
|
"meter_poll_s": 0}, P1Ingest(max_age_s=30), None, None) is None)
|
||||||
|
check("a non-numeric cadence is refused",
|
||||||
|
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||||
|
"meter_poll_s": "fast"}, P1Ingest(max_age_s=30), None, None) is None)
|
||||||
|
# The four modes must not bleed into each other.
|
||||||
|
check("ha_signed ignores p1_host and still selects the entity transport",
|
||||||
|
type(build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
|
||||||
|
"p1_host": "192.168.2.250"}, P1Ingest(), None, None))
|
||||||
|
is HaSignedSource)
|
||||||
|
check("homewizard_local ignores p1_net_entity and needs its own host",
|
||||||
|
build_source({"meter_source": SOURCE_HOMEWIZARD,
|
||||||
|
"p1_net_entity": "sensor.n"}, P1Ingest(), None, None) is None)
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
print("the age sensor must not exist when P1 is off")
|
print("the age sensor must not exist when P1 is off")
|
||||||
# ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog
|
# ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog
|
||||||
@@ -556,7 +1299,6 @@ print("the age sensor must not exist when P1 is off")
|
|||||||
# age were published with meter_source off it would climb past 30 s on every
|
# age were published with meter_source off it would climb past 30 s on every
|
||||||
# existing install within half a minute and pin the inverter at 0 W forever.
|
# existing install within half a minute and pin the inverter at 0 W forever.
|
||||||
|
|
||||||
from app.p1 import is_enabled # noqa: E402
|
|
||||||
from app.mqtt import SENSORS, MqttPublisher # noqa: E402
|
from app.mqtt import SENSORS, MqttPublisher # noqa: E402
|
||||||
|
|
||||||
check("meter_source off is disabled", is_enabled({"meter_source": "off"}) is False)
|
check("meter_source off is disabled", is_enabled({"meter_source": "off"}) is False)
|
||||||
@@ -564,6 +1306,8 @@ check("a missing meter_source is disabled", is_enabled({}) is False)
|
|||||||
check("an empty meter_source is disabled", is_enabled({"meter_source": ""}) is False)
|
check("an empty meter_source is disabled", is_enabled({"meter_source": ""}) is False)
|
||||||
check("ha_dsmr is enabled", is_enabled({"meter_source": SOURCE_HA}) is True)
|
check("ha_dsmr is enabled", is_enabled({"meter_source": SOURCE_HA}) is True)
|
||||||
check("mqtt_p1 is enabled", is_enabled({"meter_source": SOURCE_MQTT}) is True)
|
check("mqtt_p1 is enabled", is_enabled({"meter_source": SOURCE_MQTT}) is True)
|
||||||
|
check("homewizard_local is enabled here too",
|
||||||
|
is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True)
|
||||||
|
|
||||||
# The entity id SAFETY-01's firmware subscribes to, pinned by object_id.
|
# The entity id SAFETY-01's firmware subscribes to, pinned by object_id.
|
||||||
row = [s for s in SENSORS if s[0] == "p1_age"]
|
row = [s for s in SENSORS if s[0] == "p1_age"]
|
||||||
@@ -602,6 +1346,17 @@ check("with P1 on the age sensor IS announced",
|
|||||||
from app.main import Controller # noqa: E402
|
from app.main import Controller # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _never():
|
||||||
|
"""A stop Event these publish-only tests never set.
|
||||||
|
|
||||||
|
`stop` is a REQUIRED keyword argument (CFG-01): a Controller that cannot
|
||||||
|
stop itself used to be the default one, and on the late-refusal path that
|
||||||
|
meant a live process with no control loop and no exit. These cases only call
|
||||||
|
publish(), so any Event will do - but they may not skip it.
|
||||||
|
"""
|
||||||
|
return asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
class _Store:
|
class _Store:
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
@@ -624,18 +1379,80 @@ class _Pub:
|
|||||||
|
|
||||||
|
|
||||||
pub_off = _Pub()
|
pub_off = _Pub()
|
||||||
Controller({"meter_source": "off"}, None, _Store(), pub_off).publish()
|
Controller({"meter_source": "off"}, None, _Store(), pub_off, stop=_never()).publish()
|
||||||
check("with P1 off, p1_age is absent from the published payload",
|
check("with P1 off, p1_age is absent from the published payload",
|
||||||
"p1_age" not in pub_off.last)
|
"p1_age" not in pub_off.last)
|
||||||
check("...while the normal status keys are still published",
|
check("...while the normal status keys are still published",
|
||||||
"setpoint" in pub_off.last and "grid" in pub_off.last)
|
"setpoint" in pub_off.last and "grid" in pub_off.last)
|
||||||
|
|
||||||
pub_on = _Pub()
|
pub_on = _Pub()
|
||||||
Controller({"meter_source": SOURCE_HA}, None, _Store(), pub_on).publish()
|
Controller({"meter_source": SOURCE_HA}, None, _Store(), pub_on, stop=_never()).publish()
|
||||||
check("with P1 on, p1_age is published", "p1_age" in pub_on.last)
|
check("with P1 on, p1_age is published", "p1_age" in pub_on.last)
|
||||||
check("...as a number, so has_state() becomes true only once we feed it",
|
check("...as a number, so has_state() becomes true only once we feed it",
|
||||||
isinstance(pub_on.last["p1_age"], float))
|
isinstance(pub_on.last["p1_age"], float))
|
||||||
|
|
||||||
|
# ⚠️ And on ha_signed identically - this is the whole reason TEL-04 exists. The
|
||||||
|
# age sensor is a hard prerequisite for the FW-01 flash, and it has to appear on
|
||||||
|
# the transport that can actually read the meter in this house.
|
||||||
|
pub_sig = _Pub()
|
||||||
|
Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig, stop=_never()).publish()
|
||||||
|
check("with ha_signed selected, p1_age is published too",
|
||||||
|
"p1_age" in pub_sig.last and isinstance(pub_sig.last["p1_age"], float))
|
||||||
|
|
||||||
|
# ⚠️ And on homewizard_local, where it is the one age FW-01 may actually
|
||||||
|
# threshold - every other transport's age measures when the VALUE changed.
|
||||||
|
pub_hw = _Pub()
|
||||||
|
Controller({"meter_source": SOURCE_HOMEWIZARD}, None, _Store(), pub_hw, stop=_never()).publish()
|
||||||
|
check("with homewizard_local selected, p1_age is published",
|
||||||
|
"p1_age" in pub_hw.last and isinstance(pub_hw.last["p1_age"], float))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("unchanged_s has somewhere an operator can read it")
|
||||||
|
# ⚠️ The counter is deliberately NOT thresholded and NOT folded into the age -
|
||||||
|
# at the converged -10 W this controller aims for, a 1 Wh register needs six
|
||||||
|
# minutes to move, so any limit false-trips at the target operating point. That
|
||||||
|
# refusal only holds up if a HUMAN can interpret the number instead, and DOCS.md
|
||||||
|
# tells them "the transport tracks it as unchanged_s". Before this, nothing ever
|
||||||
|
# read the transport object back: connected, polls, poll_errors and unchanged_s
|
||||||
|
# were all write-only, and the documented signal existed nowhere an operator
|
||||||
|
# could see it.
|
||||||
|
|
||||||
|
|
||||||
|
def p1_rows(ctl):
|
||||||
|
"""The P1 status line(s), or the exception that stopped checks() making one.
|
||||||
|
|
||||||
|
Same reason as built(): a status line that raises would abort the suite with
|
||||||
|
a traceback instead of reddening the check that names the rule.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")]
|
||||||
|
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
|
||||||
|
print(f" checks() raised {type(err).__name__}: {err}")
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
def _fed(source):
|
||||||
|
ctl = Controller({"meter_source": source}, None, _Store(), _Pub(), stop=_never())
|
||||||
|
ctl.p1.submit(make_sample(source, 350.0, 0.0, phases=1,
|
||||||
|
ingest_mono=time.monotonic()))
|
||||||
|
return ctl
|
||||||
|
|
||||||
|
|
||||||
|
hw_ctl = _fed(SOURCE_HOMEWIZARD)
|
||||||
|
hw_src = HomeWizardLocalSource(None, hw_ctl.p1, "127.0.0.1")
|
||||||
|
hw_src._note(hw_doc(350.0, l1=350.0))
|
||||||
|
hw_ctl.p1_source = hw_src # what amain() does once the transport exists
|
||||||
|
rows = p1_rows(hw_ctl)
|
||||||
|
check("the healthy P1 status line reports the transport's unchanged_s",
|
||||||
|
isinstance(rows, list) and len(rows) == 1 and "unchanged" in rows[0])
|
||||||
|
|
||||||
|
# ⚠️ getattr, not attribute access: p1_source is None until amain() builds one,
|
||||||
|
# and ha_dsmr/ha_signed/mqtt_p1 have no such counter at all. Reaching for it
|
||||||
|
# directly would turn the whole status page into a 500 on every other transport.
|
||||||
|
ha_rows = p1_rows(_fed(SOURCE_HA))
|
||||||
|
check("...and the line is unharmed on a transport that has no such counter",
|
||||||
|
isinstance(ha_rows, list) and len(ha_rows) == 1 and "unchanged" not in ha_rows[0])
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")
|
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
"""Runnable check for the startup gate. `python3 test_validate.py`
|
||||||
|
|
||||||
|
Two halves, and the second is the one that matters. The first asserts that the
|
||||||
|
three refusable configurations are refused. The second asserts that everything
|
||||||
|
on validate.py's "deliberately not refused" list still STARTS - because the way
|
||||||
|
a validator dies is not by missing a fault, it is by refusing something harmless
|
||||||
|
until somebody deletes the call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.validate import (
|
||||||
|
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
||||||
|
validate_startup_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def base(**over):
|
||||||
|
"""A configuration that must start. Everything else is this, spoilt."""
|
||||||
|
# ⚠️ The maintenance rates are named EXPLICITLY, at the values config.yaml
|
||||||
|
# ships (MAINT-04). They are not decoration: with the key absent, rule 6
|
||||||
|
# falls back to main.py's 2500 W, which against this 2000 W ceiling is a
|
||||||
|
# genuine refusal - so a base() without them would be a config that does
|
||||||
|
# not start, and every check below would be measuring the wrong thing.
|
||||||
|
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off",
|
||||||
|
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||||
|
o.update(over)
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
|
def why(problems, needle):
|
||||||
|
return any(needle in p for p in problems)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("the baseline must start, or nothing below means anything")
|
||||||
|
check("a good config with a 5000 W device is accepted",
|
||||||
|
validate_startup_config(base(), -5000, 5000) == [])
|
||||||
|
check("...and with no device reachable yet, too",
|
||||||
|
validate_startup_config(base(), None, None) == [])
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("1. max_w against the device's own rating")
|
||||||
|
# ⚠️ THE ORIGINAL BUG. main.py used to log 'the device wins' and run anyway,
|
||||||
|
# with write_setpoint() clamping, so the configured ceiling was never the real
|
||||||
|
# one and the only evidence had scrolled off the log.
|
||||||
|
p = validate_startup_config(base(max_w=8000), -5000, 5000)
|
||||||
|
check("max_w above the device maximum is refused", len(p) == 1)
|
||||||
|
check("...and the message names the configured value", why(p, "8000"))
|
||||||
|
check("...and the device's value", why(p, "5000"))
|
||||||
|
check("...and says which one wins", why(p, "THE DEVICE WINS"))
|
||||||
|
check("max_w exactly at the device maximum is fine",
|
||||||
|
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
|
||||||
|
check("max_w under it is fine",
|
||||||
|
validate_startup_config(base(max_w=2000), -5000, 5000) == [])
|
||||||
|
# The charge side is a separate rail and an asymmetric device is legal.
|
||||||
|
p = validate_startup_config(base(max_w=4000), -2500, 5000)
|
||||||
|
check("a max_w the device cannot take on the CHARGE side is refused too",
|
||||||
|
len(p) == 1 and why(p, "charge side"))
|
||||||
|
check("...naming the device's floor", why(p, "-2500"))
|
||||||
|
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
|
||||||
|
# re-runs this the moment the real rating arrives.
|
||||||
|
check("an unreachable device skips the range check rather than assuming",
|
||||||
|
validate_startup_config(base(max_w=8000), None, None) == [])
|
||||||
|
check("a non-numeric max_w is refused",
|
||||||
|
why(validate_startup_config(base(max_w="lots"), -5000, 5000), "not a number"))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("2. an actuator to write to")
|
||||||
|
# A controller with no actuator: write_setpoint() returns on the blank entity,
|
||||||
|
# so no rating is read, no heartbeat is sent, layer 1 pins the inverter at 0 W,
|
||||||
|
# and the add-on looks perfectly healthy the whole time.
|
||||||
|
for blank in ("", " ", None):
|
||||||
|
p = validate_startup_config(base(setpoint_entity=blank), None, None)
|
||||||
|
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
|
||||||
|
check("...and the message says why it is dangerous rather than untidy",
|
||||||
|
why(validate_startup_config(base(setpoint_entity=""), None, None),
|
||||||
|
"heartbeat"))
|
||||||
|
# specs §1.5: two writers on one actuator is not expressible in this schema.
|
||||||
|
check("there is exactly one way to reach the actuator",
|
||||||
|
ACTUATOR_WRITE_KEYS == ("setpoint_entity",))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("3. a meter that can never read")
|
||||||
|
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
||||||
|
# also uses. Asserted here to prove the gate actually consults it - one entry
|
||||||
|
# point, not two.
|
||||||
|
check("meter_source off asks nothing of the P1 keys",
|
||||||
|
validate_startup_config(base(meter_source="off"), -5000, 5000) == [])
|
||||||
|
check("homewizard_local with no p1_host is refused",
|
||||||
|
why(validate_startup_config(base(meter_source="homewizard_local"),
|
||||||
|
-5000, 5000), "p1_host"))
|
||||||
|
check("...and with a host it is accepted",
|
||||||
|
validate_startup_config(base(meter_source="homewizard_local",
|
||||||
|
p1_host="192.168.2.250"), -5000, 5000) == [])
|
||||||
|
check("a poll cadence at meter_max_age_s is refused at STARTUP, not just disabled",
|
||||||
|
why(validate_startup_config(base(meter_source="homewizard_local",
|
||||||
|
p1_host="m", meter_poll_s=30,
|
||||||
|
meter_max_age_s=30), -5000, 5000),
|
||||||
|
"meter_max_age_s"))
|
||||||
|
check("ha_signed with no p1_net_entity is refused",
|
||||||
|
why(validate_startup_config(base(meter_source="ha_signed"), -5000, 5000),
|
||||||
|
"p1_net_entity"))
|
||||||
|
check("a phase list disagreeing with meter_phases is refused",
|
||||||
|
why(validate_startup_config(base(meter_source="ha_signed",
|
||||||
|
p1_net_entity="sensor.n", meter_phases=3,
|
||||||
|
p1_phase_net_entities=["a", "b"]),
|
||||||
|
-5000, 5000), "phase-count"))
|
||||||
|
check("mqtt_p1 with no topic is refused",
|
||||||
|
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
|
||||||
|
"meter_mqtt_topic"))
|
||||||
|
check("ha_dsmr with only half its entities is refused",
|
||||||
|
why(validate_startup_config(base(meter_source="ha_dsmr",
|
||||||
|
p1_import_entity="sensor.i"), -5000, 5000),
|
||||||
|
"p1_export_entity"))
|
||||||
|
check("a misspelt meter_source is refused rather than silently ignored",
|
||||||
|
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
|
||||||
|
"is not one of"))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("4. meter_max_age_s against the firmware's compile-time veto")
|
||||||
|
# ⚠️ The schema allows int(5,300); the firmware's threshold is baked in at 30 s
|
||||||
|
# and wins silently. Same shape as the max_w case: a configured bound overridden
|
||||||
|
# by a lower layer, with nothing saying so.
|
||||||
|
hw = dict(meter_source="homewizard_local", p1_host="m")
|
||||||
|
p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
|
||||||
|
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
|
||||||
|
check("...naming the configured value", why(p, "120"))
|
||||||
|
check("...naming the firmware's value", why(p, "30"))
|
||||||
|
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
|
||||||
|
check("the tested pair 30/30 is accepted",
|
||||||
|
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
|
||||||
|
check("one second above it is refused",
|
||||||
|
validate_startup_config(base(**hw, meter_max_age_s=31), -5000, 5000) != [])
|
||||||
|
# Below the veto the add-on gives up FIRST, which is fail-safe and visible on
|
||||||
|
# its own status page. Only above is the silent case.
|
||||||
|
check("below the veto is fine - the add-on being stricter is not a conflict",
|
||||||
|
validate_startup_config(base(**hw, meter_max_age_s=10,
|
||||||
|
meter_poll_s=4), -5000, 5000) == [])
|
||||||
|
# ⚠️ Scope: meter_max_age_s is read by nothing but P1Ingest, and the veto keys
|
||||||
|
# off a sensor only published when ingestion runs. With the meter off it governs
|
||||||
|
# nothing, so refusing would be pure trivia.
|
||||||
|
check("with meter_source off the number governs nothing and is not refused",
|
||||||
|
validate_startup_config(base(meter_source="off", meter_max_age_s=300),
|
||||||
|
-5000, 5000) == [])
|
||||||
|
check("the constant is the firmware's, not a rounded guess",
|
||||||
|
FIRMWARE_INGEST_VETO_S == 30.0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("6. a maintenance charge the ceiling can actually accommodate (MAINT-04)")
|
||||||
|
# ⚠️ THE SHIPPED BUG. config.yaml shipped max_w 2000 against
|
||||||
|
# maintenance_charge_w 2500, so the cheap-window shaping asked for hi=-2500
|
||||||
|
# against the supervised lo=-2000 - lo > hi, arbiter clause 3, 0 W. Maintenance
|
||||||
|
# silently never charged in a cheap window on a default install.
|
||||||
|
p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
||||||
|
-5000, 5000)
|
||||||
|
check("maintenance_charge_w above max_w is refused", len(p) == 1)
|
||||||
|
check("...and the message names the charge rate", why(p, "2500"))
|
||||||
|
check("...and the ceiling", why(p, "2000"))
|
||||||
|
check("...and says which one wins", why(p, "THE CEILING WINS"))
|
||||||
|
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
|
||||||
|
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
|
||||||
|
# deleted, and it is the boundary the rule is written `>` for.
|
||||||
|
check("maintenance_charge_w exactly at max_w starts",
|
||||||
|
validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
||||||
|
-5000, 5000) == [])
|
||||||
|
check("below it starts",
|
||||||
|
validate_startup_config(base(max_w=2000, maintenance_charge_w=1500),
|
||||||
|
-5000, 5000) == [])
|
||||||
|
# Not scoped to maintenance_enabled: the pair is contradictory either way, and
|
||||||
|
# scoping would leave the trap armed for the one action - ticking the box - that
|
||||||
|
# cannot be expected to change what a charge rate means.
|
||||||
|
check("...and it is refused with maintenance_enabled off, as shipped",
|
||||||
|
why(validate_startup_config(
|
||||||
|
base(max_w=2000, maintenance_charge_w=2500,
|
||||||
|
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
||||||
|
|
||||||
|
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
|
||||||
|
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
|
||||||
|
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
||||||
|
# names the clamp every cycle. Loud, and it still moves energy - so warned, not
|
||||||
|
# refused. If someone later "fixes the asymmetry" by making this a refusal,
|
||||||
|
# this check goes red and they have to justify it.
|
||||||
|
check("maintenance_discharge_w above max_w WARNS but starts",
|
||||||
|
validate_startup_config(base(max_w=2000, maintenance_discharge_w=2500),
|
||||||
|
-5000, 5000) == [])
|
||||||
|
|
||||||
|
# The demonstration the rule is derived from, asserted rather than described.
|
||||||
|
from app.arbiter import ( # noqa: E402
|
||||||
|
Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_SAFETY, resolve,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _charge(max_w, charge_w):
|
||||||
|
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
||||||
|
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-charge_w,
|
||||||
|
reason="cheap-window charge"),
|
||||||
|
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
||||||
|
|
||||||
|
def _drain(max_w, disch_w):
|
||||||
|
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
||||||
|
Claim.set("maintenance", P_MAINTENANCE, disch_w, "drain"),
|
||||||
|
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
||||||
|
|
||||||
|
r = _charge(2000, 2500)
|
||||||
|
check("arbiter: the refused pair really does emit 0 W",
|
||||||
|
r.target_w == 0.0 and r.contradiction)
|
||||||
|
r = _charge(2000, 2000)
|
||||||
|
check("arbiter: the permitted equality really does charge",
|
||||||
|
r.target_w == -2000.0 and not r.contradiction)
|
||||||
|
r = _charge(3000, 2500)
|
||||||
|
check("arbiter: raising max_w above the rate charges at the rate",
|
||||||
|
r.target_w == -2500.0 and not r.contradiction)
|
||||||
|
r = _drain(2000, 2500)
|
||||||
|
check("arbiter: the drain SET is clamped, not contradicted",
|
||||||
|
r.target_w == 2000.0 and not r.contradiction)
|
||||||
|
check("...and the clamp names itself, which is why it is not refused",
|
||||||
|
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
||||||
|
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
||||||
|
# refuses. Read config.yaml itself rather than a transcription of it - a
|
||||||
|
# transcription is what lets the two drift apart silently. Regex, not PyYAML:
|
||||||
|
# the add-on image has no PyYAML (Dockerfile installs py3-aiohttp/py3-paho-mqtt
|
||||||
|
# only) and a check that skips on ImportError is not a check.
|
||||||
|
import os # noqa: E402
|
||||||
|
import re # noqa: E402
|
||||||
|
|
||||||
|
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||||
|
with open(_CFG, encoding="utf-8") as _fh:
|
||||||
|
_txt = _fh.read()
|
||||||
|
_shipped = {k: float(v) for k, v in re.findall(
|
||||||
|
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
||||||
|
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||||
|
check("config.yaml really was read (all four defaults found)",
|
||||||
|
set(_shipped) == {"max_w", "maintenance_charge_w",
|
||||||
|
"maintenance_discharge_w", "sign_disagree_w"})
|
||||||
|
check(f"the shipped defaults are accepted {_shipped}",
|
||||||
|
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
||||||
|
check("...and the shipped charge rate is at or under the shipped ceiling",
|
||||||
|
_shipped["maintenance_charge_w"] <= _shipped["max_w"])
|
||||||
|
check("...and the shipped discharge rate too, so it warns about nothing",
|
||||||
|
_shipped["maintenance_discharge_w"] <= _shipped["max_w"])
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("the KNOWN cross-file default divergence, pinned rather than commented")
|
||||||
|
# ⚠️ WHAT THIS GUARDS, AND WHY IT ASSERTS THE BUG RATHER THAN THE FIX.
|
||||||
|
# config.yaml and validate.py now default maintenance_charge_w to 2000, while
|
||||||
|
# main.py's two `opts.get(..., 2500)` fallbacks and MaintConfig's dataclass
|
||||||
|
# default still say 2500. So an options dict with the key ABSENT is ACCEPTED by
|
||||||
|
# rule 6 at 2000 and then built by main.py at 2500 - which is a contradiction:
|
||||||
|
#
|
||||||
|
# validate_startup_config -> [] (accepted)
|
||||||
|
# MaintConfig().charge_w -> 2500.0
|
||||||
|
# arbiter -> 0.0 W, contradiction=True
|
||||||
|
#
|
||||||
|
# That is the bug rule 6 exists to catch, passing rule 6. It is unreachable on a
|
||||||
|
# Supervisor install (every key is always supplied from config.yaml) but fully
|
||||||
|
# reachable from sim/inject.py and from tests - which is exactly where anyone
|
||||||
|
# debugging maintenance works.
|
||||||
|
#
|
||||||
|
# The fix is ONE SOURCE OF TRUTH for option defaults. That is a bigger change
|
||||||
|
# than this ticket and it lands in main.py, which another ticket is live in - so
|
||||||
|
# the divergence is RECORDED here instead of closed. The check therefore passes
|
||||||
|
# on the mismatch and FAILS when either side moves, in EITHER direction:
|
||||||
|
#
|
||||||
|
# - config.yaml moves -> FAIL: re-decide what the fallback should be
|
||||||
|
# - main.py is fixed to match -> FAIL: the divergence is gone, DELETE this
|
||||||
|
# check and the ⚠️ block in validate.py
|
||||||
|
#
|
||||||
|
# A comment cannot do that, and "a comment where a check belonged" is this
|
||||||
|
# repo's most repeated defect.
|
||||||
|
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
|
||||||
|
|
||||||
|
def _read(*parts):
|
||||||
|
with open(os.path.join(*parts), encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
# Every literal fallback for this key in main.py, whatever the call shape.
|
||||||
|
_main_falls = [float(m) for m in re.findall(
|
||||||
|
r'\.get\(\s*"maintenance_charge_w"\s*,\s*(-?\d+(?:\.\d+)?)\s*\)',
|
||||||
|
_read(_APP, "main.py"))]
|
||||||
|
# ...and MaintConfig's own dataclass default, which is the one that bites when
|
||||||
|
# main.py is bypassed entirely (a Maintenance built straight from defaults).
|
||||||
|
_dc_falls = [float(m) for m in re.findall(
|
||||||
|
r"^\s*charge_w:\s*float\s*=\s*(-?\d+(?:\.\d+)?)\s*$",
|
||||||
|
_read(_APP, "maintenance.py"), re.M)]
|
||||||
|
|
||||||
|
check(f"main.py's fallbacks are still found and still 2500 {_main_falls}",
|
||||||
|
len(_main_falls) == 2 and set(_main_falls) == {2500.0})
|
||||||
|
check(f"MaintConfig's default is still found and still 2500 {_dc_falls}",
|
||||||
|
len(_dc_falls) == 1 and set(_dc_falls) == {2500.0})
|
||||||
|
check(f"...and config.yaml still ships 2000, so the divergence is UNCHANGED "
|
||||||
|
f"(shipped {_shipped['maintenance_charge_w']:.0f}, code 2500)",
|
||||||
|
_shipped["maintenance_charge_w"] == 2000.0)
|
||||||
|
|
||||||
|
# ⚠️ And the consequence itself, through the REAL code rather than through the
|
||||||
|
# numbers above - so that if someone "fixes" this by changing a literal without
|
||||||
|
# understanding it, the thing that goes red is the behaviour, not the transcript.
|
||||||
|
from app.maintenance import MaintConfig # noqa: E402
|
||||||
|
|
||||||
|
_absent = {k: v for k, v in base().items() if k != "maintenance_charge_w"}
|
||||||
|
_built = MaintConfig().charge_w
|
||||||
|
check("with the key ABSENT the validator accepts...",
|
||||||
|
validate_startup_config(_absent, -5000, 5000) == [])
|
||||||
|
check(f"...while the code would build {_built:.0f} W against a "
|
||||||
|
f"{_absent['max_w']:.0f} W ceiling...", _built > _absent["max_w"])
|
||||||
|
_r = _charge(_absent["max_w"], _built)
|
||||||
|
check("...which the arbiter resolves to 0 W as a contradiction - the hole, "
|
||||||
|
"asserted so it cannot widen unnoticed",
|
||||||
|
_r.target_w == 0.0 and _r.contradiction)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("max_w must be a positive magnitude")
|
||||||
|
# Schema-blocked on a real install, but validate_startup_config() is also
|
||||||
|
# sim/inject.py's entry point and can be handed any dict at all.
|
||||||
|
for bad in (0, -1, -2000):
|
||||||
|
p = validate_startup_config(base(max_w=bad), -5000, 5000)
|
||||||
|
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
|
||||||
|
check("...and the message explains it would read as a trip, not a mistake",
|
||||||
|
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
|
||||||
|
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
|
||||||
|
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
|
||||||
|
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
|
||||||
|
# would report itself armed while being incapable of firing. What this check is
|
||||||
|
# FOR is "a positive max_w is not itself a refusal", so it now names a sign
|
||||||
|
# threshold the ceiling can actually reach, and the interaction gets its own
|
||||||
|
# check below.
|
||||||
|
check("the smallest sane max_w still starts",
|
||||||
|
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
||||||
|
maintenance_charge_w=1), -5000, 5000) == [])
|
||||||
|
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
||||||
|
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("what must NOT be refused - a validator that fires on trivia gets deleted")
|
||||||
|
# DOCS.md: the arbiter's `supervised` limit still binds the wire to +-max_w, so
|
||||||
|
# the only cost of the extra headroom is unwind latency after saturation.
|
||||||
|
check("integrator_max_w above max_w warns but starts",
|
||||||
|
validate_startup_config(base(max_w=2000, integrator_max_w=9000),
|
||||||
|
-5000, 5000) == [])
|
||||||
|
check("integrator_max_w 0 (follow max_w) starts",
|
||||||
|
validate_startup_config(base(integrator_max_w=0), -5000, 5000) == [])
|
||||||
|
# Fail-safe AND already a red NOT READY banner naming the entity: a refusal on
|
||||||
|
# top of that adds nothing an operator can act on.
|
||||||
|
check("a missing soc_entity is left to the readiness banner",
|
||||||
|
validate_startup_config(base(soc_entity=""), -5000, 5000) == [])
|
||||||
|
check("a missing batt_entity likewise",
|
||||||
|
validate_startup_config(base(batt_entity=""), -5000, 5000) == [])
|
||||||
|
# The transient case. ESPHome is routinely not up when the add-on boots, and
|
||||||
|
# refusing here would turn a device that arrives late into a restart loop.
|
||||||
|
check("a setpoint entity that is configured but not yet found starts",
|
||||||
|
validate_startup_config(base(), None, None) == [])
|
||||||
|
check("meter_poll_s with thin but non-zero headroom starts",
|
||||||
|
validate_startup_config(base(meter_source="homewizard_local", p1_host="m",
|
||||||
|
meter_poll_s=20, meter_max_age_s=30),
|
||||||
|
-5000, 5000) == [])
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("the refusal must be legible as a refusal, not as a crash")
|
||||||
|
p = validate_startup_config(base(max_w=8000, setpoint_entity=""), -5000, 5000)
|
||||||
|
check("several problems are all reported, not just the first", len(p) >= 1)
|
||||||
|
refuse(p) # prints the banner; the eye is the assertion for its wording
|
||||||
|
err = ConfigRefused(p)
|
||||||
|
check("ConfigRefused carries every problem", err.problems == p)
|
||||||
|
check("...and str() names one of them", str(err) == p[0])
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("the LATE refusal, when the device only shows up after the gate has run")
|
||||||
|
# ⚠️ This path had no test at all and no live demonstration - the review's
|
||||||
|
# catch. ESPHome is routinely not up when the add-on boots, so hass.limits()
|
||||||
|
# returns (None, None), the range checks are skipped, and run_control() is the
|
||||||
|
# first place the device's real rating is ever known. Everything below is about
|
||||||
|
# what happens when it turns out to conflict.
|
||||||
|
from app.main import Controller # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _LateHass:
|
||||||
|
"""Reports the range only run_control() gets to see."""
|
||||||
|
|
||||||
|
def __init__(self, lo, hi):
|
||||||
|
self.lo, self.hi = lo, hi
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
async def limits(self, entity):
|
||||||
|
return (self.lo, self.hi, 1.0)
|
||||||
|
|
||||||
|
async def number(self, *a, **k):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
async def set_number(self, entity, value):
|
||||||
|
self.writes.append(value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _Store2:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
def set(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Pub2:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
def publish(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def late(opts_over, lo, hi):
|
||||||
|
"""Drive run_control() against a device that only reports (lo, hi) now.
|
||||||
|
|
||||||
|
⚠️ Returns whether run_control() EXITED ON ITS OWN. That is the assertion
|
||||||
|
that matters: on a refusal it must return without entering the while loop,
|
||||||
|
and on a clean config it must not return at all. So the clean case is stopped
|
||||||
|
from outside after it has had time to write, rather than waited on - a
|
||||||
|
wait_for() here would just time out, which is the correct behaviour dressed
|
||||||
|
up as a failure.
|
||||||
|
"""
|
||||||
|
async def go():
|
||||||
|
stop = asyncio.Event()
|
||||||
|
hass = _LateHass(lo, hi)
|
||||||
|
# A device narrower than the shipped 5000 W schema ceiling is exactly
|
||||||
|
# the case that cannot be reached through the Supervisor on the live
|
||||||
|
# install, so it is reached here instead.
|
||||||
|
c = Controller(base(**opts_over), hass, _Store2(), _Pub2(), stop=stop)
|
||||||
|
c.auto = True
|
||||||
|
task = asyncio.create_task(c.run_control())
|
||||||
|
for _ in range(30): # up to ~3 s
|
||||||
|
if task.done():
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
exited = task.done()
|
||||||
|
if not exited:
|
||||||
|
c.stopping = True
|
||||||
|
await asyncio.wait_for(task, timeout=5)
|
||||||
|
return c, stop, hass, exited
|
||||||
|
return asyncio.run(go())
|
||||||
|
|
||||||
|
c, stop, hass, exited = late({"max_w": 5000}, -3600.0, 3600.0)
|
||||||
|
check("a device narrower than max_w, discovered late, is refused",
|
||||||
|
c.refused is not None and len(c.refused) == 1)
|
||||||
|
check("...naming both values", why(c.refused, "5000") and why(c.refused, "3600"))
|
||||||
|
check("...signalling amain to wind down", stop.is_set())
|
||||||
|
check("...and returning instead of looping forever", exited)
|
||||||
|
# The whole point: run_control() must not fall through into the while loop. A
|
||||||
|
# single write here would mean it commanded the inverter under a config it had
|
||||||
|
# just declared unusable.
|
||||||
|
check("...having never entered the control loop, so it wrote nothing",
|
||||||
|
hass.writes == [])
|
||||||
|
check("...and it is recorded in the event log an operator reads",
|
||||||
|
any("REFUSED" in e for e in c.events))
|
||||||
|
|
||||||
|
# The same path when there is no conflict: it must NOT refuse, and it must
|
||||||
|
# actually regulate - asserted by a write reaching the device, not by absence.
|
||||||
|
c2, stop2, hass2, exited2 = late({"max_w": 2000}, -5000.0, 5000.0)
|
||||||
|
check("a late device that agrees does not trip the re-check", c2.refused is None)
|
||||||
|
check("...and the control loop runs, writing to the device", hass2.writes != [])
|
||||||
|
check("...and does not signal a shutdown", not stop2.is_set() and not exited2)
|
||||||
|
|
||||||
|
# ⚠️ THE FOOTGUN THE REVIEW FOUND. `stop` used to default to None, so this
|
||||||
|
# construction was legal and the refusal path then returned into a live process
|
||||||
|
# with no control loop, no exit and no further output.
|
||||||
|
try:
|
||||||
|
Controller(base(), _LateHass(-5000.0, 5000.0), _Store2(), _Pub2())
|
||||||
|
check("a Controller with no stop Event is rejected at construction", False)
|
||||||
|
except TypeError:
|
||||||
|
check("a Controller with no stop Event is rejected at construction", True)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"""Runnable check for the sign-disagreement watchdog. `python3 test_watchdog.py`
|
||||||
|
|
||||||
|
Every case here is either a fault this must catch or a piece of NORMAL behaviour
|
||||||
|
it must not mistake for one. The second list is the longer one on purpose: an
|
||||||
|
alarm that fires on ordinary operation is worse than no alarm, because the next
|
||||||
|
person switches it off.
|
||||||
|
|
||||||
|
The headline validation is not here - it is `sim/replay_sign.py` in the infra
|
||||||
|
repo, which drives this module over the two captured bus days. This file is the
|
||||||
|
table-driven half: boundaries, timers, the latch, and the arming gate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.watchdog import (
|
||||||
|
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||||
|
)
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
T0 = datetime(2026, 8, 20, 15, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def drive(samples, cfg=None, following=True):
|
||||||
|
"""Feed (offset_s, commanded_w, grid_w) tuples. Returns (wd, trip_offset)."""
|
||||||
|
wd = SignWatchdog(cfg)
|
||||||
|
trip_at = None
|
||||||
|
for item in samples:
|
||||||
|
offset, cmd, grid = item[0], item[1], item[2]
|
||||||
|
f = item[3] if len(item) > 3 else following
|
||||||
|
if wd.evaluate(cmd, grid, f, T0 + timedelta(seconds=offset)) and trip_at is None:
|
||||||
|
trip_at = offset
|
||||||
|
return wd, trip_at
|
||||||
|
|
||||||
|
|
||||||
|
def opposed(duration_s, step=5, cmd=-1000, grid=1500, following=True, start=0):
|
||||||
|
return [(start + t, cmd, grid, following)
|
||||||
|
for t in range(0, int(duration_s) + 1, step)]
|
||||||
|
|
||||||
|
|
||||||
|
print("the fault it exists to catch")
|
||||||
|
wd, at = drive(opposed(300))
|
||||||
|
check("charging into an importing house trips", wd.tripped)
|
||||||
|
check("and trips at 180 s, not before", at == 180)
|
||||||
|
check("the reason names both signals", "-1000" in wd.reason and "+1500" in wd.reason)
|
||||||
|
print(f" e.g. {wd.reason!r}")
|
||||||
|
|
||||||
|
wd, at = drive(opposed(300, cmd=2000, grid=-2000))
|
||||||
|
check("discharging into an exporting house trips too", wd.tripped and at == 180)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("the boundaries")
|
||||||
|
wd, _ = drive(opposed(600, cmd=-300, grid=1500))
|
||||||
|
check("300 W exactly does NOT qualify (strictly greater)", not wd.tripped)
|
||||||
|
wd, _ = drive(opposed(600, cmd=-301, grid=1500))
|
||||||
|
check("301 W does", wd.tripped)
|
||||||
|
wd, _ = drive(opposed(600, cmd=-1000, grid=300))
|
||||||
|
check("a 300 W meter does not qualify either", not wd.tripped)
|
||||||
|
wd, _ = drive(opposed(175))
|
||||||
|
check("175 s of disagreement does not trip", not wd.tripped)
|
||||||
|
wd, _ = drive(opposed(180))
|
||||||
|
check("180 s does (>= hold_s)", wd.tripped)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("continuity - the rule that produced zero false positives on real data")
|
||||||
|
broken = opposed(175) + [(180, -1000, -1500)] + opposed(175, start=185)
|
||||||
|
wd, _ = drive(broken)
|
||||||
|
check("179 s, one agreeing sample, then 175 s more: no trip", not wd.tripped)
|
||||||
|
check("and the window really was reset", wd.disagree_since is not None)
|
||||||
|
long_enough = broken + opposed(200, start=365)
|
||||||
|
wd, _ = drive(long_enough)
|
||||||
|
check("but a full 180 s after the reset does trip", wd.tripped)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("things that are NOT faults")
|
||||||
|
# The load-step transient. Measured on the healthy capture: longest 30 s.
|
||||||
|
wd, _ = drive(opposed(30) + [(35 + t, 800, 900) for t in range(0, 300, 5)])
|
||||||
|
check("a 30 s load-step crossover does not trip", not wd.tripped)
|
||||||
|
|
||||||
|
# SoC 10 %, Standby, declining a 1350 W discharge. The battery does nothing, so
|
||||||
|
# the meter stays where it was and the command agrees with it.
|
||||||
|
wd, _ = drive([(t, 1350, 1350) for t in range(0, 900, 5)])
|
||||||
|
check("a battery refusing a discharge command cannot trip it", not wd.tripped)
|
||||||
|
|
||||||
|
# A frozen meter drives command and reading into agreement, not disagreement.
|
||||||
|
wd, _ = drive([(t, 800, 800) for t in range(0, 900, 5)])
|
||||||
|
check("a frozen meter cannot trip it", not wd.tripped)
|
||||||
|
|
||||||
|
# Maintenance grid charge: 2500 W of charge against an importing house, for
|
||||||
|
# hours. Opposed, both huge - and entirely correct.
|
||||||
|
wd, _ = drive(opposed(7200, cmd=-2500, grid=3000, following=False))
|
||||||
|
check("two hours of maintenance grid charge does not trip", not wd.tripped)
|
||||||
|
check("and the timer never even started", wd.disagree_since is None)
|
||||||
|
|
||||||
|
wd, _ = drive([(t, -1000, None) for t in range(0, 900, 5)])
|
||||||
|
check("a missing meter reading does not trip", not wd.tripped)
|
||||||
|
wd, _ = drive(opposed(175) + [(180, -1000, None)] + opposed(600, start=185))
|
||||||
|
check("a missing reading mid-window RESETS it rather than accumulating",
|
||||||
|
wd.tripped and wd.disagree_since is not None)
|
||||||
|
wd, at = drive(opposed(175) + [(180, -1000, None)] + opposed(600, start=185))
|
||||||
|
check("...so the trip lands 180 s after the gap, not after 180 s total",
|
||||||
|
at == 365)
|
||||||
|
|
||||||
|
wd, _ = drive([(t, 0.0, 3000) for t in range(0, 900, 5)])
|
||||||
|
check("a 0 W command is not a wrong-direction command", not wd.tripped)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("the latch")
|
||||||
|
wd, _ = drive(opposed(200))
|
||||||
|
check("tripped after the fault", wd.tripped)
|
||||||
|
# The trip commands 0 W, which removes the command side of the comparison.
|
||||||
|
for t in range(200, 900, 5):
|
||||||
|
wd.evaluate(0.0, 3000, True, T0 + timedelta(seconds=t))
|
||||||
|
check("stays tripped once the command is zeroed", wd.tripped)
|
||||||
|
for t in range(900, 1500, 5):
|
||||||
|
wd.evaluate(1000, 1000, True, T0 + timedelta(seconds=t))
|
||||||
|
check("stays tripped even once everything looks healthy again", wd.tripped)
|
||||||
|
wd.reset()
|
||||||
|
check("reset() is the only way out", not wd.tripped and wd.disagree_since is None)
|
||||||
|
check("and the reason is cleared with it", wd.reason == "")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("the thresholds are configuration, not constants")
|
||||||
|
cfg = SignWatchdogConfig(min_w=800.0, hold_s=60.0)
|
||||||
|
wd, at = drive(opposed(300, cmd=-1000, grid=1500), cfg=cfg)
|
||||||
|
check("a 60 s hold trips at 60 s", wd.tripped and at == 60)
|
||||||
|
wd, _ = drive(opposed(600, cmd=-700, grid=1500), cfg=cfg)
|
||||||
|
check("an 800 W gate ignores a 700 W command", not wd.tripped)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("the configuration that would disarm it is refused")
|
||||||
|
from app.validate import validate_startup_config # noqa: E402
|
||||||
|
|
||||||
|
base = {"setpoint_entity": "number.sp", "meter_source": "off",
|
||||||
|
"meter_phases": 1, "meter_max_age_s": 30}
|
||||||
|
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 300},
|
||||||
|
-5000, 5000)
|
||||||
|
check("300 W under a 2000 W ceiling is accepted", p == [])
|
||||||
|
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
|
||||||
|
-5000, 5000)
|
||||||
|
check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
|
||||||
|
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
|
||||||
|
# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W
|
||||||
|
# max_w is also below the 2000 W shipped maintenance charge - so without this
|
||||||
|
# the dict is refused twice, for two correct but different reasons, and the
|
||||||
|
# `len(p) == 1` below would be measuring the interaction rather than this rule.
|
||||||
|
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000,
|
||||||
|
"maintenance_charge_w": 500}, -5000, 5000)
|
||||||
|
check("a threshold above max_w is refused", len(p) == 1 and "never fire" in p[0])
|
||||||
|
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
|
||||||
|
-5000, 5000)
|
||||||
|
check("one watt under the ceiling is reachable, so accepted", p == [])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("the arming gate, derived from the REAL arbiter")
|
||||||
|
# ⚠️ THIS IS THE DECISION THE WHOLE TICKET TURNS ON, and until now it was
|
||||||
|
# asserted in three comment blocks and verified by nothing: every other case in
|
||||||
|
# this file passes `following` in as a literal. What has to hold is that the
|
||||||
|
# expression in main.py - loop won AND the emitted value points the way the loop
|
||||||
|
# asked AND is no larger - is never True for a resolution the loop did not
|
||||||
|
# actually get. So run real claims through the real resolve() and derive it.
|
||||||
|
from app.arbiter import ( # noqa: E402
|
||||||
|
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def following_from(claims, asked):
|
||||||
|
"""THE SHIPPED gate, not a copy of it.
|
||||||
|
|
||||||
|
⚠️ This used to transcribe main.py's expression with a comment saying
|
||||||
|
"keep the two in step", which is exactly the arrangement that does not keep
|
||||||
|
two things in step. Measured: editing the shipped gate alone to
|
||||||
|
`winner in ("loop", "peak")` left this suite - including the P_PEAK row below,
|
||||||
|
whose entire job is to catch that edit - completely green, while the watchdog
|
||||||
|
went blind for the duration of every peak event. Import it or the P_PEAK
|
||||||
|
assertion is decoration.
|
||||||
|
"""
|
||||||
|
r = resolve(claims)
|
||||||
|
return r, loop_is_following(r, asked)
|
||||||
|
|
||||||
|
|
||||||
|
MAXW = Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w")
|
||||||
|
DEV = Claim.limit("device", P_SAFETY, -5000, 5000, "rating")
|
||||||
|
|
||||||
|
# name, claims, what the loop asked, expected `following`
|
||||||
|
TABLE = [
|
||||||
|
("the loop alone, unopposed",
|
||||||
|
[Claim.set("loop", P_LOOP, 900)], 900, True),
|
||||||
|
("the loop bound by max_w - reduced, not redirected",
|
||||||
|
[Claim.set("loop", P_LOOP, 4000), MAXW], 4000, True),
|
||||||
|
("charging, bound by max_w on the other side",
|
||||||
|
[Claim.set("loop", P_LOOP, -4000), MAXW], -4000, True),
|
||||||
|
("maintenance owns the setpoint outright",
|
||||||
|
[Claim.set("loop", P_LOOP, 900),
|
||||||
|
Claim.set("maintenance", P_MAINTENANCE, -2500, "charge"), MAXW], 900, False),
|
||||||
|
("maintenance charge-only clamps the loop to 0 W",
|
||||||
|
[Claim.set("loop", P_LOOP, 900),
|
||||||
|
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only"),
|
||||||
|
MAXW], 900, False),
|
||||||
|
# ⚠️ THE CASE `winner == \"loop\"` ALONE WOULD HAVE GOT WRONG. 1500 W, not
|
||||||
|
# the default 2500 W: at max_w 2000 a -2500 hi against a -2000 lo is an
|
||||||
|
# arbiter contradiction, not a charge - see main.py.
|
||||||
|
("cheap-window charge REDIRECTS the loop while it still 'wins'",
|
||||||
|
[Claim.set("loop", P_LOOP, 500),
|
||||||
|
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-1500, reason="cheap-window"),
|
||||||
|
MAXW], 500, False),
|
||||||
|
("...and the contradiction at the shipped defaults is not following either",
|
||||||
|
[Claim.set("loop", P_LOOP, 500),
|
||||||
|
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-2500, reason="cheap-window"),
|
||||||
|
MAXW], 500, False),
|
||||||
|
("a lo limit RAISES the magnitude without flipping the sign",
|
||||||
|
[Claim.set("loop", P_LOOP, 200),
|
||||||
|
Claim.limit("floor", P_SAFETY, lo=800), MAXW], 200, False),
|
||||||
|
("a safety stop wins outright",
|
||||||
|
[Claim.set("loop", P_LOOP, 900),
|
||||||
|
Claim.set("safety", P_SAFETY, 0.0, "stopped"), MAXW], 900, False),
|
||||||
|
("no claims at all - the arbiter's own failsafe",
|
||||||
|
[MAXW, DEV], None, False),
|
||||||
|
("the loop asked for zero",
|
||||||
|
[Claim.set("loop", P_LOOP, 0.0), MAXW], 0.0, False),
|
||||||
|
# The forward hazard, asserted as it stands TODAY rather than as it should
|
||||||
|
# be. When peak shaving lands, these expectations are what have to change.
|
||||||
|
("peak shaving at P_PEAK disarms the gate (loop_is_following's forward hazard)",
|
||||||
|
[Claim.set("loop", P_LOOP, 900),
|
||||||
|
Claim.set("peak", P_PEAK, 1800, "shaving"), MAXW], 900, False),
|
||||||
|
# ⚠️ AND THE ROW ABOVE CANNOT DETECT A WIDENED GATE ON ITS OWN, which was
|
||||||
|
# found by trying it: with the shipped gate edited to
|
||||||
|
# `winner in ("loop", "peak")` that case STILL comes out False, because peak
|
||||||
|
# shaving asked for MORE than the loop and the magnitude term rejects it
|
||||||
|
# anyway. The winner term is masked. This row isolates it - a peak claim
|
||||||
|
# smaller than the loop's ask and pointing the same way, so `winner` is the
|
||||||
|
# only term that can decide - and it is what actually turns red if the gate
|
||||||
|
# is widened without anyone revisiting the hazard.
|
||||||
|
("...and with peak BELOW the loop's ask, only the winner term separates them",
|
||||||
|
[Claim.set("loop", P_LOOP, 1800),
|
||||||
|
Claim.set("peak", P_PEAK, 900, "shaving"), MAXW], 1800, False),
|
||||||
|
]
|
||||||
|
for name, claims, asked, expect in TABLE:
|
||||||
|
r, got = following_from(claims, asked)
|
||||||
|
check(f"{name} -> following={expect}", got is expect)
|
||||||
|
if got is not expect:
|
||||||
|
print(f" resolution was {r.explain()!r}")
|
||||||
|
|
||||||
|
# And the gate is never True when the loop did not win, whatever else is going on.
|
||||||
|
check("no configuration above arms the gate without the loop winning",
|
||||||
|
all(not following_from(c, a)[1]
|
||||||
|
for _, c, a, _ in TABLE if following_from(c, a)[0].winner != "loop"))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("end to end: the trip is COMMAND ZERO, not alarm-only")
|
||||||
|
# ⚠️ THE FAULT INJECTED HERE IS A NEGATIVE GAIN, and that is not a contrivance -
|
||||||
|
# it is the minimal faithful model of what this watchdog is for. The check
|
||||||
|
# compares the loop's OUTPUT DIRECTION against the INPUT STATE it was regulating,
|
||||||
|
# so what it detects is a loop whose two ends have become decoupled in sign: a
|
||||||
|
# gain, a claim or a strategy with its polarity inverted, or an integrator so far
|
||||||
|
# wound up that unwinding takes minutes. (Illusmart's was the last one: it
|
||||||
|
# reached -14 768 W against a +-5000 W rail while writing once every 20-60 s, and
|
||||||
|
# that arithmetic alone is 5-15 minutes of wrong-direction command.)
|
||||||
|
#
|
||||||
|
# `gain` is float(0.05,1.0) in the schema, so this is not a reachable
|
||||||
|
# configuration - it is a stand-in for a code regression, which is the case a
|
||||||
|
# unit test can actually hold the line on.
|
||||||
|
import asyncio # noqa: E402
|
||||||
|
|
||||||
|
from app.main import Controller # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _Hass:
|
||||||
|
def __init__(self):
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
async def limits(self, entity):
|
||||||
|
return (-5000.0, 5000.0, 1.0)
|
||||||
|
|
||||||
|
async def number(self, entity, invert=False):
|
||||||
|
# A house importing 2000 W, steadily, whatever the battery does.
|
||||||
|
return {"sensor.grid": 2000.0, "sensor.soc": 50.0,
|
||||||
|
"sensor.batt": 0.0}.get(entity)
|
||||||
|
|
||||||
|
async def set_number(self, entity, value):
|
||||||
|
self.writes.append(float(value))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _S:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
def set(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_time(self, *a):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _P:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
def publish(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _e2e():
|
||||||
|
hass = _Hass()
|
||||||
|
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off", "max_w": 2000, "gain": -0.6,
|
||||||
|
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
|
||||||
|
c = Controller(opts, hass, _S(), _P(), stop=asyncio.Event())
|
||||||
|
c.auto = True
|
||||||
|
c.dev_min, c.dev_max = -5000.0, 5000.0
|
||||||
|
seen_wrong_way = False
|
||||||
|
for _ in range(12):
|
||||||
|
await c.read_inputs()
|
||||||
|
await c.cycle()
|
||||||
|
if c.target < -300 and not c.sign_wd.tripped:
|
||||||
|
seen_wrong_way = True
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
return c, hass, seen_wrong_way
|
||||||
|
|
||||||
|
|
||||||
|
ctrl, hass, wrong_way = asyncio.run(_e2e())
|
||||||
|
check("the broken loop really did command charge into an import first", wrong_way)
|
||||||
|
check("the watchdog tripped", ctrl.sign_wd.tripped)
|
||||||
|
check("the commanded value is 0 W", ctrl.target == 0.0)
|
||||||
|
check("0 W actually reached the device", 0.0 in hass.writes)
|
||||||
|
check("and it got there through the failsafe claim, not by accident",
|
||||||
|
"failsafe" in ctrl.reason)
|
||||||
|
print(f" status line: {ctrl.reason!r}")
|
||||||
|
check("the status page says so in words",
|
||||||
|
any("SIGN-DISAGREEMENT FAILSAFE" in c["text"] for c in ctrl.checks()))
|
||||||
|
check("the banner leads with it",
|
||||||
|
"FAILSAFE" in ctrl.status_payload()["banner"])
|
||||||
|
check("it stays at 0 W on later cycles too",
|
||||||
|
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
|
||||||
|
|
||||||
|
# ⚠️ THIS USED TO BE `check("MQTT reports the failsafe state", ctrl.sign_wd.tripped)`
|
||||||
|
# which restates the assertion three lines above and never touches the payload.
|
||||||
|
# The `status: "failsafe"` value was shipped untested. Capture what publish()
|
||||||
|
# actually hands the broker.
|
||||||
|
published = {}
|
||||||
|
ctrl.mqtt = type("P", (), {"enabled": True,
|
||||||
|
"publish": lambda _s, v: published.update(v),
|
||||||
|
"close": lambda _s: None})()
|
||||||
|
ctrl.publish()
|
||||||
|
check("MQTT's controller-status sensor reports 'failsafe'",
|
||||||
|
published.get("status") == "failsafe")
|
||||||
|
ctrl.sign_wd.reset()
|
||||||
|
ctrl.publish()
|
||||||
|
check("...and goes back to 'running' once cleared",
|
||||||
|
published.get("status") == "running")
|
||||||
|
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("the latch survives a restart - it is the only thing between a tripped")
|
||||||
|
print("controller and the first thing an operator will try")
|
||||||
|
# ⚠️ THE BLOCKER THIS FILE MISSED FIRST TIME ROUND. `auto` is persisted and
|
||||||
|
# `tripped` was not, so reconstructing a Controller against the same Store -
|
||||||
|
# which is exactly what a container restart does - came back armed, into the
|
||||||
|
# fault, with no operator involved: tripped=False, banner NOT READY, and it
|
||||||
|
# resumed commanding -1000 then -2000 W before re-tripping 180 s later.
|
||||||
|
|
||||||
|
|
||||||
|
class _MemStore:
|
||||||
|
"""A Store that keeps its dict across "restarts", like the real file does."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.data = {"phase": "idle", "sign_tripped": False, "sign_reason": ""}
|
||||||
|
|
||||||
|
def set(self, k, v):
|
||||||
|
self.data[k] = v
|
||||||
|
|
||||||
|
def get_time(self, *a):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _restart():
|
||||||
|
shared = _MemStore()
|
||||||
|
hass1 = _Hass()
|
||||||
|
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off", "max_w": 2000, "gain": -0.6,
|
||||||
|
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
|
||||||
|
a = Controller(opts, hass1, shared, _P(), stop=asyncio.Event())
|
||||||
|
a.auto = True
|
||||||
|
a.dev_min, a.dev_max = -5000.0, 5000.0
|
||||||
|
for _ in range(12):
|
||||||
|
await a.read_inputs()
|
||||||
|
await a.cycle()
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
# The restart. Same options, same store, brand new Controller and Hass.
|
||||||
|
hass2 = _Hass()
|
||||||
|
b = Controller(opts, hass2, shared, _P(), stop=asyncio.Event())
|
||||||
|
b.auto = True
|
||||||
|
b.dev_min, b.dev_max = -5000.0, 5000.0
|
||||||
|
for _ in range(6):
|
||||||
|
await b.read_inputs()
|
||||||
|
await b.cycle()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return a, b, shared, hass2
|
||||||
|
|
||||||
|
|
||||||
|
pre, post, store, hass2 = asyncio.run(_restart())
|
||||||
|
check("it tripped before the restart", pre.sign_wd.tripped)
|
||||||
|
check("the trip reached the store", store.data["sign_tripped"] is True)
|
||||||
|
check("...with the reason beside it, so the operator is told why",
|
||||||
|
"SIGN_DISAGREEMENT" in store.data["sign_reason"])
|
||||||
|
check("the new Controller comes back TRIPPED", post.sign_wd.tripped)
|
||||||
|
check("...and says why, restored rather than re-detected",
|
||||||
|
"SIGN_DISAGREEMENT" in post.sign_wd.reason)
|
||||||
|
check("...commands 0 W from its very first cycle", post.target == 0.0)
|
||||||
|
check("...and never commanded anything else after the restart",
|
||||||
|
hass2.writes != [] and set(hass2.writes) == {0.0})
|
||||||
|
check("the banner still leads with the failsafe",
|
||||||
|
"FAILSAFE" in post.status_payload()["banner"])
|
||||||
|
# ⚠️ TWO PRESSES, and the first one must NOT clear it. `auto` is True while
|
||||||
|
# tripped, so the first press stops control; only the transition back to True
|
||||||
|
# clears. That is the documented behaviour and it is worth an assertion, because
|
||||||
|
# a one-press clear would be reachable by an operator who never read the banner.
|
||||||
|
asyncio.run(post.handle_action("auto_toggle")) # -> stopped
|
||||||
|
check("pressing stop does not clear the failsafe",
|
||||||
|
post.sign_wd.tripped and store.data["sign_tripped"] is True)
|
||||||
|
asyncio.run(post.handle_action("auto_toggle")) # -> started
|
||||||
|
check("pressing start DOES clear it, and clears the store with it",
|
||||||
|
not post.sign_wd.tripped and store.data["sign_tripped"] is False
|
||||||
|
and store.data["sign_reason"] == "")
|
||||||
|
# One more restart from the cleared store, to prove the clear persisted too.
|
||||||
|
fresh = Controller({"setpoint_entity": "number.sp", "meter_source": "off"},
|
||||||
|
_Hass(), store, _P(), stop=asyncio.Event())
|
||||||
|
check("...so the next restart comes back armed and untripped, as it should",
|
||||||
|
not fresh.sign_wd.tripped)
|
||||||
|
|
||||||
|
# ⚠️ THE WRITE ORDERING IS AN INVARIANT, SO IT GETS AN ASSERTION. main.py writes
|
||||||
|
# `sign_reason` and THEN `sign_tripped`, each one its own `save()`, so that a
|
||||||
|
# process that dies between the two leaves a reason nothing reads - harmless -
|
||||||
|
# rather than a latch with no explanation for the operator who has to decide
|
||||||
|
# whether to clear it. Documented reasoning is not enforcement: swapping those two
|
||||||
|
# lines used to pass every check in this file.
|
||||||
|
#
|
||||||
|
# The death is modelled as the second write raising. In the field it is a power
|
||||||
|
# cut or a container kill (Store.save() swallows OSError rather than raising), so
|
||||||
|
# what is asserted here is the PROPERTY - what survives on disk - not the
|
||||||
|
# mechanism.
|
||||||
|
|
||||||
|
|
||||||
|
class _CrashOnFlag(_MemStore):
|
||||||
|
def set(self, k, v):
|
||||||
|
if k == "sign_tripped" and v:
|
||||||
|
raise RuntimeError("power cut, mid-trip")
|
||||||
|
super().set(k, v)
|
||||||
|
|
||||||
|
|
||||||
|
async def _crash_mid_write():
|
||||||
|
store = _CrashOnFlag()
|
||||||
|
hass = _Hass()
|
||||||
|
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off", "max_w": 2000, "gain": -0.6,
|
||||||
|
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
|
||||||
|
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
|
||||||
|
c.auto = True
|
||||||
|
c.dev_min, c.dev_max = -5000.0, 5000.0
|
||||||
|
died = False
|
||||||
|
for _ in range(12):
|
||||||
|
try:
|
||||||
|
await c.read_inputs()
|
||||||
|
await c.cycle()
|
||||||
|
except RuntimeError:
|
||||||
|
died = True
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
return store, died
|
||||||
|
|
||||||
|
|
||||||
|
crashed_store, died = asyncio.run(_crash_mid_write())
|
||||||
|
check("the second write is the one that sets the flag", died)
|
||||||
|
check("a crash between the two writes leaves the REASON on disk",
|
||||||
|
"SIGN_DISAGREEMENT" in crashed_store.data.get("sign_reason", ""))
|
||||||
|
check("...and never a latch with nothing to explain it",
|
||||||
|
not crashed_store.data.get("sign_tripped"))
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
Reference in New Issue
Block a user