Compare commits
25
Commits
147456c2a2
..
CFG-01
@@ -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
|
||||
+1
-1
@@ -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 |
|
||||
| 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 |
|
||||
|
||||
@@ -1,5 +1,210 @@
|
||||
# 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`).
|
||||
|
||||
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
|
||||
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
|
||||
unsigned registers and refuse negatives, so neither fits the meter installed here.
|
||||
|
||||
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
|
||||
directly. This exists because of something the HA paths cannot do: Home Assistant
|
||||
fires no state change for a repeated reading and exposes no arrival signal at
|
||||
all, so `sensor.p1_sample_age_s` on those transports measures time since the
|
||||
*value changed*, not since the meter *reported*. Real captured data has gaps of
|
||||
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
|
||||
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
|
||||
local API is a genuine arrival, so the age means what it says.
|
||||
|
||||
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
|
||||
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
|
||||
age never exceeding the poll cadence.
|
||||
|
||||
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
|
||||
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
|
||||
install that does not opt in.
|
||||
|
||||
Freeze detection is separate and deliberately not thresholded: a meter answering
|
||||
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
|
||||
energy registers carry that as `unchanged_s`, surfaced on the status line for a
|
||||
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
|
||||
about six minutes to move, so any threshold would false-trip at the target
|
||||
operating point.
|
||||
|
||||
## Unreleased
|
||||
|
||||
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
|
||||
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
|
||||
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
|
||||
(default 5 s, the meter's own update rate) sets the cadence.
|
||||
|
||||
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
|
||||
the meter *reported*, and therefore the first one a firmware watchdog may
|
||||
threshold.** Every HTTP response is an arrival: the meter answered, now, with
|
||||
its current reading, and whether the *number* moved is not consulted. Home
|
||||
Assistant cannot express that at all — a repeated reading emits no
|
||||
`state_changed`, advances `last_reported` on neither serialiser, and
|
||||
`state_reported` is not subscribable ("Event filter is required"). On
|
||||
`ha_signed` that made a healthy meter under a flat load indistinguishable from
|
||||
a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s
|
||||
between changes — both past the default `meter_max_age_s` of 30, i.e. a false
|
||||
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
|
||||
mode.
|
||||
|
||||
Everything TEL-01 established is reused, not re-implemented: ingest
|
||||
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
|
||||
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
|
||||
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
|
||||
is a *missing* reading — never 0 W — and it does not reset the age.
|
||||
|
||||
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
|
||||
freeze` injected. Still defaults to `off`.
|
||||
|
||||
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
|
||||
detector can: a meter answering `200 OK` forever with a stale number is
|
||||
arriving. The local API does expose what the HA path never had — the
|
||||
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
|
||||
`unchanged_s`, but that is deliberately **not** folded into the age and not
|
||||
thresholded: this controller regulates grid power toward ~0 W, and at a
|
||||
converged −10 W the export register needs six minutes to move by its 1 Wh
|
||||
resolution while the power figure legitimately repeats. Thresholding it at 30 s
|
||||
would rebuild the false-trip limit cycle at the exact operating point we aim
|
||||
for. Freeze detection needs the low-power case solved first, separately. It is
|
||||
shown on the status page's P1 line instead — leaving it unthresholded only
|
||||
holds up if a human can read it, so now they can.
|
||||
|
||||
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
|
||||
Assistant entity: positive = import, negative = export. That is the shape a
|
||||
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
|
||||
actually fitted here - which neither TEL-01 transport can read, because
|
||||
`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every
|
||||
exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the
|
||||
per-phase capacity-tariff figures on a three-phase connection.
|
||||
|
||||
Everything TEL-01 established is inherited rather than re-implemented - the
|
||||
new transport is a subclass of the `ha_dsmr` one overriding only which
|
||||
entities it wants and how they become a sample. So ingest timestamping,
|
||||
`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and
|
||||
republished once a second, the plausibility bounds, and `unavailable` /
|
||||
`unknown` treated as a *missing reading and never 0 W* all behave identically
|
||||
across the three sources.
|
||||
|
||||
Still defaults to `off`; an existing install is unaffected until it opts in.
|
||||
|
||||
⚠️ **`sensor.p1_sample_age_s` is published on `ha_signed`, but must not yet be
|
||||
thresholded by the ESP32 stale-input watchdog.** On the HA WebSocket paths the
|
||||
age is stamped from `state_changed`, so it measures time since the value
|
||||
*changed*, not since the meter *reported* - and Home Assistant exposes no
|
||||
arrival signal for a repeated reading (no `state_changed`, no `last_reported`
|
||||
movement on either serialiser, and `state_reported` is not subscribable over
|
||||
the WebSocket). Measured on the ENV-01 rig against the real HomeWizard
|
||||
integration. `ha_dsmr` mostly escapes it because a telegram moves several
|
||||
entities at once; `ha_signed` has one, so a healthy meter under a flat load is
|
||||
indistinguishable from a dead one. Our own capture has the house meter going
|
||||
42.2 s and 97.0 s between changes. Raising `meter_max_age_s` does not fix that,
|
||||
it only chooses which error you get; the fix is an arrival stamp from the meter
|
||||
itself and is a separate ticket. Full detail in DOCS.md.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||||
bounded independently of the output clamp instead of inheriting whatever
|
||||
headroom the clamp happened to leave. It also freezes while the inverter is
|
||||
not tracking, rather than continuing to wind up against a command nothing is
|
||||
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
|
||||
"follow `max_w`", which is the existing behaviour.
|
||||
|
||||
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
|
||||
against the previous control law confirms it decides identically at
|
||||
`integrator_max_w: 0`.
|
||||
|
||||
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
|
||||
(consumption, injection) no longer need a hand-written signed template
|
||||
sensor: the subtraction moves into the add-on, done once and tested. Two
|
||||
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
|
||||
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
|
||||
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
|
||||
nothing changes for an install that does not opt in.
|
||||
|
||||
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
|
||||
accepted telegram, recomputed against the clock and republished roughly once
|
||||
a second rather than only when a telegram lands. That is deliberate - Home
|
||||
Assistant only pushes a state on change, so a meter sitting at a genuinely
|
||||
constant reading would otherwise look identical to a dead one. Watching the
|
||||
age instead means a frozen meter shows a climbing age, not a flat line. The
|
||||
firmware watchdog subscribes to this exact entity id.
|
||||
|
||||
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
|
||||
republishing its last telegram still "arrives", so the age cannot detect
|
||||
that particular failure - prefer `ha_dsmr` where both are available. And a
|
||||
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
|
||||
to call the reading stale, then 15 s of `stale_input_s` on top), which is
|
||||
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
|
||||
|
||||
## 0.2.1
|
||||
|
||||
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
||||
|
||||
+191
-9
@@ -51,21 +51,84 @@ phase having charged nothing.
|
||||
### P1 meter ingestion
|
||||
|
||||
`meter_entity` above expects one signed sensor, which usually means a template
|
||||
someone wrote by hand. A Belgian P1 meter does not publish one: it publishes two
|
||||
**unsigned** registers, consumption and injection. Setting `meter_source` moves
|
||||
that subtraction into the add-on, where it is done once and tested, and replaces
|
||||
`meter_entity` entirely.
|
||||
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 |
|
||||
| `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: grid power reads as *missing*, and the existing failsafe commands 0 W |
|
||||
| `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
|
||||
@@ -89,6 +152,55 @@ tested, and guessing a key here means guessing a kilowatt:
|
||||
it is present it is used for the age, which is what stops a retained message
|
||||
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
|
||||
@@ -102,6 +214,53 @@ emits nothing, which is indistinguishable — to anything watching the value —
|
||||
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
|
||||
|
||||
| option | default | meaning |
|
||||
@@ -113,12 +272,35 @@ telegrams stop and resets when they arrive, whatever the reading says.
|
||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||
| `step_w` | 10 | Quantisation |
|
||||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. **Do not set to 1** |
|
||||
| `integrator_max_w` | 3000 | Bound on the loop's accumulator, separate from `max_w`. Caps how much stale error can be waiting to unwind when the sign flips. **Keep it above `max_w`, and do not set it equal to `max_w`** |
|
||||
| `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** |
|
||||
| `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 |
|
||||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W |
|
||||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
|
||||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||||
|
||||
#### Saturation is counted in cycles, not seconds
|
||||
|
||||
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||||
**cycles** instead, and that is a deliberate, accepted deviation rather than an
|
||||
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
|
||||
meter value differs from the previous poll. At the reference P1's ~5 s update rate the
|
||||
default of 3 cycles is usually around 15 s, but there is **no guaranteed wall-clock
|
||||
window** - a meter that repeats the same value stalls the counter for as long as it
|
||||
repeats.
|
||||
|
||||
Two reasons that is acceptable:
|
||||
|
||||
- the control law is a pure function with no clock, which is what makes it testable
|
||||
without hardware, and a seconds-based window would have to live in the controller;
|
||||
- a stalled counter is a detection-latency limit and not a runaway risk. The condition
|
||||
that stalls it - an unchanging meter - stops the whole loop, so nothing accumulates
|
||||
while it is stalled.
|
||||
|
||||
If a guaranteed window matters on your site, raise `saturation_cycles` for a fast meter,
|
||||
and treat the figure as "N meter updates" rather than "N seconds".
|
||||
|
||||
#### Why `target_grid_w` is not zero
|
||||
|
||||
The deadband is a one-way ratchet: any resting point inside it holds until
|
||||
|
||||
@@ -28,16 +28,22 @@ class Tuning:
|
||||
step_w: int = 10
|
||||
saturation_w: float = 500.0
|
||||
saturation_cycles: int = 3
|
||||
# ⚠️ The integrator's OWN bound, and deliberately not max_w. A commercial
|
||||
# controller on this same site clamped only its output and still reported
|
||||
# 14 768 W: with the inverter switched off its integrator climbed ~130 W
|
||||
# every 4 s past 10 kW while the output sat on the 5 kW rail, so the moment
|
||||
# the error flipped there were minutes of accumulated wind to burn off
|
||||
# before the command moved at all. Bounding the accumulator is what makes
|
||||
# recovery time finite; bounding the output only hides it.
|
||||
# Headroom above max_w is wanted (a legitimate large error must not be
|
||||
# truncated at the rail), headroom without limit is the bug.
|
||||
integrator_max_w: float = 3000.0
|
||||
# The integrator's own bound. None means "follow max_w", which is the
|
||||
# default and the recommended setting.
|
||||
#
|
||||
# ⚠️ DO NOT RAISE THIS ABOVE max_w without a measurement to justify it.
|
||||
# Every watt of integrator above the rail is a watt of wind that has to be
|
||||
# burned off before the command can start moving the other way, i.e. extra
|
||||
# cycles of discharge into an already-exporting meter after every
|
||||
# saturation event. Measured on the closed-loop sim, 4000 W load dropped to
|
||||
# 0: at integrator_max_w == max_w the command is 1000 W two cycles later; at
|
||||
# 1.5x max_w it is 1800 W. The output clamp already bounds what reaches the
|
||||
# wire, so headroom here buys nothing but unwind latency.
|
||||
#
|
||||
# It is a separate key because it has to be able to be SMALLER than max_w,
|
||||
# which is the only direction that buys anything: it caps unwind latency
|
||||
# below what the rail implies. Merging it into max_w would take that away.
|
||||
integrator_max_w: float | None = None
|
||||
# What the meter should rest at, in W. Negative = a slight export.
|
||||
# ⚠️ The deadband is a one-way ratchet: any resting point inside it holds
|
||||
# forever, and the meter's IMPORT register counts every positive one with
|
||||
@@ -54,9 +60,9 @@ class Decision:
|
||||
sat_count: int
|
||||
frozen: bool
|
||||
reason: str
|
||||
# The integrator AFTER this cycle, pre-clamp-to-max_w. Carry it back in as
|
||||
# `i_w` next cycle; that is what keeps it a separate quantity from the
|
||||
# command, which is the whole point of the bound above.
|
||||
# The integrator AFTER this cycle, before the output clamp, the slew limit
|
||||
# and quantisation. Carry it back in as `i_w` next cycle; that is what keeps
|
||||
# it a separate quantity from the command.
|
||||
i_w: float = 0.0
|
||||
|
||||
|
||||
@@ -89,14 +95,19 @@ def compute(
|
||||
# lets slew be larger than saturation_w.
|
||||
#
|
||||
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
||||
# "3 samples" (§10.3). Cycles are authoritative here because this function
|
||||
# has no clock - it is driven one cycle per meter update by run_control(),
|
||||
# which only calls cycle() when the meter value changes. At the ~5 s
|
||||
# HomeWizard P1 cadence the default 3 cycles is ~15 s, i.e. the stricter
|
||||
# reading of the two. On a faster meter it is not, so saturation_cycles is
|
||||
# configurable and must be raised to keep the window over 10 s.
|
||||
# ponytail: a seconds-based window would mean plumbing wall-clock or dt
|
||||
# into a pure function whose whole value is that it has neither.
|
||||
# "3 samples" (§10.3). This counts CYCLES, and a cycle is not a unit of
|
||||
# time: run_control() calls cycle() only when the meter value CHANGES
|
||||
# (`if self.grid != last_grid`), so three cycles is three distinct meter
|
||||
# readings and nothing more. At the reference P1's ~5 s update rate that is
|
||||
# usually ~15 s, but there is no upper bound on it - a meter that repeats a
|
||||
# value stalls the counter.
|
||||
#
|
||||
# That is a detection-latency limit, not a windup hazard: the same
|
||||
# condition that stalls the counter stalls the whole loop, so nothing
|
||||
# accumulates in the meantime either. If a wall-clock window is ever
|
||||
# required, it belongs in Controller (which has a clock) and not here.
|
||||
# ponytail: this function is worth keeping clockless; the ceiling is that
|
||||
# saturation_cycles cannot express a guaranteed number of seconds.
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||
frozen = sat_count >= tuning.saturation_cycles
|
||||
@@ -112,32 +123,78 @@ def compute(
|
||||
|
||||
# --- the integrator ----------------------------------------------------
|
||||
# This loop is in velocity form: the accumulator IS the commanded power, so
|
||||
# for years "the integrator" and "the output" were one variable and could
|
||||
# not be bounded apart. `i_w` is that accumulator made explicit. A caller
|
||||
# that passes nothing gets the old behaviour exactly - seeded from the last
|
||||
# command every cycle - and main.py carries it instead, which is what turns
|
||||
# the two clamps below into two independent limits.
|
||||
# "the integrator" and "the output" were one variable and could not be
|
||||
# bounded apart. `i_w` is that accumulator made explicit; main.py carries it
|
||||
# between cycles, which is what turns the two clamps into two limits.
|
||||
#
|
||||
# Passing i_w=None re-seeds it from the last command every cycle. With
|
||||
# integrator_max_w following max_w that reduces this function to the exact
|
||||
# velocity form it replaced, frozen branch included - asserted by an
|
||||
# exhaustive comparison against a transcription of the old law in
|
||||
# test_control.py, not by inspection. Break either the gate or the bound
|
||||
# below and that test is what tells you the equivalence went with it.
|
||||
if i_w is None:
|
||||
i_w = float(prev_w)
|
||||
limit = tuning.max_w if tuning.integrator_max_w is None else tuning.integrator_max_w
|
||||
|
||||
if abs(error) < tuning.deadband_w:
|
||||
reason = "deadband"
|
||||
else:
|
||||
step_i = tuning.gain * error
|
||||
# ⚠️ Freeze means "may not wind FURTHER", not "may not move". A strict
|
||||
# freeze would strand the command at whatever it had reached until the
|
||||
# inverter started tracking again - and the inverter is not tracking,
|
||||
# that is what saturation means, so nothing would ever release it. The
|
||||
# unwind direction is the escape route and stays open; the same rule is
|
||||
# applied again to the output below.
|
||||
if not frozen or abs(i_w + step_i) < abs(i_w):
|
||||
i_w = i_w + step_i
|
||||
moved = i_w + tuning.gain * error
|
||||
# ⚠️ Freeze means "may not wind FURTHER in the direction it is already
|
||||
# pushing". It may fall, cross zero, or reverse outright.
|
||||
#
|
||||
# It must NOT be encoded as "only corrections that shrink |i_w|": that
|
||||
# is unsatisfiable for BOTH signs of error whenever the correction is
|
||||
# larger than twice the integrator, i.e. every time the integrator is
|
||||
# near zero. The loop then sits at its last value forever, because what
|
||||
# clears the freeze is the inverter tracking again and not-tracking is
|
||||
# the definition of saturation. Measured on that encoding: 0 W held
|
||||
# indefinitely into a 2 kW import, where this form recovers next cycle.
|
||||
#
|
||||
# This is the same asymmetric rule the output freeze uses below, which
|
||||
# has been in service on real hardware. It is applied here as well
|
||||
# because the requirement is that the INTEGRATOR stop accumulating, not
|
||||
# only the command.
|
||||
#
|
||||
# ⚠️ EXACTLY ZERO IS ITS OWN CASE, and it must be handled explicitly
|
||||
# rather than falling into one of the two branches. "May not wind
|
||||
# further in the direction it is already pushing" has no referent at
|
||||
# zero: nothing is wound, and neither direction is "further". Writing
|
||||
# this as `if i_w > 0 ... else ...` silently files zero under
|
||||
# rising-only and permanently blocks the first push toward charging -
|
||||
# the same deadlock as the shrink-only encoding above, mirrored in sign,
|
||||
# and reachable because main.py resets i_w to exactly 0.0 on every stop
|
||||
# and every reseed. Measured before the fix: 12 800 of 25 920 frozen
|
||||
# ticks at i_w == 0.0 held the integrator, 8 304 of them changing the
|
||||
# emitted command, worst case abandoning a 2 kW charge into a 4 kW
|
||||
# export.
|
||||
#
|
||||
# Freezing at zero would also be pointless: the freeze exists to stop
|
||||
# accumulation running away, and a first step from zero is bounded by
|
||||
# the gain, the output clamp and the slew limit like any other.
|
||||
if not frozen or i_w == 0.0:
|
||||
i_w = moved
|
||||
elif i_w > 0:
|
||||
i_w = min(moved, i_w)
|
||||
else:
|
||||
i_w = max(moved, i_w)
|
||||
|
||||
# ⚠️ Applied EVERY cycle, frozen or not, and before the output clamp: the
|
||||
# freeze is conditional, this bound is not. Order matters only in that the
|
||||
# command below is derived from the already-bounded integrator, so no
|
||||
# accumulated value can reach the wire even once.
|
||||
i_w = max(-tuning.integrator_max_w, min(tuning.integrator_max_w, i_w))
|
||||
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
||||
# bound is not. It is what makes the worst-case unwind time finite and
|
||||
# knowable instead of a function of how long the error happened to stand.
|
||||
bounded = max(-limit, min(limit, i_w))
|
||||
if bounded != i_w:
|
||||
# ⚠️ SAFETY-03 (alarm whenever the loop winds into a rail) must watch
|
||||
# for THIS, not for "clamped" below. At the default limit == max_w the
|
||||
# integrator bound is reached first and the command derived from it can
|
||||
# then never exceed max_w, so "clamped" is unreachable on a default
|
||||
# install - it survives only for a configuration that deliberately lets
|
||||
# the integrator run above the rail. Two reasons rather than one
|
||||
# because the two events want different alarms: "i-clamped" is the loop
|
||||
# winding, "clamped" is a command that came out over the rating anyway.
|
||||
reason = "i-clamped"
|
||||
i_w = bounded
|
||||
want = i_w
|
||||
|
||||
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
||||
|
||||
+109
-14
@@ -39,7 +39,8 @@ from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .hass import HomeAssistant
|
||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||
from .mqtt import MqttPublisher
|
||||
from .p1 import P1Ingest, build_source
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .validate import ConfigRefused, refuse, validate_startup_config
|
||||
from . import web
|
||||
|
||||
OPTIONS_PATH = "/data/options.json"
|
||||
@@ -56,11 +57,22 @@ def load_options() -> dict:
|
||||
|
||||
|
||||
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.hass = hass
|
||||
self.store = store
|
||||
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(
|
||||
gain=float(opts.get("gain", 0.6)),
|
||||
@@ -71,7 +83,11 @@ class Controller:
|
||||
step_w=int(opts.get("step_w", 10)),
|
||||
saturation_w=float(opts.get("saturation_w", 500)),
|
||||
saturation_cycles=int(opts.get("saturation_cycles", 3)),
|
||||
integrator_max_w=float(opts.get("integrator_max_w", 3000)),
|
||||
# 0 / unset means "follow max_w", which is the recommended
|
||||
# value. Read the note in control.py before raising it above
|
||||
# max_w: every watt above the rail is unwind latency.
|
||||
integrator_max_w=(float(opts["integrator_max_w"])
|
||||
if opts.get("integrator_max_w") else None),
|
||||
)
|
||||
self.maint = Maintenance(
|
||||
MaintConfig(
|
||||
@@ -92,7 +108,11 @@ class Controller:
|
||||
# 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 = str(opts.get("meter_source", "off")) not in ("off", "")
|
||||
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
|
||||
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
|
||||
@@ -295,10 +315,29 @@ class Controller:
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
if 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:
|
||||
self.log_event(
|
||||
f"clamp {self.tuning.max_w:.0f} W exceeds the device maximum "
|
||||
f"{self.dev_max:.0f} W - the device wins")
|
||||
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
|
||||
# exists to remove: a max_w above the device's rating meant
|
||||
# write_setpoint() silently clamped and the configured ceiling was
|
||||
# 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()
|
||||
heartbeat = float(self.o.get("heartbeat_s", 10))
|
||||
@@ -320,16 +359,27 @@ class Controller:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def publish(self) -> None:
|
||||
self.mqtt.publish({
|
||||
values = {
|
||||
"setpoint": self.target,
|
||||
"grid": self.grid,
|
||||
"battery": self.batt,
|
||||
"soc": self.soc,
|
||||
"phase": self.maint.phase,
|
||||
"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.
|
||||
"p1_age": round(self.p1.published_age_s, 1),
|
||||
})
|
||||
values["p1_age"] = round(self.p1.published_age_s, 1)
|
||||
self.mqtt.publish(values)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Deterministic wind-down. Do not skip this."""
|
||||
@@ -351,10 +401,22 @@ class Controller:
|
||||
+ (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"{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:
|
||||
@@ -484,6 +546,26 @@ async def amain() -> None:
|
||||
except Exception as _e: # noqa: BLE001
|
||||
_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
|
||||
# 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
|
||||
@@ -497,6 +579,7 @@ async def amain() -> None:
|
||||
broker.get("port", 1883) if broker else 1883,
|
||||
broker.get("username") 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
|
||||
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
|
||||
@@ -508,10 +591,10 @@ async def amain() -> None:
|
||||
|
||||
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)
|
||||
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
@@ -525,6 +608,7 @@ async def amain() -> None:
|
||||
# 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()
|
||||
@@ -537,6 +621,10 @@ async def amain() -> None:
|
||||
await task
|
||||
await runner.cleanup()
|
||||
_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:
|
||||
@@ -544,6 +632,13 @@ def main() -> None:
|
||||
asyncio.run(amain())
|
||||
except KeyboardInterrupt:
|
||||
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__":
|
||||
|
||||
@@ -45,13 +45,32 @@ SENSORS = [
|
||||
("soc", "goodwe_battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
||||
("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"),
|
||||
("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"),
|
||||
# ⚠️ This one deliberately breaks the goodwe_ prefix above: the entity id
|
||||
# must be exactly `sensor.p1_sample_age_s`, because SAFETY-01's firmware
|
||||
# watchdog subscribes to that literal id and the ENV-01 simulation rig
|
||||
# asserts on it. Renaming it silently disarms a safety layer. It is seconds
|
||||
# since the newest accepted P1 telegram, republished every second so that a
|
||||
# meter frozen at a constant value still shows a climbing age - which is the
|
||||
# false-trip that this entity exists to remove.
|
||||
# ⚠️ 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),
|
||||
]
|
||||
|
||||
@@ -60,7 +79,12 @@ AVAILABILITY = f"{BASE}/availability"
|
||||
|
||||
|
||||
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.client = None
|
||||
if not self.enabled:
|
||||
@@ -94,6 +118,8 @@ class MqttPublisher:
|
||||
|
||||
def _announce(self) -> None:
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in self.omit:
|
||||
continue
|
||||
cfg = {
|
||||
"name": name,
|
||||
"object_id": object_id,
|
||||
|
||||
+548
-16
@@ -4,13 +4,22 @@ Everything downstream trusts this module: the safety checks, the capacity-tariff
|
||||
peak, the optimizer, the control loop's sign. So three things happen here and
|
||||
nowhere else.
|
||||
|
||||
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 meter exposes two UNSIGNED
|
||||
registers - consumption and injection - never one signed figure. Net power
|
||||
is `import_w - export_w`, positive = import, and that subtraction is done
|
||||
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 read over DSMR exposes two
|
||||
UNSIGNED registers - consumption and injection. Net power is
|
||||
`import_w - export_w`, positive = import, and that subtraction is done
|
||||
exactly once, here (spec §5.2: "the derivation is the EMS's job, not a
|
||||
template the user has to write"). A second copy of it somewhere else is a
|
||||
second chance to invert the control loop.
|
||||
|
||||
Some P1 readers - the HomeWizard P1 among them - publish the OTHER shape:
|
||||
one SIGNED figure, positive = import, and no unsigned registers at all.
|
||||
`split_signed()` fans that back out into the same two magnitudes, so there
|
||||
is still exactly one internal representation and one sign convention. ⚠️ It
|
||||
lives here, next to the subtraction, for the same reason the subtraction
|
||||
does: the moment a user is asked to write two template sensors that split a
|
||||
signed value, the sign convention is back in unreviewed YAML underneath a
|
||||
safety input, which is precisely what §5.2 moved into the EMS.
|
||||
|
||||
2. THE INGEST TIMESTAMP. Every accepted sample is stamped on arrival. A value
|
||||
with no age is a value that cannot be trusted (§5.2), and staleness is the
|
||||
failsafe trigger (§11.2).
|
||||
@@ -47,6 +56,8 @@ _LOG = logging.getLogger("goodwe.p1")
|
||||
|
||||
SOURCE_HA = "ha_dsmr"
|
||||
SOURCE_MQTT = "mqtt_p1"
|
||||
SOURCE_HA_SIGNED = "ha_signed"
|
||||
SOURCE_HOMEWIZARD = "homewizard_local"
|
||||
|
||||
QUARTER_S = 900
|
||||
|
||||
@@ -78,7 +89,7 @@ class P1Sample:
|
||||
ingest_ts: datetime # tz-aware UTC, set at ingest
|
||||
ingest_mono: float # time.monotonic() at ingest - see age_s()
|
||||
telegram_ts: datetime | None # from the telegram, where the source has one
|
||||
source: str # SOURCE_HA | SOURCE_MQTT
|
||||
source: str # one of the SOURCE_* constants above
|
||||
import_w: float # unsigned magnitude, as the meter reports it
|
||||
export_w: float # unsigned magnitude
|
||||
net_w: float # import_w - export_w (+ import, - export)
|
||||
@@ -129,6 +140,29 @@ def _watts(value, what: str) -> float:
|
||||
return out
|
||||
|
||||
|
||||
def split_signed(net_w) -> tuple[float, float]:
|
||||
"""One signed figure -> the (import, export) magnitudes the module speaks.
|
||||
|
||||
The inverse of make_sample's subtraction, and the easy direction: no second
|
||||
register to disagree with, so there is nothing to mix a fresh reading with a
|
||||
stale one. `+` is import, `-` is export - verified in test_p1.py against real
|
||||
captured readings from this house's own meter, not against a datasheet.
|
||||
|
||||
⚠️ Exactly one of the two comes out non-zero. Splitting into `(max(v,0),
|
||||
max(-v,0))` rather than clamping keeps `import_w - export_w == v` exactly, so
|
||||
the signed value the meter published survives the round trip bit for bit -
|
||||
a control loop must not be steered by a number that changed on the way in.
|
||||
|
||||
⚠️ Validation is `_watts`, the same gate the unsigned path uses: NaN,
|
||||
infinity, non-numbers and the §20 open-question-5 unsigned-decode
|
||||
contamination (64954 for -582 W) are all refused here rather than believed.
|
||||
A signed source makes that check MORE important, not less - on this path
|
||||
64954 is not obviously wrong the way a negative "unsigned" register is.
|
||||
"""
|
||||
v = _watts(net_w, "net")
|
||||
return (v, 0.0) if v >= 0 else (0.0, -v)
|
||||
|
||||
|
||||
def make_sample(source: str, import_w, export_w, *, phases: int,
|
||||
phase_import_w=None, phase_export_w=None,
|
||||
telegram_ts: datetime | None = None,
|
||||
@@ -213,8 +247,16 @@ class QuarterAverager:
|
||||
halves credited to the two blocks, never attributed wholly to either.
|
||||
"""
|
||||
|
||||
def __init__(self, phases: int = 1):
|
||||
def __init__(self, phases: int = 1, max_hold_s: float = 30.0):
|
||||
self.phases = phases
|
||||
# ⚠️ How long one sample may be held forward before the series is
|
||||
# treated as a gap rather than a plateau. Without this the meter can die
|
||||
# while importing 5 kW, come back ten minutes later, and the hold-forward
|
||||
# credits 5 kW x 600 s to the capacity-tariff accumulator - a fabricated
|
||||
# peak, on a permanent record, from data that was never measured. Set
|
||||
# from meter_max_age_s: the point past which the reading is not trusted
|
||||
# for control is the point past which it must not be billed either.
|
||||
self.max_hold_s = float(max_hold_s)
|
||||
self._block: int | None = None # epoch seconds of the block start
|
||||
self._acc = 0.0 # W*s of offtake in the open block
|
||||
self._pp_acc = [0.0] * phases
|
||||
@@ -273,16 +315,22 @@ class QuarterAverager:
|
||||
return closed
|
||||
|
||||
cursor = self._last_t
|
||||
# Beyond this instant the held value stops being evidence of anything.
|
||||
# The stretch from here to `t` is walked so the block boundaries are
|
||||
# still crossed correctly, but nothing is accumulated and `_elapsed`
|
||||
# does not grow - which is what makes a closed block, always divided by
|
||||
# the full 900 s, actually get dragged down by the missing coverage.
|
||||
hold_end = self._last_t + self.max_hold_s
|
||||
while True:
|
||||
end = self._block + QUARTER_S
|
||||
stop = min(t, end)
|
||||
dt = stop - cursor
|
||||
if dt > 0:
|
||||
self._acc += max(self._last_net, 0.0) * dt
|
||||
covered = max(0.0, min(stop, hold_end) - cursor)
|
||||
if covered > 0:
|
||||
self._acc += max(self._last_net, 0.0) * covered
|
||||
if self._last_pp is not None:
|
||||
for i, v in enumerate(self._last_pp[: self.phases]):
|
||||
self._pp_acc[i] += max(v, 0.0) * dt
|
||||
self._elapsed += dt
|
||||
self._pp_acc[i] += max(v, 0.0) * covered
|
||||
self._elapsed += covered
|
||||
cursor = stop
|
||||
if stop < end:
|
||||
break
|
||||
@@ -321,7 +369,9 @@ class P1Ingest:
|
||||
def __init__(self, phases: int = 1, max_age_s: float = 30.0):
|
||||
self.phases = phases
|
||||
self.max_age_s = float(max_age_s)
|
||||
self.averager = QuarterAverager(phases)
|
||||
# The same threshold governs control and billing: a reading too old to
|
||||
# steer by is too old to bill by. See QuarterAverager.max_hold_s.
|
||||
self.averager = QuarterAverager(phases, max_hold_s=self.max_age_s)
|
||||
self.blocks: list[QuarterBlock] = []
|
||||
self.samples = 0
|
||||
self.parse_errors = 0
|
||||
@@ -477,9 +527,17 @@ class HaDsmrSource:
|
||||
continue
|
||||
payload = json.loads(msg.data)
|
||||
if payload.get("id") == 2 and payload.get("type") == "result":
|
||||
# ⚠️ Prime the cache, but do NOT build a sample from it.
|
||||
# get_states returns whatever HA currently holds, which
|
||||
# after a Core restart is a RestoreEntity value of unknown
|
||||
# age. Stamping that with ingest_ts=now resets the age to
|
||||
# zero and reports a fresh meter that may have been dead for
|
||||
# an hour - a synthetic sample hiding the outage from the
|
||||
# watchdog that exists to catch it. The cache is what lets
|
||||
# the FIRST real state_changed build a complete sample; the
|
||||
# age stays honest until one arrives.
|
||||
for obj in payload.get("result") or []:
|
||||
self._absorb(obj.get("entity_id"), obj.get("state"))
|
||||
self._schedule()
|
||||
elif payload.get("type") == "event":
|
||||
data = (payload.get("event") or {}).get("data") or {}
|
||||
if data.get("entity_id") not in self.ids:
|
||||
@@ -644,17 +702,474 @@ class MqttP1Source:
|
||||
self.ingest.reject(err)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# transport 3: Home Assistant WebSocket, one signed entity
|
||||
# --------------------------------------------------------------------------- #
|
||||
class HaSignedSource(HaDsmrSource):
|
||||
"""The same websocket, subscribed to ONE signed power entity.
|
||||
|
||||
For readers that publish net power as a single signed figure - a HomeWizard
|
||||
P1's `sensor.p1_meter_active_power`, positive = import - rather than the two
|
||||
unsigned DSMR registers. This is the meter actually installed at the house,
|
||||
and `ha_dsmr` cannot read it: it needs two registers and refuses a negative
|
||||
one outright, which is every exporting telegram.
|
||||
|
||||
⚠️ A subclass, not a copy. The connect / auth / subscribe / reconnect /
|
||||
`_absorb` machinery above is transport, not shape, and it has already been
|
||||
debugged once - notably "prime the cache from get_states but never build a
|
||||
sample out of it" and "a reconnect emits nothing". Only `_wanted` (which
|
||||
entity ids) and `build` (how they become a sample) differ, so only those two
|
||||
are overridden. Everything TEL-01 established therefore applies unchanged:
|
||||
ingest timestamping, meter_max_age_s, the clock-recomputed age sensor, and
|
||||
`unavailable` treated as a missing reading rather than 0 W.
|
||||
|
||||
⚠️ THE AGE ON THIS TRANSPORT MEASURES TIME SINCE THE VALUE CHANGED, not time
|
||||
since the meter reported, and on one entity those are very different things.
|
||||
Home Assistant offers no arrival signal for a repeated reading: it emits no
|
||||
`state_changed`, it does not advance `last_reported` on either serialiser,
|
||||
and `state_reported` cannot be subscribed to over the websocket at all
|
||||
("Event filter is required for event state_reported"). All three measured on
|
||||
the ENV-01 rig against the real HomeWizard integration with the meter frozen
|
||||
- 0 state_changed in 70 s, no timestamp movement anywhere.
|
||||
|
||||
`ha_dsmr` mostly escapes it because a DSMR telegram moves several entities at
|
||||
once. This transport has ONE, so a healthy meter under a flat load looks
|
||||
exactly like a dead one - and our own capture has the real meter going 42.2 s
|
||||
and 97.0 s between changes, both past the default max_age_s of 30. Hence
|
||||
DOCS.md: sensor.p1_sample_age_s is correct while the value moves and must not
|
||||
yet be thresholded by the firmware watchdog on this transport. Raising
|
||||
meter_max_age_s does not fix it, it only chooses which of the two errors you
|
||||
get. The fix is an arrival stamp from the meter itself - reading the
|
||||
HomeWizard local API rather than an HA entity - which is a separate ticket.
|
||||
|
||||
⚠️ The debounce is inherited but does nothing useful here, and that is fine:
|
||||
one telegram is one entity, so there is no burst of per-entity events to
|
||||
coalesce and no window in which a new reading sits beside a stale one. It
|
||||
costs one scheduled sleep per telegram at ~0.2 Hz. Left in place rather than
|
||||
special-cased, because a second code path through build() is a second place
|
||||
for the sign to go wrong.
|
||||
"""
|
||||
|
||||
def _wanted(self) -> set[str]:
|
||||
out = set()
|
||||
if self.entities.get("net"):
|
||||
out.add(self.entities["net"])
|
||||
out.update(e for e in self.entities.get("phase_net") or [] if e)
|
||||
return out
|
||||
|
||||
def build(self) -> bool:
|
||||
"""Assemble one sample from the cache. Returns True if one was accepted."""
|
||||
net_id = self.entities.get("net")
|
||||
if net_id not in self.cache:
|
||||
return False
|
||||
pn = [self.cache.get(e) for e in self.entities.get("phase_net") or []]
|
||||
if pn and None in pn:
|
||||
return False # incomplete phase set: wait, do not guess
|
||||
try:
|
||||
imp, exp = split_signed(self.cache[net_id])
|
||||
pi = pe = None
|
||||
if pn:
|
||||
# ponytail: this split is arithmetically redundant today -
|
||||
# make_sample subtracts the two lists again and does not
|
||||
# sign-check per-phase figures, so handing it the signed values
|
||||
# with a zero export list produces the identical tuple. Verified:
|
||||
# mutating it that way leaves all 174 checks green, i.e. no test
|
||||
# can tell the difference, and it is recorded here rather than
|
||||
# left as a silent equivalent mutant for the next reviewer to
|
||||
# rediscover. Kept because `phase_import_w` means a MAGNITUDE:
|
||||
# a negative in it is the double-signing that make_sample refuses
|
||||
# outright for the connection-level registers, and the day that
|
||||
# check is extended per-phase the shortcut breaks the meter, not
|
||||
# the test.
|
||||
pairs = [split_signed(v) for v in pn]
|
||||
pi = [a for a, _ in pairs]
|
||||
pe = [b for _, b in pairs]
|
||||
self.ingest.submit(make_sample(
|
||||
SOURCE_HA_SIGNED, imp, exp,
|
||||
phases=self.ingest.phases,
|
||||
phase_import_w=pi, phase_export_w=pe,
|
||||
# ⚠️ No telegram_ts, for the same reason as ha_dsmr: HA's
|
||||
# last_changed is when the VALUE changed, which on a steady meter
|
||||
# is minutes ago while the telegram is current. sensor.
|
||||
# p1_sample_age_s is what covers a genuinely frozen meter.
|
||||
))
|
||||
return True
|
||||
except P1Error as err:
|
||||
self.ingest.reject(err)
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# transport 4: the HomeWizard P1's own local API
|
||||
# --------------------------------------------------------------------------- #
|
||||
HOMEWIZARD_PATH = "/api/v1/data"
|
||||
HOMEWIZARD_PORT = 80
|
||||
|
||||
|
||||
def parse_homewizard(doc, phases: int, *, now: datetime | None = None,
|
||||
ingest_mono: float | None = None) -> P1Sample:
|
||||
"""One `GET /api/v1/data` document -> a sample. Raises P1Error.
|
||||
|
||||
The meter's own JSON, of which we read four keys:
|
||||
|
||||
{"active_power_w": -5710.0, # SIGNED, + import - export
|
||||
"active_power_l1_w": ..., "active_power_l2_w": ..., "..._l3_w": ...,
|
||||
"total_power_import_kwh": 1234.567, "total_power_export_kwh": 890.123}
|
||||
|
||||
Same shape as `ha_signed` - one signed connection figure plus signed
|
||||
per-phase figures - so it goes through the same `split_signed` and the same
|
||||
`make_sample`. Nothing about the sign convention is re-derived here.
|
||||
|
||||
⚠️ No telegram timestamp, because the API carries none, and that is correct
|
||||
rather than a gap: the document was produced by the meter in the moment it
|
||||
answered, so the ingest stamp IS the measurement time. This is the whole
|
||||
reason the transport exists - see HomeWizardLocalSource.
|
||||
|
||||
⚠️ Per-phase figures are taken only when the meter serves ALL `phases` of
|
||||
them. A single-phase HomeWizard returns `active_power_l2_w: null`, and a
|
||||
partial set must not become a fabricated tuple. Unlike the HA transports
|
||||
there is no "wait for the rest" case to worry about: every field here came
|
||||
out of ONE response, so a missing phase cannot be a stale phase, and the
|
||||
connection-level reading is still a genuine, complete measurement. Dropping
|
||||
the whole sample over an absent per-phase field would turn a healthy meter
|
||||
into a climbing age, which is the exact false-trip this ticket removes.
|
||||
"""
|
||||
if not isinstance(doc, dict):
|
||||
raise P1Error(f"meter response is not a JSON object ({type(doc).__name__})")
|
||||
|
||||
imp, exp = split_signed(doc.get("active_power_w"))
|
||||
|
||||
pi = pe = None
|
||||
# ⚠️ range(phases), not the legs the meter served: a 3-phase meter configured
|
||||
# as meter_phases: 1 yields a per-phase tuple covering ONE leg of three.
|
||||
# Control is unaffected (it uses the connection figure), but the
|
||||
# capacity-tariff peak is understated. Filed separately - not fixed here.
|
||||
legs = [doc.get(f"active_power_l{i + 1}_w") for i in range(phases)]
|
||||
if all(v is not None for v in legs):
|
||||
pairs = [split_signed(v) for v in legs]
|
||||
pi = [a for a, _ in pairs]
|
||||
pe = [b for _, b in pairs]
|
||||
|
||||
return make_sample(SOURCE_HOMEWIZARD, imp, exp, phases=phases,
|
||||
phase_import_w=pi, phase_export_w=pe,
|
||||
ingest_ts=now, ingest_mono=ingest_mono)
|
||||
|
||||
|
||||
def measurement_fingerprint(doc) -> tuple:
|
||||
"""The part of a response that a genuinely new measurement has to move.
|
||||
|
||||
Power plus both energy registers. A meter under any real load advances a
|
||||
kWh counter (1 Wh resolution: ~10 s at 350 W), so this changes even when the
|
||||
power figure happens to repeat. Used ONLY for `unchanged_s` - see there for
|
||||
why it must not be allowed anywhere near the age.
|
||||
"""
|
||||
if not isinstance(doc, dict):
|
||||
return ()
|
||||
return (doc.get("active_power_w"),
|
||||
doc.get("total_power_import_kwh"),
|
||||
doc.get("total_power_export_kwh"))
|
||||
|
||||
|
||||
class HomeWizardLocalSource:
|
||||
"""Polls the meter's own local HTTP API instead of a Home Assistant entity.
|
||||
|
||||
⚠️ THIS IS THE ONLY TRANSPORT WHOSE AGE MEASURES ARRIVAL. That is the entire
|
||||
reason it exists, and it is not an optimisation - it is the difference
|
||||
between an age sensor a firmware watchdog may threshold and one it may not.
|
||||
|
||||
Every HTTP response is a genuine arrival: the meter answered, now, with its
|
||||
current reading. Whether the NUMBER moved is irrelevant, so a healthy meter
|
||||
under a flat load resets the age exactly like a busy one. Home Assistant
|
||||
cannot provide this at all - it emits `state_changed` only on a change, does
|
||||
not advance `last_reported` on either serialiser for a repeat, and refuses a
|
||||
`state_reported` subscription outright ("Event filter is required"). Measured
|
||||
on the ENV-01 rig over 70 s of a frozen meter, and independently against the
|
||||
live house over ten repeated readings. So on `ha_signed` the age means "time
|
||||
since the value changed", and our own capture of this house's meter has
|
||||
42.2 s and 97.0 s between changes - both past the default `meter_max_age_s`
|
||||
of 30, i.e. a false trip to 0 W on a perfectly healthy meter.
|
||||
|
||||
Everything else is TEL-01's pipeline unchanged: ingest timestamping,
|
||||
`meter_max_age_s`, the clock-recomputed age, the plausibility bounds, and a
|
||||
failed read treated as a MISSING reading rather than 0 W.
|
||||
|
||||
⚠️ A failed or timed-out poll submits nothing. It is a rejection, so the
|
||||
last good sample and its stamp stay exactly where they were and the age goes
|
||||
on climbing - which is precisely the signal a dead meter should produce.
|
||||
|
||||
⚠️ POLL CADENCE vs `meter_max_age_s`. The age can only be as fresh as the
|
||||
poll interval, so `meter_poll_s` must sit well under `meter_max_age_s` or the
|
||||
reading flaps in and out of staleness on a healthy meter. The real meter
|
||||
updates every ~5.0 s (NOTES.md:640 measures 4.97 s), so polling faster than
|
||||
that buys nothing but re-reads. Default 5 s against a 30 s max age; the
|
||||
startup check in build_source refuses `meter_poll_s >= meter_max_age_s` and
|
||||
warns past half of it.
|
||||
|
||||
ponytail: a plain `asyncio.sleep` loop rather than a scheduler, and no
|
||||
backoff. A poll that fails costs one rejection and is retried on the next
|
||||
tick; a meter that is down stays down and the age reports it. The ceiling is
|
||||
a meter so slow that polls overlap - the request timeout is held under the
|
||||
poll interval so they cannot.
|
||||
"""
|
||||
|
||||
def __init__(self, session, ingest: P1Ingest, host: str, *,
|
||||
port: int = HOMEWIZARD_PORT, poll_s: float = 5.0,
|
||||
timeout_s: float | None = None):
|
||||
self.session = session
|
||||
self.ingest = ingest
|
||||
self.host, self.port = host, int(port)
|
||||
self.poll_s = float(poll_s)
|
||||
# ⚠️ Held under the poll interval on purpose: a timeout longer than the
|
||||
# cadence queues polls behind a hung meter, and a backlog of requests
|
||||
# all landing at once would stamp several arrivals for one measurement.
|
||||
self.timeout_s = float(timeout_s) if timeout_s else max(1.0, self.poll_s * 0.8)
|
||||
self.url = f"http://{self.host}:{self.port}{HOMEWIZARD_PATH}"
|
||||
self.connected = False
|
||||
self.polls = 0
|
||||
self.poll_errors = 0
|
||||
self._fingerprint: tuple | None = None
|
||||
self._changed_mono: float | None = None
|
||||
|
||||
@property
|
||||
def unchanged_s(self) -> float | None:
|
||||
"""Seconds since the meter last served a DIFFERENT measurement.
|
||||
|
||||
⚠️ Diagnostic only, and deliberately NOT folded into
|
||||
`sensor.p1_sample_age_s`. A frozen meter (SAFETY-01's six-minute
|
||||
runaway) answers 200 OK with a stale document forever, so no arrival
|
||||
detector can see it - only the document standing still can, and this is
|
||||
that signal. It is exposed rather than acted on because it is NOT safe
|
||||
as a 30 s watchdog input: this controller regulates grid power toward
|
||||
~0 W, and at a converged -10 W the export register takes six minutes to
|
||||
advance by its 1 Wh resolution while the power figure legitimately
|
||||
repeats. Thresholding that would rebuild the very limit cycle the
|
||||
arrival stamp just removed, at the exact operating point we aim for.
|
||||
Whoever wires a freeze detector must handle the low-power case first.
|
||||
"""
|
||||
if self._changed_mono is None:
|
||||
return None
|
||||
return max(0.0, time.monotonic() - self._changed_mono)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Long-lived task: poll, submit, sleep, forever.
|
||||
|
||||
⚠️ Nothing but cancellation may end this loop. An escaping exception
|
||||
would kill the poll task for the lifetime of the add-on, and it would do
|
||||
it QUIETLY: the failure mode is safe (no submissions, the age climbs, the
|
||||
watchdog holds the battery at 0 W) but it looks identical to a dead
|
||||
meter, so the operator goes hunting the wrong device. Retry on the next
|
||||
tick instead - poll_s is already the retry cadence, so no backoff.
|
||||
"""
|
||||
_LOG.info("P1 ingest: polling %s every %.1fs", self.url, self.poll_s)
|
||||
while True:
|
||||
try:
|
||||
await self.poll_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - the task must outlive it
|
||||
_LOG.warning("P1 meter poll %s: %s - retrying in %.1fs", self.url,
|
||||
str(err) or type(err).__name__, self.poll_s)
|
||||
await asyncio.sleep(self.poll_s)
|
||||
|
||||
async def poll_once(self) -> bool:
|
||||
"""One GET. Returns True if a sample was accepted."""
|
||||
self.polls += 1
|
||||
try:
|
||||
async with self.session.get(
|
||||
self.url,
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout_s)) as resp:
|
||||
if resp.status != 200:
|
||||
raise P1Error(f"meter returned HTTP {resp.status}")
|
||||
# content_type=None: the meter's own firmware is the authority on
|
||||
# what it serves, and refusing a reading over a Content-Type
|
||||
# header would be a fabricated outage.
|
||||
# ⚠️ Kept because a real HomeWizard firmware that ever answers
|
||||
# text/plain would otherwise read as a dead meter and hold the
|
||||
# battery at 0 W. (This was once recorded here as an equivalent
|
||||
# mutant - it is not. aiohttp's json_response always sets
|
||||
# application/json, but web.Response(text=...) defaults to
|
||||
# text/plain, so the fake meter CAN serve valid JSON under the
|
||||
# wrong header, and test_p1.py now does.)
|
||||
doc = await resp.json(content_type=None)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - any read failure is a gap
|
||||
self.connected = False
|
||||
self.poll_errors += 1
|
||||
# ⚠️ str() of a bare asyncio.TimeoutError is the EMPTY STRING, so
|
||||
# f"...: {err}" renders "last error:" and then nothing - on the
|
||||
# status page, on a hung meter, at the moment the battery has just
|
||||
# dropped to 0 W and someone is reading that line to find out why.
|
||||
# The class name is the only thing that says "it timed out".
|
||||
# ⚠️ str(err), not `err or ...`: an exception object is ALWAYS truthy,
|
||||
# empty message or not, so the `or` would never reach the fallback.
|
||||
self.ingest.reject(
|
||||
f"meter poll {self.url}: {str(err) or type(err).__name__}")
|
||||
return False
|
||||
|
||||
self.connected = True
|
||||
try:
|
||||
sample = parse_homewizard(doc, self.ingest.phases)
|
||||
# ⚠️ Submitted unconditionally, INCLUDING a document identical to the
|
||||
# last one. The response is the arrival; the number is not.
|
||||
# Inside the try on purpose: submit() outside it would put a raise on
|
||||
# a path with no handler at all, killing the poll task permanently -
|
||||
# safely (the age climbs) but silently. run() backstops the rest.
|
||||
self.ingest.submit(sample)
|
||||
self._note(doc)
|
||||
except P1Error as err:
|
||||
self.ingest.reject(err)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _note(self, doc) -> None:
|
||||
fp = measurement_fingerprint(doc)
|
||||
if self._changed_mono is None or fp != self._fingerprint:
|
||||
self._fingerprint = fp
|
||||
self._changed_mono = time.monotonic()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# selection
|
||||
# --------------------------------------------------------------------------- #
|
||||
def is_enabled(opts: dict) -> bool:
|
||||
"""Whether P1 ingestion is switched on at all.
|
||||
|
||||
⚠️ One definition, because three places depend on it and they MUST agree:
|
||||
where the grid reading comes from, whether the ingest task is started, and
|
||||
whether sensor.p1_sample_age_s is announced over MQTT discovery. An age
|
||||
sensor announced with no ingester behind it is a watchdog input nobody is
|
||||
feeding, and the ESP32 trips on it.
|
||||
"""
|
||||
return str(opts.get("meter_source", "off") or "off").strip() not in ("off", "")
|
||||
|
||||
|
||||
def homewizard_host_port(opts: dict) -> tuple[str, str]:
|
||||
"""`p1_host` split into host and (still unparsed) port text.
|
||||
|
||||
One definition because validate_meter_options() and build_source() must
|
||||
agree byte for byte about what counts as a usable address - a pasted browser
|
||||
URL is the obvious way to fill this in wrong.
|
||||
"""
|
||||
host = str(opts.get("p1_host", "") or "").strip()
|
||||
host = host.removeprefix("http://").removeprefix("https://").rstrip("/")
|
||||
host, _, port_s = host.partition(":")
|
||||
return host, port_s
|
||||
|
||||
|
||||
def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[str]:
|
||||
"""Every way `meter_source` can name a meter that will never produce a reading.
|
||||
|
||||
⚠️ THIS IS THE ONLY PLACE THAT KNOWS THESE RULES. It used to be a run of
|
||||
inline `return None`s inside build_source(); CFG-01 lifted them out because
|
||||
the same predicates now have two callers with two different consequences:
|
||||
|
||||
validate.validate_startup_config() refuses to start the add-on
|
||||
build_source() returns None, as it always did
|
||||
|
||||
They are one function and not two on purpose. A second copy of "what makes a
|
||||
meter config wrong" would drift, and the half that drifted would be the one
|
||||
that decides whether the process is allowed to run.
|
||||
|
||||
Checked ONCE at startup rather than per telegram, because a misconfigured
|
||||
source otherwise fails in the one way indistinguishable from a healthy source
|
||||
nobody has sent anything to yet: no samples, a climbing age, the firmware
|
||||
watchdog holding the battery at 0 W, and nothing in the log saying why.
|
||||
|
||||
Returns a list of human-readable problems; empty means the config is usable.
|
||||
`phases` and `max_age_s` are passed in rather than read from opts so the
|
||||
caller decides whether it is validating the options or an already-built
|
||||
P1Ingest - build_source() has an ingest whose values may not have come from
|
||||
this dict.
|
||||
"""
|
||||
source = str(opts.get("meter_source", "off") or "off").strip()
|
||||
if source in ("off", ""):
|
||||
return []
|
||||
problems: list[str] = []
|
||||
|
||||
if source == SOURCE_HA:
|
||||
imp = str(opts.get("p1_import_entity", "") or "").strip()
|
||||
exp = str(opts.get("p1_export_entity", "") or "").strip()
|
||||
if not imp or not exp:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
|
||||
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||
phase_net = [str(e).strip() for e in
|
||||
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
|
||||
if not net:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
|
||||
"(sensor.p1_sample_age_s would otherwise be announced with "
|
||||
"nothing feeding it)")
|
||||
if phase_net and len(phase_net) != phases:
|
||||
problems.append(
|
||||
f"p1_phase_net_entities has {len(phase_net)} entities but "
|
||||
f"meter_phases is {phases} - every telegram would be rejected "
|
||||
"on the phase-count check")
|
||||
elif source == SOURCE_MQTT:
|
||||
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
|
||||
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
|
||||
elif source == SOURCE_HOMEWIZARD:
|
||||
host, port_s = homewizard_host_port(opts)
|
||||
if not host:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
|
||||
"own address, e.g. 192.168.2.250)")
|
||||
if port_s:
|
||||
try:
|
||||
int(port_s)
|
||||
except ValueError:
|
||||
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
|
||||
"unparseable port")
|
||||
try:
|
||||
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
|
||||
# cadence of 0 is a misconfiguration that must be reported, not
|
||||
# quietly corrected into something that looks like it was asked for.
|
||||
raw = opts.get("meter_poll_s", 5)
|
||||
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||
except (TypeError, ValueError):
|
||||
problems.append(f"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
|
||||
else:
|
||||
if poll_s <= 0:
|
||||
problems.append("meter_poll_s must be positive")
|
||||
# ⚠️ The age can never be fresher than the poll interval. At or past
|
||||
# max_age_s every reading is stale before its successor arrives, so
|
||||
# the controller would sit permanently on missing inputs while the
|
||||
# meter is perfectly healthy - refuse it rather than ship that.
|
||||
elif poll_s >= max_age_s:
|
||||
problems.append(
|
||||
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
|
||||
f"{max_age_s:.1f} - every reading would go stale before the "
|
||||
"next poll")
|
||||
else:
|
||||
problems.append(
|
||||
f"meter_source {source!r} is not one of "
|
||||
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
|
||||
return problems
|
||||
|
||||
|
||||
def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
||||
"""Return the transport named by `meter_source`, or None if disabled.
|
||||
|
||||
This is the whole of AC 1's "switchable": every consumer reads P1Ingest, so
|
||||
changing transport is a config edit, never a code path.
|
||||
|
||||
⚠️ In the add-on this is now unreachable with a bad config -
|
||||
validate_startup_config() refuses to start on anything
|
||||
validate_meter_options() rejects. The guard stays because this function is
|
||||
also called directly by tests and by anything embedding P1Ingest, and
|
||||
"returns None" is a safer default for those callers than a transport that
|
||||
can never read.
|
||||
"""
|
||||
source = str(opts.get("meter_source", "off") or "off").strip()
|
||||
if source in ("off", ""):
|
||||
if not is_enabled(opts):
|
||||
return None
|
||||
problems = validate_meter_options(opts, ingest.phases, ingest.max_age_s)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
_LOG.error("%s - P1 ingestion disabled", problem)
|
||||
return None
|
||||
if source == SOURCE_HA:
|
||||
return HaDsmrSource(session, ingest, {
|
||||
@@ -663,12 +1178,29 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
||||
"phase_import": opts.get("p1_phase_import_entities") or [],
|
||||
"phase_export": opts.get("p1_phase_export_entities") or [],
|
||||
})
|
||||
if source == SOURCE_HA_SIGNED:
|
||||
return HaSignedSource(session, ingest, {
|
||||
"net": str(opts.get("p1_net_entity", "") or "").strip(),
|
||||
"phase_net": [str(e).strip() for e in
|
||||
(opts.get("p1_phase_net_entities") or []) if str(e).strip()],
|
||||
})
|
||||
if source == SOURCE_HOMEWIZARD:
|
||||
host, port_s = homewizard_host_port(opts)
|
||||
port = int(port_s) if port_s else HOMEWIZARD_PORT
|
||||
raw = opts.get("meter_poll_s", 5)
|
||||
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||
# A warning, not a refusal: one missed poll costs a stale reading and a
|
||||
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
|
||||
# can NEVER be fresh.
|
||||
if poll_s > ingest.max_age_s / 2:
|
||||
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
|
||||
"%.1f: one missed poll makes the reading stale. The meter "
|
||||
"updates every ~5 s; 5 s against 30 s is the tested pair.",
|
||||
poll_s, ingest.max_age_s)
|
||||
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
|
||||
if source == SOURCE_MQTT:
|
||||
broker = broker or {}
|
||||
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),
|
||||
broker.get("host"), broker.get("port", 1883),
|
||||
broker.get("username"), broker.get("password"))
|
||||
if source:
|
||||
_LOG.error("meter_source %r is not %s or %s - P1 ingestion disabled",
|
||||
source, SOURCE_HA, SOURCE_MQTT)
|
||||
return None
|
||||
|
||||
@@ -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
|
||||
version: "0.2.1"
|
||||
version: "0.4.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -42,11 +42,24 @@ options:
|
||||
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||
# `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.
|
||||
# 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: ""
|
||||
@@ -55,6 +68,15 @@ options:
|
||||
# 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 ---------------------------------------------------------------
|
||||
max_w: 2000
|
||||
@@ -65,7 +87,7 @@ options:
|
||||
step_w: 10
|
||||
saturation_w: 500
|
||||
saturation_cycles: 3
|
||||
integrator_max_w: 3000
|
||||
integrator_max_w: 0
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
auto_start: false
|
||||
@@ -98,20 +120,25 @@ schema:
|
||||
batt_invert: bool
|
||||
setpoint_entity: str
|
||||
|
||||
meter_source: list(off|ha_dsmr|mqtt_p1)
|
||||
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
||||
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
||||
# 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)
|
||||
gain: float(0.05,1.0)
|
||||
@@ -121,7 +148,7 @@ schema:
|
||||
step_w: int(1,100)
|
||||
saturation_w: int(100,2000)
|
||||
saturation_cycles: int(1,10)
|
||||
integrator_max_w: int(100,15000)
|
||||
integrator_max_w: int(0,15000)
|
||||
heartbeat_s: int(2,25)
|
||||
stale_input_s: int(5,120)
|
||||
auto_start: bool
|
||||
|
||||
@@ -24,6 +24,63 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COVERAGE AUDIT - measured, not executed. Read this before adding a mechanism.
|
||||
#
|
||||
# THE INVARIANT: every mechanism in compute() must be noticed by AT LEAST TWO
|
||||
# checks when it is deleted. If you add a mechanism to compute(), re-run the
|
||||
# audit and add it to the table. If a figure here drops, a check has started
|
||||
# passing for a reason other than the one it names.
|
||||
#
|
||||
# THE TECHNIQUE, because there is no script to run: replace one mechanism in
|
||||
# control.py with a no-op, run this file, count the failures, restore. That is
|
||||
# the converse of the usual mutation - not "does a wrong value fail?" but "does
|
||||
# anyone notice when the mechanism is GONE?". It is kept as a comment rather
|
||||
# than as tooling on purpose: the only cheap way to automate it is to key on
|
||||
# source lines, which goes stale silently, and a green audit that has quietly
|
||||
# stopped testing anything is precisely the failure this ticket exists to fix.
|
||||
# A comment cannot go stale-green, because it never claims to be running.
|
||||
#
|
||||
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
||||
#
|
||||
# mechanism in compute() checks that fail when deleted
|
||||
# ------------------------------------------ -----------------------------
|
||||
# integrator freeze (AC 3) 2
|
||||
# integrator clamp (AC 1) 6
|
||||
# integrator bound follows max_w 4
|
||||
# output clamp 3
|
||||
# slew limit 4
|
||||
# output freeze 2
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
# saturation detector, `saturated_now = False` 11
|
||||
# saturation duration (AC 2), fires instantly 2
|
||||
# sat counter reset on a good cycle 6
|
||||
# target_grid_w bias 3
|
||||
# i_w=None seeding from prev_w 6
|
||||
#
|
||||
# The detector figure is for the `saturated_now = False` form specifically;
|
||||
# disabling it further down as `frozen = False` is a weaker mutation and gives
|
||||
# 10. Reproduce the same form or the number will not match.
|
||||
#
|
||||
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
|
||||
# for the mechanism under test. Deleting the integrator freeze once failed
|
||||
# NOTHING, because the fixtures sat at max_w 2000 and the integrator bound
|
||||
# truncated a wound value back to exactly 2000 - the assertion passed on the
|
||||
# clamp. The output clamp was masked the same way by the integrator bound.
|
||||
# Hence: A FIXTURE MUST SIT CLEAR OF EVERY RAIL IT IS NOT TESTING. Where a test
|
||||
# names one mechanism, make that mechanism the binding one (see TCLAMP and TF).
|
||||
#
|
||||
# ⚠️ RUN MUTATIONS WITH `python -B` AND CLEAR app/__pycache__. CPython
|
||||
# invalidates a .pyc on (source mtime in whole seconds, source size), so a
|
||||
# same-second rewrite that also preserves the file size reuses stale bytecode
|
||||
# and the suite reports on code you are no longer running. It under-reported one
|
||||
# mutation here as 2 where the true figure is 6. The error is one-directional -
|
||||
# stale bytecode can only under-report - so every figure above is a lower bound
|
||||
# at worst, and the two zeros ever recorded were both confirmed by fixing them
|
||||
# and watching the count rise, which a caching artefact cannot do.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
print("control law")
|
||||
|
||||
# Deadband: inside meter noise, hold exactly - do not drift.
|
||||
@@ -41,13 +98,22 @@ check("proportional step (gain 0.6)", d.target_w == 300)
|
||||
d = compute(prev_w=0, grid_w=-500, actual_w=0, tuning=T)
|
||||
check("export drives charging", d.target_w == -300)
|
||||
|
||||
# Clamp
|
||||
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=Tuning(max_w=2000, slew_w=5000))
|
||||
# Clamp.
|
||||
# ⚠️ integrator_max_w is lifted clear of max_w so that the OUTPUT clamp is the
|
||||
# mechanism under test. Left at the default the integrator bound truncates
|
||||
# first, these two assertions pass on that alone, and deleting the output clamp
|
||||
# fails nothing - the same masking that hid the integrator freeze.
|
||||
TCLAMP = Tuning(max_w=2000, slew_w=5000, integrator_max_w=5000)
|
||||
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=TCLAMP)
|
||||
check("clamped to max_w", d.target_w == 2000)
|
||||
|
||||
# Slew: from 0 with a huge error, no more than slew_w in one cycle.
|
||||
d = compute(prev_w=0, grid_w=5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
|
||||
check("slew limits one cycle", d.target_w == 1000)
|
||||
d = compute(prev_w=-1900, grid_w=-1000, actual_w=-1900, tuning=TCLAMP)
|
||||
check("clamped to -max_w", d.target_w == -2000)
|
||||
d = compute(prev_w=0, grid_w=-5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
|
||||
check("slew limits one cycle, charging", d.target_w == -1000)
|
||||
|
||||
# Saturation needs DURATION: one diverging cycle must NOT freeze.
|
||||
t = Tuning(saturation_w=500, saturation_cycles=3)
|
||||
@@ -78,7 +144,7 @@ print("SAFETY-04: the integrator is bounded apart from the output")
|
||||
# The historical runaway, with its real numbers. A commercial controller on
|
||||
# this site, with the inverter switched OFF, wound ~130 W every 4 s past 10 kW
|
||||
# and reported 14 768 W while its output clamp sat at 5 kW. At gain 0.6 that
|
||||
# rate is a standing error of 130/0.6 ≈ 217 W that never resolves, because the
|
||||
# rate is a standing error of 130/0.6 = 217 W that never resolves, because the
|
||||
# inverter is not there to resolve it. 150 cycles is past the ~113 it took to
|
||||
# reach 14 768 W at that rate.
|
||||
RUNAWAY_ERROR = 130.0 / 0.6
|
||||
@@ -86,12 +152,12 @@ RUNAWAY_CYCLES = 150
|
||||
HISTORICAL_W = 14768.0
|
||||
|
||||
|
||||
def runaway(tuning):
|
||||
def runaway(tuning, sign=1):
|
||||
"""Inverter off: it reports 0 W forever, the error never clears."""
|
||||
prev, i_w, sat = 0.0, 0.0, 0
|
||||
worst_i, worst_cmd = 0.0, 0.0
|
||||
for _ in range(RUNAWAY_CYCLES):
|
||||
d = compute(prev_w=prev, grid_w=RUNAWAY_ERROR, actual_w=0.0,
|
||||
d = compute(prev_w=prev, grid_w=sign * RUNAWAY_ERROR, actual_w=0.0,
|
||||
tuning=tuning, sat_count=sat, i_w=i_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
worst_i = max(worst_i, abs(i_w))
|
||||
@@ -99,33 +165,113 @@ def runaway(tuning):
|
||||
return worst_i, worst_cmd
|
||||
|
||||
|
||||
TR = Tuning(max_w=2000, integrator_max_w=3000)
|
||||
TR = Tuning(max_w=2000) # integrator_max_w unset => follows max_w
|
||||
wi, wc = runaway(TR)
|
||||
check(f"runaway: integrator plateaus at {wi:.0f} W (<= 3000)", wi <= TR.integrator_max_w)
|
||||
check(f"runaway: integrator plateaus at {wi:.0f} W (<= 2000)", wi <= TR.max_w)
|
||||
check(f"runaway: emitted command peaks at {wc:.0f} W (<= 2000)", wc <= TR.max_w)
|
||||
check("runaway: nowhere near the historical 14 768 W", wc < HISTORICAL_W / 4)
|
||||
|
||||
# ...and with the saturation detector deliberately defeated, so that only the
|
||||
# clamp is holding. This is the AC that says the two mechanisms are
|
||||
# independent: kill one, the other still bounds it.
|
||||
TD = Tuning(max_w=2000, integrator_max_w=3000, saturation_w=1e9)
|
||||
# clamp is holding. Kill one mechanism, the other still bounds it.
|
||||
TD = Tuning(max_w=2000, saturation_w=1e9)
|
||||
wi, wc = runaway(TD)
|
||||
check(f"runaway with the detector defeated: integrator still <= 3000 ({wi:.0f} W)",
|
||||
wi <= TD.integrator_max_w)
|
||||
check(f"runaway with the detector defeated: integrator still bounded ({wi:.0f} W)",
|
||||
wi <= TD.max_w)
|
||||
check("runaway with the detector defeated: command still <= max_w", wc <= TD.max_w)
|
||||
|
||||
# The bound is not max_w. If someone "simplifies" them into one key this fails.
|
||||
d = compute(prev_w=0, grid_w=6000, actual_w=0,
|
||||
tuning=Tuning(max_w=2000, integrator_max_w=3000, slew_w=5000))
|
||||
check("integrator bound is separate from the output clamp",
|
||||
d.i_w == 3000 and d.target_w == 2000)
|
||||
# The mirror: the same runaway driving the other way. An export that never
|
||||
# clears winds the integrator negative just as hard.
|
||||
wi, wc = runaway(Tuning(max_w=2000), sign=-1)
|
||||
check(f"runaway (export direction): integrator bounded at {wi:.0f} W", wi <= 2000)
|
||||
check("runaway (export direction): emitted command <= max_w", wc <= 2000)
|
||||
|
||||
# Freeze = does not accumulate. Same input twice; the integrator must not move.
|
||||
TF = Tuning(saturation_w=500, saturation_cycles=3)
|
||||
# The bound is a separate quantity, and the useful direction is BELOW max_w:
|
||||
# there it binds first and caps unwind latency tighter than the rail does.
|
||||
d = compute(prev_w=0, grid_w=6000, actual_w=0,
|
||||
tuning=Tuning(max_w=2000, integrator_max_w=1000, slew_w=5000))
|
||||
check("integrator bound binds independently of the output clamp",
|
||||
d.i_w == 1000 and d.target_w == 1000)
|
||||
|
||||
# Freeze = may not wind further in the direction it is already pushing.
|
||||
# ⚠️ max_w is raised WELL above the fixtures on purpose. At the default 2000
|
||||
# the integrator bound truncates a wound value back to exactly 2000 and
|
||||
# satisfies these assertions on its own, so deleting the freeze outright
|
||||
# failed nothing - the clamp was standing in for the mechanism under test.
|
||||
# Any fixture here must sit clear of every rail, or it tests the rail.
|
||||
TF = Tuning(saturation_w=500, saturation_cycles=3, max_w=5000)
|
||||
f1 = compute(prev_w=2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
|
||||
check("frozen: integration does not accumulate", f1.i_w == 2000.0 and f1.frozen)
|
||||
check("frozen: integration does not wind further", f1.i_w == 2000.0 and f1.frozen)
|
||||
f2 = compute(prev_w=2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
|
||||
check("frozen: unwinding is still allowed", f2.i_w < 2000.0)
|
||||
# ...and the same two on the charging side. Every freeze rule in this file has
|
||||
# a mirror, because the one that did not is the defect that got through review.
|
||||
f3 = compute(prev_w=-2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=-2000.0)
|
||||
check("frozen (charging): integration does not wind further", f3.i_w == -2000.0)
|
||||
f4 = compute(prev_w=-2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=-2000.0)
|
||||
check("frozen (charging): unwinding is still allowed", f4.i_w > -2000.0)
|
||||
|
||||
# ⚠️ REGRESSION, and the reason the first cut of SAFETY-04 was rejected. A
|
||||
# freeze encoded as "only corrections that shrink |i_w|" is unsatisfiable for
|
||||
# BOTH signs of error whenever |correction| > 2*|i_w|, so near zero the loop
|
||||
# stops moving forever - the freeze cannot clear, because clearing it needs the
|
||||
# inverter to track and not-tracking is what saturation means. Measured on that
|
||||
# encoding: 0 W held into a 2 kW import for as long as the sim ran.
|
||||
z = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T, sat_count=3, i_w=0.0)
|
||||
check("frozen at i_w=0: a 2 kW import still moves the command",
|
||||
z.frozen and z.target_w == 1000)
|
||||
# ...and the next cycle the inverter is inside saturation_w of the command, so
|
||||
# the freeze clears on its own. Deadlock would show up here as frozen=True.
|
||||
z2 = compute(prev_w=1000, grid_w=1000, actual_w=600, tuning=T,
|
||||
sat_count=z.sat_count, i_w=z.i_w)
|
||||
check("frozen at i_w=0: the freeze then clears", not z2.frozen)
|
||||
# Same stranding on the other side: a small positive integrator against export.
|
||||
z3 = compute(prev_w=100, grid_w=-1000, actual_w=800, tuning=T, sat_count=3, i_w=100.0)
|
||||
check("frozen at i_w=+100: a 1 kW export still moves the command",
|
||||
z3.frozen and z3.target_w < 0)
|
||||
z4 = compute(prev_w=-100, grid_w=1000, actual_w=-800, tuning=T, sat_count=3, i_w=-100.0)
|
||||
check("frozen at i_w=-100: a 1 kW import still moves the command",
|
||||
z4.frozen and z4.target_w > 0)
|
||||
|
||||
# ⚠️ EXACTLY ZERO, BOTH DIRECTIONS. This boundary has a history: the first cut
|
||||
# deadlocked here under import, and the fix for it deadlocked here under export
|
||||
# because `if i_w > 0 ... else ...` files 0.0 under rising-only. main.py resets
|
||||
# i_w to exactly 0.0 on every stop and every reseed, so it is a normal state,
|
||||
# not a corner.
|
||||
zi = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T, sat_count=3, i_w=0.0)
|
||||
check("frozen at i_w=0.0: an import push moves the integrator",
|
||||
zi.frozen and zi.i_w > 0)
|
||||
ze = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T, sat_count=3, i_w=0.0)
|
||||
check("frozen at i_w=0.0: an export push moves the integrator",
|
||||
ze.frozen and ze.i_w < 0)
|
||||
# The COMMAND still holds at 0 W in that second case, and that is release/1.0's
|
||||
# rule, not a leftover: at prev_w == 0 the output freeze forbids starting to
|
||||
# charge while saturated, because commanding 0 while the inverter reports
|
||||
# hundreds of watts means something else is driving the bus. Asserted so that
|
||||
# nobody "fixes" it by accident - the integrator moving is what this ticket
|
||||
# owns, the command rule belongs to the output freeze.
|
||||
check("frozen at i_w=0.0: the output freeze still blocks a charge from 0 W",
|
||||
ze.target_w == 0.0)
|
||||
# Where prev_w is already charging the output freeze does NOT block, and there
|
||||
# the difference reaches the wire: held at 0.0 the integrator abandons the
|
||||
# charge mid-export.
|
||||
zc = compute(prev_w=-2000, grid_w=-4000, actual_w=-600, tuning=T, sat_count=3, i_w=0.0)
|
||||
check("frozen at i_w=0.0: a charge is not abandoned during heavy export",
|
||||
zc.target_w == -2000.0)
|
||||
|
||||
# The general property, rather than another handful of points: while frozen the
|
||||
# integrator may be held ONLY when the correction would push it further from
|
||||
# zero on the side it already sits. Any other hold is a deadlock.
|
||||
stuck = []
|
||||
for i0 in [x * 25.0 for x in range(-80, 81)]:
|
||||
for g in [x * 100.0 for x in range(-40, 41)]:
|
||||
err = g - T.target_grid_w
|
||||
if abs(err) < T.deadband_w:
|
||||
continue
|
||||
dd = compute(prev_w=0.0, grid_w=g, actual_w=1500.0, tuning=T, sat_count=3, i_w=i0)
|
||||
if dd.i_w == i0 and not ((i0 > 0 and err > 0) or (i0 < 0 and err < 0)):
|
||||
stuck.append((i0, g))
|
||||
check(f"frozen integrator never deadlocks, over {161*81} states"
|
||||
+ (f" (e.g. {stuck[0]})" if stuck else ""), not stuck)
|
||||
|
||||
# False-positive guard: a normal 2 kW load step must not trip the detector,
|
||||
# because the plant needs several cycles to catch up on every one of them.
|
||||
@@ -137,6 +283,107 @@ for _ in range(12):
|
||||
froze = froze or d.frozen
|
||||
check("a normal 2 kW load step does not trip the saturation freeze", not froze)
|
||||
|
||||
# The convergence sim below runs WITHOUT a carried integrator. This is the same
|
||||
# 2 kW step in the configuration that actually ships, where main.py carries it.
|
||||
prev, actual, sat, i_w = 0.0, 0.0, 0, 0.0
|
||||
carried = 0
|
||||
for _ in range(12):
|
||||
d = compute(prev, 2000.0 - actual, actual, T, sat, i_w)
|
||||
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
|
||||
actual = actual + 0.94 * (prev - actual)
|
||||
carried += 1
|
||||
if abs(2000.0 - actual) < T.deadband_w:
|
||||
break
|
||||
check(f"carried integrator converges in {carried} cycles (<=6)", carried <= 6)
|
||||
check("carried integrator does not overshoot the load", actual <= 2000.0 + T.deadband_w)
|
||||
|
||||
# ⚠️ REGRESSION: an integrator allowed to wind past the rail buys nothing (the
|
||||
# output clamp already bounds the wire) and costs extra cycles of
|
||||
# wrong-direction power after every saturation event. 4000 W load held to
|
||||
# saturation, then dropped to 0; the figure is the command on the first cycle
|
||||
# after the drop. This is what makes the DOCS advice checkable.
|
||||
def unwind(t):
|
||||
prev, actual, sat, i_w, load = 0.0, 0.0, 0, 0.0, 4000.0
|
||||
for c in range(16):
|
||||
if c == 15:
|
||||
load = 0.0
|
||||
d = compute(prev, load - actual, actual, t, sat, i_w)
|
||||
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
|
||||
actual = actual + 0.94 * (prev - actual)
|
||||
return prev
|
||||
|
||||
|
||||
tight, loose = unwind(Tuning(max_w=2000)), unwind(Tuning(max_w=2000, integrator_max_w=3000))
|
||||
check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <= 1000)
|
||||
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
|
||||
loose > tight)
|
||||
|
||||
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
|
||||
|
||||
|
||||
def legacy(prev, grid, actual, t, sat_count):
|
||||
"""release/1.0's control law, transcribed. Do not 'improve' this."""
|
||||
reason = "tracking"
|
||||
sc = min(sat_count + 1, 10) if abs(prev - actual) > t.saturation_w else 0
|
||||
frozen = sc >= t.saturation_cycles
|
||||
error = grid - t.target_grid_w
|
||||
if abs(error) < t.deadband_w:
|
||||
want, reason = prev, "deadband"
|
||||
else:
|
||||
want = prev + t.gain * error
|
||||
target = max(-t.max_w, min(t.max_w, want))
|
||||
if target != want:
|
||||
reason = "clamped"
|
||||
slewed = max(prev - t.slew_w, min(prev + t.slew_w, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
target = slewed
|
||||
if frozen:
|
||||
target = min(target, prev) if prev > 0 else max(target, prev)
|
||||
reason = "saturated-freeze"
|
||||
step = max(1, int(t.step_w))
|
||||
return float(round(target / step) * step), sc, frozen, reason
|
||||
|
||||
|
||||
# ⚠️ Compare EVERYTHING observable, not just the number. A previous version of
|
||||
# this sweep compared (target_w, sat_count) only and passed 3024 cases while
|
||||
# `reason` had silently lost a value - which is the kind of thing a sweep this
|
||||
# broad exists to catch. `frozen` and `reason` are both in the tuple now.
|
||||
#
|
||||
# The one deliberate rename: what release/1.0 called "clamped" is now
|
||||
# "i-clamped", because the truncation happens on the integrator before the
|
||||
# command is derived from it. Aliased here rather than papered over - if any
|
||||
# OTHER reason ever diverges, this check goes red.
|
||||
ALIAS = {"i-clamped": "clamped"}
|
||||
diffs = []
|
||||
seen = set()
|
||||
for tune in (Tuning(), Tuning(target_grid_w=-10.0), Tuning(max_w=5000, slew_w=5000)):
|
||||
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
|
||||
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
|
||||
for actual in (-2000.0, 0.0, 600.0, 2000.0):
|
||||
for sc in (0, 2, 3, 9):
|
||||
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
|
||||
seen.add(d.reason)
|
||||
got = (d.target_w, d.sat_count, d.frozen,
|
||||
ALIAS.get(d.reason, d.reason))
|
||||
if got != legacy(prev, grid, actual, tune, sc):
|
||||
diffs.append((prev, grid, actual, sc, got,
|
||||
legacy(prev, grid, actual, tune, sc)))
|
||||
check(f"i_w=None reproduces release/1.0 over {3*7*9*4*4} cases, reason included"
|
||||
+ (f" (first diff {diffs[0]})" if diffs else ""), not diffs)
|
||||
|
||||
# ...and the rename is not a quiet deletion: the signal SAFETY-03 alarms on has
|
||||
# to actually occur in that sweep, or its hook is dead.
|
||||
check("the integrator clamp reports itself as 'i-clamped'", "i-clamped" in seen)
|
||||
|
||||
# "clamped" stays reachable, but only where the integrator is deliberately
|
||||
# allowed above the rail - then BOTH fire and the output clamp, which describes
|
||||
# the value actually emitted, is the one reported.
|
||||
dc = compute(prev_w=0, grid_w=6000, actual_w=0,
|
||||
tuning=Tuning(max_w=2000, integrator_max_w=3000, slew_w=5000))
|
||||
check("the output clamp still reports 'clamped' when it is the binding one",
|
||||
dc.reason == "clamped" and dc.i_w == 3000 and dc.target_w == 2000)
|
||||
|
||||
print("capacity tariff")
|
||||
check("no forecast means no cap", maintenance_charge_floor(2500, None, 3500) == 2500)
|
||||
check("headroom caps the charge", maintenance_charge_floor(2500, 2000, 3500) == 1500)
|
||||
|
||||
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