Compare commits
25
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
|
||||||
+11
-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,18 @@ 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_loop.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
⚠️ `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 five suites together are ~395 checks and finish in
|
||||||
|
seconds. 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,325 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
|
||||||
|
instead of logging a line and carrying on. `validate_startup_config()` runs in
|
||||||
|
`amain()` before the broker, the web server or any task exists, so a refusal
|
||||||
|
cannot leave half a controller running behind a UI.
|
||||||
|
|
||||||
|
It refuses only where continuing produces one of two outcomes: **(a)** a stated
|
||||||
|
limit is untrue - the operator configured a bound and a lower layer silently
|
||||||
|
enforces a different one - or **(b)** the add-on is inert and indistinguishable
|
||||||
|
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
|
||||||
|
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
|
||||||
|
become unsafe, it becomes a convincing impression of a working controller.
|
||||||
|
|
||||||
|
Four things qualify:
|
||||||
|
|
||||||
|
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
|
||||||
|
that changed behaviour. It used to warn `the device wins` and run anyway, with
|
||||||
|
`write_setpoint()` silently clamping, so the configured ceiling was never the
|
||||||
|
real one and the only evidence had scrolled off the log. The refusal names the
|
||||||
|
configured value, the device's value, and which wins.
|
||||||
|
- **No `setpoint_entity`.** A controller with no actuator: every write is a
|
||||||
|
no-op, the device rating is never read, and the ESP32 heartbeat that holds off
|
||||||
|
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
|
||||||
|
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
|
||||||
|
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
|
||||||
|
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
|
||||||
|
`meter_max_age_s`. These were already caught, but they only *disabled
|
||||||
|
ingestion*, which leaves the grid reading None forever: permanent "inputs
|
||||||
|
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
|
||||||
|
has sent anything to yet.
|
||||||
|
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
|
||||||
|
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
|
||||||
|
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
|
||||||
|
reading as fresh and keeps commanding, while the firmware has already vetoed
|
||||||
|
and is holding the inverter at 0 W - healthy meter, running controller,
|
||||||
|
nothing moving, and no line anywhere saying the number you set was overruled.
|
||||||
|
Structurally the same fault as the `max_w` case. Only *above* the veto is
|
||||||
|
refused: below it the add-on gives up first, which is fail-safe and visible on
|
||||||
|
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
|
||||||
|
be imported from a compile-time YAML substitution, so it carries a note about
|
||||||
|
the one direction in which it can go stale.
|
||||||
|
|
||||||
|
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
|
||||||
|
`validate_startup_config()` is now also the simulation suite's entry point, and
|
||||||
|
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
|
||||||
|
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
|
||||||
|
than as the typo it is.
|
||||||
|
|
||||||
|
The predicates for the meter group still live in `p1.validate_meter_options()`,
|
||||||
|
which `build_source()` also calls - one definition, two callers, not two
|
||||||
|
competing validators. Two transports gained a check they never had: `ha_dsmr`
|
||||||
|
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
|
||||||
|
which previously built a transport that could never read.
|
||||||
|
|
||||||
|
Deliberately **not** refused, because it is untidy rather than unsafe:
|
||||||
|
`integrator_max_w` above `max_w` (the arbiter still bounds the wire to ±max_w,
|
||||||
|
so the cost is unwind latency, not amperage - warned), a missing `soc_entity` /
|
||||||
|
`batt_entity` (fail-safe, and already a red NOT READY banner naming it), a
|
||||||
|
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
|
||||||
|
late; refusing would make a slow boot need a human - re-checked once the real
|
||||||
|
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
|
||||||
|
|
||||||
|
A refusal is legible as a refusal: a banner that says in words that this is a
|
||||||
|
configuration problem and nothing is broken, the numbered problems, no traceback,
|
||||||
|
and exit status 78 (`EX_CONFIG`).
|
||||||
|
|
||||||
|
**SAFETY-12.** The control loop now evaluates on a clock as well as on a change.
|
||||||
|
`run_control()` ran `cycle()` only when the meter value differed from the
|
||||||
|
previous poll, so a meter that repeated a value ran **no control cycles at
|
||||||
|
all** - while the heartbeat went on re-emitting the last command and the
|
||||||
|
firmware went on putting it on the wire every 2 s. Layer 1 stayed fed, every
|
||||||
|
indicator read healthy, and a command computed from an old reading kept being
|
||||||
|
executed through whatever the load did next. Measured on the rig during TEL-05:
|
||||||
|
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||||||
|
|
||||||
|
**It was worse than a latency limit: it was a safety claim that could not
|
||||||
|
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
|
||||||
|
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
|
||||||
|
never called again - so that claim had no way to run. Constant meter, SoC entity
|
||||||
|
dying at t=4 s, same binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
|
||||||
|
final target: 220.0 W reason: 'loop -> 220 W'
|
||||||
|
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
|
||||||
|
final target: 0.0 W reason: 'safety -> 0 W'
|
||||||
|
```
|
||||||
|
|
||||||
|
The pre-fix controller held 220 W through the entire input outage with its
|
||||||
|
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
|
||||||
|
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
|
||||||
|
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
|
||||||
|
behaviour was not "holds a stale command safely" - it was a failsafe that a
|
||||||
|
repeating meter switched off. `test_loop.py` asserts both halves.
|
||||||
|
|
||||||
|
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
|
||||||
|
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
|
||||||
|
meter it never elapses, so the cycle rate, the write sequence and therefore the
|
||||||
|
per-cycle gain the tuning was measured against are unchanged - asserted in
|
||||||
|
`test_loop.py` by driving the real loop twice on one binary (a huge
|
||||||
|
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
|
||||||
|
commanded values.
|
||||||
|
|
||||||
|
The reset is not the only thing holding that up, and the other argument is the
|
||||||
|
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
|
||||||
|
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
|
||||||
|
even a hypothetical floor racing the arrivals could not exceed the nominal
|
||||||
|
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
|
||||||
|
while `cycle_floor_s` stays above the meter's update interval, and the schema
|
||||||
|
minimum is 5 s - which is exactly the reference meter's interval. Nothing
|
||||||
|
enforces the relationship; `validate.py` is the obvious home for it and does not
|
||||||
|
have it yet.
|
||||||
|
|
||||||
|
Observed over six real minutes on the same rig, one binary, meter held at a
|
||||||
|
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
|
||||||
|
the command stuck at 220 W against a 350 W import for the rest of the window.
|
||||||
|
**After, 24 cycles**, worst gap 15.14 s, and the ordinary PI ramp doing its job -
|
||||||
|
220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W, one step per cycle,
|
||||||
|
rail at t=136 s and flat after. Three minutes with the meter parked at the
|
||||||
|
target instead: **1 cycle before, 12 after, and 19 writes either way - the same
|
||||||
|
19**. Three minutes on a normally-changing meter: **36 cycles and 49 writes on
|
||||||
|
both sides, the same values in the same order.** The heartbeat write count never
|
||||||
|
moves; the only writes the fix adds are the ten the loop was supposed to be
|
||||||
|
making all along.
|
||||||
|
|
||||||
|
15 s is bracketed, not round.
|
||||||
|
|
||||||
|
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
|
||||||
|
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
|
||||||
|
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
|
||||||
|
the poller steps over an update entirely - a ~10 s gap between changed values on
|
||||||
|
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
|
||||||
|
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
|
||||||
|
sits above the beat-skip so it does not fire on that alone; what it takes to
|
||||||
|
reach 15 s is a beat-skip *and* a failed poll coinciding.
|
||||||
|
|
||||||
|
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
|
||||||
|
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
|
||||||
|
guaranteed to have re-decided at least twice inside any window in which layer 1
|
||||||
|
might otherwise be the only thing deciding. ⚠️ The default is therefore not
|
||||||
|
independently justified at 15: if the firmware's veto ever moves, this moves
|
||||||
|
with it. Noted beside the constant in `main.py`.
|
||||||
|
|
||||||
|
It is also exactly `stale_input_s`, this system's existing answer to "how long
|
||||||
|
may we hold without re-deciding".
|
||||||
|
|
||||||
|
A floor cycle runs the **full** law on the same inputs, integrator included. In
|
||||||
|
P1 mode the freshness question is already answered upstream and answered harder
|
||||||
|
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
|
||||||
|
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
|
||||||
|
this system already calls fresh. ⚠️ That is transport-conditional: with
|
||||||
|
`meter_source: off`, `read_inputs()` takes the grid figure straight from
|
||||||
|
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
|
||||||
|
gone quiet reads as current on that path, with or without this change. Running
|
||||||
|
the full law is still right there - the alternative is a degraded second law
|
||||||
|
reasoning about an age nobody measured - but the floor does not make that path
|
||||||
|
fresh. The integrator advances per *cycle*, not per second,
|
||||||
|
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
|
||||||
|
that. A gentler second law for floor cycles would be a second set of dynamics
|
||||||
|
nobody can test on this hardware.
|
||||||
|
|
||||||
|
⚠️ **This is not a freeze detector**, and TEL-05 established one cannot be built
|
||||||
|
here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move
|
||||||
|
while the power figure legitimately repeats, so any threshold on `unchanged_s`
|
||||||
|
false-trips at the exact operating point we run at. Nothing in this change
|
||||||
|
decides whether a repeated reading is *true*. It stops "the number is the same"
|
||||||
|
from meaning "do not think". The consequence, stated plainly: against a meter
|
||||||
|
genuinely frozen at a wrong value the loop will now correct toward that wrong
|
||||||
|
value rather than hold a stale command, bounded by `max_w`, the arbiter and the
|
||||||
|
device rating. That was already what happened to a lying meter whose value
|
||||||
|
merely jittered; bit-exact repetition was never a safety property.
|
||||||
|
|
||||||
|
`DOCS.md`'s "Saturation is counted in cycles, not seconds" section documented
|
||||||
|
the old behaviour as an accepted deviation. It is **qualified, not deleted**:
|
||||||
|
the deviation survives (the window is still counted in cycles, and 3 x 15 s is
|
||||||
|
still not the specification's 10 s) but it is now bounded at 45 s instead of
|
||||||
|
open-ended, and the passage's second justification - "nothing accumulates while
|
||||||
|
it is stalled" - is called out as having covered integrator windup and not
|
||||||
|
staleness, which was the actual hazard. The same wrong argument in
|
||||||
|
`control.py`'s saturation comment is corrected too.
|
||||||
|
|
||||||
|
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
|
||||||
|
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
|
||||||
|
unsigned registers and refuse negatives, so neither fits the meter installed here.
|
||||||
|
|
||||||
|
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
|
||||||
|
directly. This exists because of something the HA paths cannot do: Home Assistant
|
||||||
|
fires no state change for a repeated reading and exposes no arrival signal at
|
||||||
|
all, so `sensor.p1_sample_age_s` on those transports measures time since the
|
||||||
|
*value changed*, not since the meter *reported*. Real captured data has gaps of
|
||||||
|
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
|
||||||
|
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
|
||||||
|
local API is a genuine arrival, so the age means what it says.
|
||||||
|
|
||||||
|
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
|
||||||
|
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
|
||||||
|
age never exceeding the poll cadence.
|
||||||
|
|
||||||
|
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
|
||||||
|
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
|
||||||
|
install that does not opt in.
|
||||||
|
|
||||||
|
Freeze detection is separate and deliberately not thresholded: a meter answering
|
||||||
|
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
|
||||||
|
energy registers carry that as `unchanged_s`, surfaced on the status line for a
|
||||||
|
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
|
||||||
|
about six minutes to move, so any threshold would false-trip at the target
|
||||||
|
operating point.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
|
||||||
|
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
|
||||||
|
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
|
||||||
|
(default 5 s, the meter's own update rate) sets the cadence.
|
||||||
|
|
||||||
|
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
|
||||||
|
the meter *reported*, and therefore the first one a firmware watchdog may
|
||||||
|
threshold.** Every HTTP response is an arrival: the meter answered, now, with
|
||||||
|
its current reading, and whether the *number* moved is not consulted. Home
|
||||||
|
Assistant cannot express that at all — a repeated reading emits no
|
||||||
|
`state_changed`, advances `last_reported` on neither serialiser, and
|
||||||
|
`state_reported` is not subscribable ("Event filter is required"). On
|
||||||
|
`ha_signed` that made a healthy meter under a flat load indistinguishable from
|
||||||
|
a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s
|
||||||
|
between changes — both past the default `meter_max_age_s` of 30, i.e. a false
|
||||||
|
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
|
||||||
|
mode.
|
||||||
|
|
||||||
|
Everything TEL-01 established is reused, not re-implemented: ingest
|
||||||
|
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
|
||||||
|
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
|
||||||
|
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
|
||||||
|
is a *missing* reading — never 0 W — and it does not reset the age.
|
||||||
|
|
||||||
|
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
|
||||||
|
freeze` injected. Still defaults to `off`.
|
||||||
|
|
||||||
|
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
|
||||||
|
detector can: a meter answering `200 OK` forever with a stale number is
|
||||||
|
arriving. The local API does expose what the HA path never had — the
|
||||||
|
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
|
||||||
|
`unchanged_s`, but that is deliberately **not** folded into the age and not
|
||||||
|
thresholded: this controller regulates grid power toward ~0 W, and at a
|
||||||
|
converged −10 W the export register needs six minutes to move by its 1 Wh
|
||||||
|
resolution while the power figure legitimately repeats. Thresholding it at 30 s
|
||||||
|
would rebuild the false-trip limit cycle at the exact operating point we aim
|
||||||
|
for. Freeze detection needs the low-power case solved first, separately. It is
|
||||||
|
shown on the status page's P1 line instead — leaving it unthresholded only
|
||||||
|
holds up if a human can read it, so now they can.
|
||||||
|
|
||||||
|
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
|
||||||
|
Assistant entity: positive = import, negative = export. That is the shape a
|
||||||
|
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
|
||||||
|
actually fitted here - which neither TEL-01 transport can read, because
|
||||||
|
`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every
|
||||||
|
exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the
|
||||||
|
per-phase capacity-tariff figures on a three-phase connection.
|
||||||
|
|
||||||
|
Everything TEL-01 established is inherited rather than re-implemented - the
|
||||||
|
new transport is a subclass of the `ha_dsmr` one overriding only which
|
||||||
|
entities it wants and how they become a sample. So ingest timestamping,
|
||||||
|
`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and
|
||||||
|
republished once a second, the plausibility bounds, and `unavailable` /
|
||||||
|
`unknown` treated as a *missing reading and never 0 W* all behave identically
|
||||||
|
across the three sources.
|
||||||
|
|
||||||
|
Still defaults to `off`; an existing install is unaffected until it opts in.
|
||||||
|
|
||||||
|
⚠️ **`sensor.p1_sample_age_s` is published on `ha_signed`, but must not yet be
|
||||||
|
thresholded by the ESP32 stale-input watchdog.** On the HA WebSocket paths the
|
||||||
|
age is stamped from `state_changed`, so it measures time since the value
|
||||||
|
*changed*, not since the meter *reported* - and Home Assistant exposes no
|
||||||
|
arrival signal for a repeated reading (no `state_changed`, no `last_reported`
|
||||||
|
movement on either serialiser, and `state_reported` is not subscribable over
|
||||||
|
the WebSocket). Measured on the ENV-01 rig against the real HomeWizard
|
||||||
|
integration. `ha_dsmr` mostly escapes it because a telegram moves several
|
||||||
|
entities at once; `ha_signed` has one, so a healthy meter under a flat load is
|
||||||
|
indistinguishable from a dead one. Our own capture has the house meter going
|
||||||
|
42.2 s and 97.0 s between changes. Raising `meter_max_age_s` does not fix that,
|
||||||
|
it only chooses which error you get; the fix is an arrival stamp from the meter
|
||||||
|
itself and is a separate ticket. Full detail in DOCS.md.
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||||||
|
bounded independently of the output clamp instead of inheriting whatever
|
||||||
|
headroom the clamp happened to leave. It also freezes while the inverter is
|
||||||
|
not tracking, rather than continuing to wind up against a command nothing is
|
||||||
|
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
|
||||||
|
"follow `max_w`", which is the existing behaviour.
|
||||||
|
|
||||||
|
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
|
||||||
|
against the previous control law confirms it decides identically at
|
||||||
|
`integrator_max_w: 0`.
|
||||||
|
|
||||||
|
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
|
||||||
|
(consumption, injection) no longer need a hand-written signed template
|
||||||
|
sensor: the subtraction moves into the add-on, done once and tested. Two
|
||||||
|
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
|
||||||
|
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
|
||||||
|
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
|
||||||
|
nothing changes for an install that does not opt in.
|
||||||
|
|
||||||
|
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
|
||||||
|
accepted telegram, recomputed against the clock and republished roughly once
|
||||||
|
a second rather than only when a telegram lands. That is deliberate - Home
|
||||||
|
Assistant only pushes a state on change, so a meter sitting at a genuinely
|
||||||
|
constant reading would otherwise look identical to a dead one. Watching the
|
||||||
|
age instead means a frozen meter shows a climbing age, not a flat line. The
|
||||||
|
firmware watchdog subscribes to this exact entity id.
|
||||||
|
|
||||||
|
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
|
||||||
|
republishing its last telegram still "arrives", so the age cannot detect
|
||||||
|
that particular failure - prefer `ha_dsmr` where both are available. And a
|
||||||
|
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
|
||||||
|
to call the reading stale, then 15 s of `stale_input_s` on top), which is
|
||||||
|
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
|
||||||
|
|
||||||
## 0.2.1
|
## 0.2.1
|
||||||
|
|
||||||
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
||||||
|
|||||||
+250
-15
@@ -48,6 +48,219 @@ Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
|||||||
those serve cached values, and a stale reading here ends the maintenance charge
|
those serve cached values, and a stale reading here ends the maintenance charge
|
||||||
phase having charged nothing.
|
phase having charged nothing.
|
||||||
|
|
||||||
|
### P1 meter ingestion
|
||||||
|
|
||||||
|
`meter_entity` above expects one signed sensor, which usually means a template
|
||||||
|
someone wrote by hand. Setting `meter_source` moves the whole derivation into
|
||||||
|
the add-on, where it is done once and tested, and replaces `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 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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_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 |
|
||||||
|
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
|
||||||
|
| `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_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
|
||||||
|
|
||||||
|
`meter_max_age_s` and `stale_input_s` **stack**. They are two different clocks
|
||||||
|
and neither one is the whole answer:
|
||||||
|
|
||||||
|
| step | option | default |
|
||||||
|
|---|---|---|
|
||||||
|
| telegrams stop, P1 sample goes stale, grid power starts reading *missing* | `meter_max_age_s` | 30 s |
|
||||||
|
| inputs have been missing long enough for the loop to command 0 W | `stale_input_s` | 15 s |
|
||||||
|
| **total, meter death → 0 W commanded by this add-on** | | **45 s** |
|
||||||
|
|
||||||
|
So in P1 mode `stale_input_s` is *not* "how long inputs may be missing before
|
||||||
|
commanding 0 W" measured from the meter dying — it is measured from the moment
|
||||||
|
the P1 sample already went stale. Size the pair together: the ESP32's own
|
||||||
|
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
|
||||||
|
that layer is unaffected by either option.
|
||||||
|
|
||||||
|
There is **no fallback to an inverter-side power figure**, deliberately. The
|
||||||
|
inverter's own AC power tracks its battery almost perfectly and the real meter
|
||||||
|
hardly at all, so a controller that failed over to it would be regulating
|
||||||
|
against its own output while looking healthy.
|
||||||
|
|
||||||
|
The `mqtt_p1` payload is one JSON object per telegram, and the schema is strict —
|
||||||
|
a key it does not recognise is a telegram from something other than what was
|
||||||
|
tested, and guessing a key here means guessing a kilowatt:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"import_w": 1234.0,
|
||||||
|
"export_w": 0.0,
|
||||||
|
"phases": [{"import_w": 500, "export_w": 0},
|
||||||
|
{"import_w": 400, "export_w": 0},
|
||||||
|
{"import_w": 334, "export_w": 0}],
|
||||||
|
"timestamp": "2026-08-24T18:00:05+02:00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`phases` and `timestamp` are optional; `timestamp` must carry a UTC offset. Where
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### `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`
|
||||||
|
|
||||||
|
Published over MQTT discovery whenever a broker is available: **seconds since the
|
||||||
|
newest accepted telegram**, refreshed every second rather than only when a
|
||||||
|
telegram lands. The ESP32's stale-input watchdog subscribes to this exact entity
|
||||||
|
id, so do not rename it.
|
||||||
|
|
||||||
|
The reason it is recomputed against the clock is that Home Assistant only pushes
|
||||||
|
a state when the state *changes*. A meter sitting at a genuinely constant reading
|
||||||
|
emits nothing, which is indistinguishable — to anything watching the value — from
|
||||||
|
a meter that has died. Watching the age instead separates the two: it climbs when
|
||||||
|
telegrams stop and resets when they arrive, whatever the reading says.
|
||||||
|
|
||||||
|
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
|
||||||
|
behind it would trip the firmware watchdog on a system that is working fine.
|
||||||
|
|
||||||
|
> **Known limit, `mqtt_p1`.** The age measures *arrival*. On the MQTT path a
|
||||||
|
> bridge that is stuck republishing its last telegram keeps arriving, so the age
|
||||||
|
> stays near zero and a frozen meter still looks fresh. Detecting *that* needs a
|
||||||
|
> change-detector rather than an arrival-detector, and it is not in this version.
|
||||||
|
|
||||||
|
> ⚠️ **Known limit, `ha_signed` — do not drive a watchdog off this age yet.**
|
||||||
|
> 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
|
||||||
|
|
||||||
| option | default | meaning |
|
| option | default | meaning |
|
||||||
@@ -59,10 +272,11 @@ phase having charged nothing.
|
|||||||
| `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 |
|
||||||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W |
|
| `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" |
|
||||||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||||||
|
|
||||||
#### Saturation is counted in cycles, not seconds
|
#### Saturation is counted in cycles, not seconds
|
||||||
@@ -71,22 +285,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+235
-22
@@ -39,6 +39,8 @@ from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
|||||||
from .hass import HomeAssistant
|
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 .validate import ConfigRefused, refuse, validate_startup_config
|
||||||
from . import web
|
from . import web
|
||||||
|
|
||||||
OPTIONS_PATH = "/data/options.json"
|
OPTIONS_PATH = "/data/options.json"
|
||||||
@@ -55,11 +57,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)),
|
||||||
@@ -90,6 +103,17 @@ class Controller:
|
|||||||
store,
|
store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
||||||
|
# single-entity meter_entity path, so an existing install is unchanged
|
||||||
|
# until it opts in.
|
||||||
|
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
||||||
|
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
||||||
|
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)))
|
||||||
self.target = 0.0
|
self.target = 0.0
|
||||||
@@ -121,8 +145,18 @@ class Controller:
|
|||||||
# -- io ------------------------------------------------------------------
|
# -- io ------------------------------------------------------------------
|
||||||
async def read_inputs(self) -> None:
|
async def read_inputs(self) -> None:
|
||||||
o = self.o
|
o = self.o
|
||||||
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
if self.p1_enabled:
|
||||||
bool(o.get("meter_invert")))
|
# ⚠️ P1 is the only authoritative measurement of what the utility
|
||||||
|
# sees (§5.1). When it is stale this is None, which falls into the
|
||||||
|
# existing "inputs missing -> command 0 W" path below. There is
|
||||||
|
# deliberately NO fallback to an inverter-side figure: the
|
||||||
|
# inverter's own AC power correlates 0.998 with battery power and
|
||||||
|
# 0.09 with the real meter, so a controller that failed over to it
|
||||||
|
# would be regulating against its own output.
|
||||||
|
self.grid = self.p1.net_w
|
||||||
|
else:
|
||||||
|
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
||||||
|
bool(o.get("meter_invert")))
|
||||||
self.soc = await self.hass.number(o.get("soc_entity", ""))
|
self.soc = await self.hass.number(o.get("soc_entity", ""))
|
||||||
self.batt = await self.hass.number(o.get("batt_entity", ""),
|
self.batt = await self.hass.number(o.get("batt_entity", ""),
|
||||||
bool(o.get("batt_invert")))
|
bool(o.get("batt_invert")))
|
||||||
@@ -281,19 +315,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
|
||||||
@@ -306,14 +433,27 @@ class Controller:
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
def publish(self) -> None:
|
def publish(self) -> None:
|
||||||
self.mqtt.publish({
|
values = {
|
||||||
"setpoint": self.target,
|
"setpoint": self.target,
|
||||||
"grid": self.grid,
|
"grid": self.grid,
|
||||||
"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",
|
"status": "running" if self.auto else "stopped",
|
||||||
})
|
}
|
||||||
|
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
||||||
|
# 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
|
||||||
|
# ingester feeding it, so published_age_s would be time-since-startup
|
||||||
|
# climbing without bound - i.e. every existing install would cross the
|
||||||
|
# threshold within 30 s and pin its inverter at 0 W forever. Publishing
|
||||||
|
# nothing leaves the entity non-existent, which is the status quo and
|
||||||
|
# what has_state() in the firmware is checking for.
|
||||||
|
if self.p1_enabled:
|
||||||
|
# Recomputed here, once a second, on purpose - see P1Ingest.
|
||||||
|
values["p1_age"] = round(self.p1.published_age_s, 1)
|
||||||
|
self.mqtt.publish(values)
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
"""Deterministic wind-down. Do not skip this."""
|
"""Deterministic wind-down. Do not skip this."""
|
||||||
@@ -326,11 +466,39 @@ class Controller:
|
|||||||
def checks(self) -> list:
|
def checks(self) -> list:
|
||||||
o = self.o
|
o = self.o
|
||||||
out = []
|
out = []
|
||||||
for label, value, entity in (
|
if self.p1_enabled:
|
||||||
("grid power", self.grid, o.get("meter_entity")),
|
age = self.p1.published_age_s
|
||||||
("battery SoC", self.soc, o.get("soc_entity")),
|
if self.p1.stale:
|
||||||
("battery power", self.batt, o.get("batt_entity")),
|
out.append({"ok": False, "warn": False,
|
||||||
):
|
"text": f"P1 meter ({o.get('meter_source')}): no reading for "
|
||||||
|
f"{age:.0f} s (limit {self.p1.max_age_s:.0f} s)"
|
||||||
|
+ (f" - last error: {self.p1.last_error}"
|
||||||
|
if self.p1.last_error 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,
|
||||||
|
"text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, "
|
||||||
|
f"{age:.0f} s old, {self.p1.samples} telegrams, "
|
||||||
|
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")),
|
||||||
|
("battery power", self.batt, o.get("batt_entity"))]
|
||||||
|
if not self.p1_enabled:
|
||||||
|
# In P1 mode the check above replaces this one; leaving both in
|
||||||
|
# would report "no entity configured" for a meter_entity that is
|
||||||
|
# correctly unused, i.e. a permanent false NOT READY.
|
||||||
|
rows.insert(0, ("grid power", self.grid, o.get("meter_entity")))
|
||||||
|
for label, value, entity in rows:
|
||||||
if not entity:
|
if not entity:
|
||||||
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
|
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
|
||||||
elif value is None:
|
elif value is None:
|
||||||
@@ -452,11 +620,32 @@ 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
|
||||||
# purpose: this crashed the add-on once already (paho 1.x vs 2.x) and
|
# purpose: this crashed the add-on once already (paho 1.x vs 2.x) and
|
||||||
# took the control loop down with it.
|
# took the control loop down with it.
|
||||||
|
broker = None
|
||||||
try:
|
try:
|
||||||
broker = await hass.mqtt_service()
|
broker = await hass.mqtt_service()
|
||||||
pub = MqttPublisher(
|
pub = MqttPublisher(
|
||||||
@@ -464,6 +653,7 @@ async def amain() -> None:
|
|||||||
broker.get("port", 1883) if broker else 1883,
|
broker.get("port", 1883) if broker else 1883,
|
||||||
broker.get("username") if broker else None,
|
broker.get("username") if broker else None,
|
||||||
broker.get("password") if broker else None,
|
broker.get("password") if broker else None,
|
||||||
|
omit=() if is_enabled(opts) else ("p1_age",),
|
||||||
)
|
)
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
|
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
|
||||||
@@ -475,24 +665,40 @@ 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):
|
||||||
loop.add_signal_handler(sig, stop.set)
|
loop.add_signal_handler(sig, stop.set)
|
||||||
|
|
||||||
task = asyncio.create_task(controller.run_control())
|
tasks = [asyncio.create_task(controller.run_control())]
|
||||||
|
|
||||||
|
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
||||||
|
# off the control loop: telegrams arrive every ~5 s and the loop would
|
||||||
|
# decimate them, so the 15-minute average - the capacity-tariff billing
|
||||||
|
# unit - would be computed from a fraction of the data.
|
||||||
|
p1_source = build_source(opts, controller.p1, session, broker)
|
||||||
|
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()))
|
||||||
|
|
||||||
await stop.wait()
|
await stop.wait()
|
||||||
|
|
||||||
await controller.shutdown()
|
await controller.shutdown()
|
||||||
task.cancel()
|
for task in tasks:
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
task.cancel()
|
||||||
await task
|
for task in tasks:
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
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:
|
||||||
@@ -500,6 +706,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__":
|
||||||
|
|||||||
@@ -45,6 +45,33 @@ 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"),
|
||||||
|
# ⚠️ THE LIVE ENTITY IS `sensor.goodwe_p1_sample_age`, NOT what this
|
||||||
|
# object_id spells. An earlier version of this comment said the id "must be
|
||||||
|
# exactly sensor.p1_sample_age_s" - that name has never existed on any HA,
|
||||||
|
# and believing it is how a safety layer gets disarmed by someone tidying up.
|
||||||
|
#
|
||||||
|
# On HA 2026.8 `object_id` in a discovery payload does NOT determine the
|
||||||
|
# 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),
|
||||||
]
|
]
|
||||||
|
|
||||||
BASE = "goodwe_ctl"
|
BASE = "goodwe_ctl"
|
||||||
@@ -52,7 +79,12 @@ AVAILABILITY = f"{BASE}/availability"
|
|||||||
|
|
||||||
|
|
||||||
class MqttPublisher:
|
class MqttPublisher:
|
||||||
def __init__(self, host, port, username=None, password=None):
|
def __init__(self, host, port, username=None, password=None, omit=()):
|
||||||
|
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||||
|
# sensor that nothing will ever publish to is not harmless here:
|
||||||
|
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||||
|
# never fed is a worse signal than one that does not exist at all.
|
||||||
|
self.omit = set(omit)
|
||||||
self.enabled = mqtt is not None and bool(host)
|
self.enabled = mqtt is not None and bool(host)
|
||||||
self.client = None
|
self.client = None
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
@@ -86,6 +118,8 @@ class MqttPublisher:
|
|||||||
|
|
||||||
def _announce(self) -> None:
|
def _announce(self) -> None:
|
||||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||||
|
if key in self.omit:
|
||||||
|
continue
|
||||||
cfg = {
|
cfg = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"object_id": object_id,
|
"object_id": object_id,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
# --- 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)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: GoodWe RS485 Controller
|
name: GoodWe RS485 Controller
|
||||||
version: "0.2.1"
|
version: "0.4.0"
|
||||||
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
|
||||||
@@ -39,6 +39,45 @@ options:
|
|||||||
batt_invert: false
|
batt_invert: false
|
||||||
setpoint_entity: ""
|
setpoint_entity: ""
|
||||||
|
|
||||||
|
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||||
|
# `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
|
||||||
|
# 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_phases: 1
|
||||||
|
meter_max_age_s: 30
|
||||||
|
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
|
||||||
|
# (import - export); do NOT point these at a signed template sensor.
|
||||||
|
p1_import_entity: ""
|
||||||
|
p1_export_entity: ""
|
||||||
|
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
|
||||||
|
# three-phase connection; the list length must equal meter_phases.
|
||||||
|
p1_phase_import_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
|
||||||
gain: 0.6
|
gain: 0.6
|
||||||
@@ -51,6 +90,7 @@ 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
|
||||||
|
|
||||||
# --- maintenance -----------------------------------------------------------
|
# --- maintenance -----------------------------------------------------------
|
||||||
@@ -81,6 +121,26 @@ schema:
|
|||||||
batt_invert: bool
|
batt_invert: bool
|
||||||
setpoint_entity: str
|
setpoint_entity: str
|
||||||
|
|
||||||
|
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
|
||||||
|
# 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
|
||||||
|
# quietly wrong number.
|
||||||
|
meter_phases: int(1,3)
|
||||||
|
meter_max_age_s: int(5,300)
|
||||||
|
meter_poll_s: int(1,60)
|
||||||
|
meter_mqtt_topic: str?
|
||||||
|
p1_host: str?
|
||||||
|
p1_import_entity: str?
|
||||||
|
p1_export_entity: str?
|
||||||
|
p1_phase_import_entities:
|
||||||
|
- str
|
||||||
|
p1_phase_export_entities:
|
||||||
|
- 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)
|
||||||
slew_w: int(50,5000)
|
slew_w: int(50,5000)
|
||||||
@@ -92,6 +152,7 @@ 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
|
||||||
|
|
||||||
maintenance_enabled: bool
|
maintenance_enabled: bool
|
||||||
|
|||||||
@@ -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")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
"""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."""
|
||||||
|
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off"}
|
||||||
|
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("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"))
|
||||||
|
check("the smallest sane max_w still starts",
|
||||||
|
validate_startup_config(base(max_w=1), -5000, 5000) == [])
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
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")
|
||||||
Reference in New Issue
Block a user