Compare commits

...
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 aae74d7571 SAFETY-12 review: name the safety claim that could not fire, and fix two figures
Review fixes.

A. test_loop.py takes 73 s, not the ~35 s claimed in three places. That number
is the whole defence against a future developer deleting a slow test, so it was
understated by 2x in exactly the place it matters. Corrected - and it is now
~95 s (measured 93 s), because this commit adds the scenario below rather than
trimming windows to make an inconvenient number smaller. Both call sites carry
an instruction to re-measure if the windows change.

B. "242 checks" was test_p1's own count quoted as the total for all five
suites. The real figure is 395 across the five, plus 14 here.

And the finding this PR should have led with. stale_input_s and the
`inputs missing -> 0 W` claim both live INSIDE cycle(), so pre-fix, on a meter
whose value happened to repeat, that claim could not fire at all. Constant
meter, SoC entity dying at t=4 s, one binary:

  pre-fix   final target 220.0 W, reason 'loop -> 220 W'
  post-fix  final target   0.0 W, reason 'safety -> 0 W'

The old controller held 220 W through the whole input outage with its status
line still reading loop -> 220 W, and layer 1 does not cover that on a
`meter_source: off` install - no P1 age sensor is published, so the firmware has
nothing to veto on, while the heartbeat keeps its watchdog fed. The pre-fix
behaviour was not "holds a stale command safely"; it was a failsafe a repeating
meter switched off. Asserted in test_loop.py, both halves.

Three points of reasoning tightened, none of them wrong before:

- the re-tune is unreachable by construction, not merely by the reset: the
  combined cycle rate is max(arrival rate, 1/floor), never the sum, and 1/15 s
  is strictly slower than the ~1/5 s arrival rate. That holds only while the
  floor exceeds the meter's update interval - and the schema minimum of 5 s is
  exactly the reference meter's interval. Flagged for validate.py, not fixed
  here.
- the ~11 s lower bracket is not only a dropped poll: a 5.0 s poller against a
  4.97 s meter beats, so about every 14 minutes the poller steps over an update
  entirely. Reaching 15 s takes a beat-skip AND a failed poll.
- the "freshness is decided upstream" guarantee is transport-conditional. On
  `meter_source: off` the grid figure comes straight from hass.number() with no
  age gate. Running the full law is still right; the claim is narrower than it
  was stated.

The 15 s default is not independently justified - it rests on the firmware's
30 s ingest veto and moves if that ever does. Said beside the constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 21:14:58 +02:00
glenn schrooyenandClaude Opus 5 8cc9438aec SAFETY-12: record the six-minute observation, not just the unit check
test_loop.py proves the property in 35 s. This is the same three scenarios run
for six and three real minutes each, on one binary, and it is what AC2 and AC3
were actually asked for: 1 cycle before vs 24 after on a constant meter with the
command stuck at 220 W against a 350 W import, and byte-identical write
sequences on both the converged and the normally-changing meter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:48:49 +02:00
glenn schrooyenandClaude Opus 5 96a808a057 HANDOVER: run every suite before deploying, and say why one is slow
The pre-deploy command listed three of the five suites; test_validate and
test_p1 were never added when they arrived. Adds those and test_loop, with a
note that test_loop's ~35 s is the cost of proving a wall-clock property about a
loop that sleeps 1 s a tick rather than a sign something is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:32:00 +02:00
glenn schrooyenandClaude Opus 5 88cd97cc7a SAFETY-12: the loop thinks on a clock, not only when the number moves
run_control() ran cycle() only when the meter value differed from the previous
poll. A meter that repeats a value therefore ran no control cycles at all -
while the heartbeat kept re-emitting the last command and the firmware kept
putting it on the wire every 2 s. Layer 1 stayed fed and every indicator read
healthy, so a command computed from an old reading went on being executed
through whatever the load did next. TEL-05 measured it: 70 s at a literally
constant 350.0 W, 14 arrivals, zero cycles.

cycle_floor_s (default 15 s) is a deadline on the GAP between cycles, reset by
every cycle whichever trigger fired - not a second scheduler. On a healthy meter
it never elapses, so the cycle rate and the commanded sequence are unchanged;
test_loop.py asserts that by driving the real loop twice on one binary (a huge
cycle_floor_s reproduces the old behaviour) and diffing the writes.

15 s is bracketed: above the ~11 s worst gap on a merely-unlucky healthy meter
(~5 s arrivals, 1 s detection, one dropped poll retried on the next tick), below
meter_max_age_s and the firmware's 30 s ingest veto, and equal to stale_input_s.

A floor cycle runs the full law. Freshness is already decided upstream - past
meter_max_age_s the reading is None, which is a P_SAFETY 0 W claim - and the
integrator advances per cycle, not per second, so bounding the gap bounds the
accumulation.

Not a freeze detector: nothing here decides whether a repeated reading is true.
DOCS.md's "accepted deviation" passage is qualified rather than deleted - the
window is now bounded at 3 x cycle_floor_s instead of open-ended, and its
"nothing accumulates while it is stalled" argument is called out as having
covered windup and not staleness. Same correction in control.py's comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:29:16 +02:00
admin e2e331ea54 CFG-01 AC2: refuse to start on a config the lower layer will silently override (#6) 2026-08-28 20:17:55 +02:00
glenn schrooyenandClaude Opus 5 547d796c5d CFG-01 review: widen the rule, refuse the firmware-veto case, fix the stop footgun
Review findings, all confirmed in code before changing anything.

THE RULE DID NOT AUTHORISE ITS OWN BEST REFUSAL. The stated test was
"makes a safety mechanism inoperative, or makes a stated limit untrue",
and a blank setpoint_entity does neither - layer 1 pinning the inverter
at 0 W is the failsafe WORKING, as validate.py's own text admitted. The
real justification is a second, separate category: the add-on is inert
and indistinguishable from healthy. Stated as (a) and (b), with each
refusal tagged, and with both halves explicitly bounded - (a) is not
"any two numbers that could disagree", (b) is not "anything degraded".
Left implicit, the gap is where trivia-creep comes back in.

meter_max_age_s ABOVE THE FIRMWARE'S VETO IS NOW REFUSED. The schema
allows int(5,300); the firmware's threshold is a compile-time
`max_age_s: "30"` and it trips layer 1 at `p1_age_s >= 30`. At 120 the
add-on believes a 119 s reading is fresh and keeps commanding while the
board has already vetoed and is holding at 0 W - healthy meter, running
controller, nothing moving. Structurally identical to the max_w case,
and unlike max_w it is settable on this install, so the headline
behaviour finally has a live demonstration. That firmware file's own
comment says a lower bound on this "belongs in CFG-01, not here".

Only ABOVE is refused: below the veto the add-on gives up first, which
is fail-safe and visible on its own status page. Scoped to P1 being
enabled, because meter_max_age_s governs nothing otherwise.
FIRMWARE_INGEST_VETO_S cannot be imported from a compile-time YAML
substitution, so it carries a note on the single direction in which it
can go stale - and a warning that TEL-06 is editing `ingest_stale`, so a
matching integer may stop meaning a matching rule.

stop= WAS A LIVE FOOTGUN. It defaulted to None, which made a Controller
that cannot stop itself the DEFAULT one. On the late-refusal path that
meant: log the banner, return, and leave the process alive forever with
no control loop and no exit - the exact "inert but looks healthy"
failure this module exists to refuse. Now required and keyword-only, so
the broken shape does not construct; test_p1.py's five call sites pass
an Event.

That whole path had no test and no live demo. It has one now: it drives
run_control() against a device that only reports a narrower range once
the loop starts, and asserts it refuses, signals, returns, and writes
NOTHING - plus the converse, that an agreeing device leads to real
writes. This also covers the max_w composition that cannot be reached
through the Supervisor on the live install.

max_w <= 0 is refused. Schema-blocked in production, but
validate_startup_config() is the simulation suite's entry point too, and
a non-positive max_w inverts the arbiter's supervised bounds into a
contradiction that reads as a safety trip rather than a typo.

Verified live 2026-08-28 (backup goodwe_controller-cfg01r2-1787940076):
meter_max_age_s 120 refused with a message naming 120, 30, and THE
FIRMWARE WINS; no traceback; add-on restored byte-exact and running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:05:10 +02:00
glenn schrooyenandClaude Opus 5 fc9bd89dca CFG-01: refuse to start on a configuration that cannot be honoured
main.py used to read the setpoint device's own +-5000 W rating, notice a
configured max_w above it, log one WARNING, and run anyway with
write_setpoint() quietly clamping. The operator's stated ceiling was never
the real ceiling and the only evidence had scrolled off the log. specs
§1.5 is explicit that such a config is refused at startup, not survived at
runtime.

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 exactly three things, all sharing one
property - continuing makes a safety mechanism inoperative or makes a
stated limit untrue:

  1. max_w outside the setpoint device's own rating.
  2. No setpoint_entity: 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.
  3. A meter_source that can never produce a reading. These were already
     caught, but they only DISABLED ingestion, which leaves grid None
     forever: permanent inputs-missing, 0 W, and a status page
     indistinguishable from a healthy meter nobody has fed yet.

Those last predicates move into p1.validate_meter_options(), which
build_source() also calls - one definition, two callers, not two
competing validators. ha_dsmr with half its entities and mqtt_p1 with a
blank topic gained the check the other two transports already had.

Deliberately not refused, because untidy is not unsafe: integrator_max_w
above max_w (the arbiter still bounds the wire to +-max_w, so the cost is
unwind latency - warned), a missing soc/batt entity (fail-safe and
already a red NOT READY banner), a setpoint entity that has not appeared
on the Core API yet (ESPHome is routinely late; re-checked in
run_control() once the real rating arrives), and a thin meter_poll_s
margin.

A refusal is legible as one: a banner saying in words that this is
configuration and nothing is broken, numbered problems, no traceback, and
exit 78 (EX_CONFIG).

Verified on the live add-on 2026-08-28, not by reading the code: a blank
setpoint_entity and a meter_poll_s equal to meter_max_age_s each stopped
the add-on with the banner and no stack trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 19:29:35 +02:00
glenn schrooyenandClaude Opus 5 0422127cab HANDOVER said 0.2.0; the running add-on is 0.4.0
Two versions stale. 0.3.0 shipped SAFETY-04 and TEL-01, 0.4.0 shipped TEL-04
and TEL-05 and changed the meter transport to homewizard_local - so a reader
taking this line at face value would look for a meter path that is no longer
how this system reads the grid.

Found in the 2026-08-27 backlog audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-27 23:55:37 +02:00
glenn schrooyenandClaude Opus 5 80ebb96e7d Two comments asserted an entity id that has never existed
mqtt.py said the P1 age entity "must be exactly sensor.p1_sample_age_s,
because SAFETY-01's firmware watchdog subscribes to that literal id", and
main.py:340 repeated it. Both are wrong, and wrong in the direction that
disarms a safety layer.

On HA 2026.8 object_id in a discovery payload does not determine entity_id.
HA composes it from the device name plus the entity name, so this one is
sensor.goodwe_p1_sample_age. That is what the firmware subscribes to, that is
what exists on the live system (verified 2026-08-26), and the firmware's own
comment records that sensor.p1_sample_age_s never existed on any HA.

The hazard is not the stale name itself, it is that two shipped artefacts
disagreed about a safety-critical id while sounding certain. A reader who
trusted these comments would have "corrected" the firmware to a name that
resolves to nothing - and the failure is silent, because has_state() simply
stays false forever and the ingest guard never arms. The firmware calls that
out as the shape that FAILS SILENTLY AND LOOKS CORRECT.

The comment now says what actually determines the id, and states plainly that
renaming the device or the entity's friendly name changes the composed id and
disarms the guard. It also cross-references TEL-06, since the guard reads this
sensor's value rather than its arrival and so cannot fire for a dead publisher.

Found in a backlog audit, as the substance behind PKG-02's premise: the real
coupling between firmware and add-on is names, not versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-27 23:48:40 +02:00
glenn schrooyenandClaude Opus 5 c70239cac2 0.4.0: the meter transports that fit this house
TEL-04 and TEL-05 merged without a version bump, so Home Assistant would not
have offered the update - the same trap DEPLOY-01 caught at 0.2.1, recurring
because merging a feature does not touch the version.

ha_signed reads one signed entity. homewizard_local polls the meter's own API,
where every HTTP response is a genuine arrival - the property the firmware
watchdog needs, and the one HA cannot provide for a repeated reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:31:54 +02:00
glenn schrooyenandClaude Opus 5 08f17f65dd TEL-05: read the meter, not Home Assistant's opinion of the meter
sensor.p1_sample_age_s measured time since the value CHANGED, not since the
meter REPORTED. Home Assistant fires no state_changed for a repeated reading and
exposes no arrival signal at all - proven on the rig three ways and confirmed
against the live house, where ten repeated meter values left last_reported
frozen every time. Real captured data in sim/scenarios/ has inter-change gaps of
42.2 s and 97.0 s, and the firmware watchdog thresholds that age at 30 s, so it
would have commanded 0 W on a perfectly healthy meter.

meter_source: homewizard_local polls GET /api/v1/data. Every HTTP response is a
genuine arrival. Same pipeline as ha_signed - only the arrival mechanism changed.

The decisive evidence is the STEADY case, not the frozen one: hwsim served a
literally constant 350.0 W for 70 s - the exact state where the HA path recorded
samples: 0 across 45 s - and this transport took 14 arrivals with the age never
exceeding the poll cadence. Reproduced independently by the reviewer against the
real hwsim, and again by the tester at 14/14.

179 -> 242 checks. Twelve mutants from the reviewer and six from the developer,
every one reddening a NAMED check rather than aborting the suite.

WHAT THIS DOES NOT DO. The ticket asked for two things that cannot both be true:
that the age reset on every HTTP response including an unchanged value, AND that
a frozen meter show a climbing age. A frozen meter still answers 200 OK - it IS
arriving - so no arrival detector can do both. That acceptance criterion was
wrong and was written by the PM; the developer refused it rather than quietly
building half of it.

Freeze detection is delivered separately as unchanged_s on the energy registers,
which the local API exposes and HA never did: 70.2 s frozen against 10.0 s
steady. Deliberately unthresholded, because at the converged -10 W this
controller aims for, a 1 Wh register needs about six minutes to move while the
power figure legitimately repeats - any threshold would rebuild the false-trip
cycle at the target operating point. It is surfaced on the status line so a
human can read it, which is what makes leaving it unthresholded defensible
rather than a dodge.

So the flash covers meter SILENCE, not meter DISHONESTY.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 21:27:13 +02:00
glenn schrooyenandClaude Opus 5 4d41b0a79e TEL-05 review follow-ups: show unchanged_s, name a timeout, keep the poll task alive
Four follow-ups on the reviewed and approved TEL-05 work. Additive; no shipped
behaviour changes except the two failure paths below.

1. unchanged_s had no operator surface. DOCS.md told a reader "the transport
   tracks it as unchanged_s" and there was nowhere to look: main.py built the
   transport, scheduled run(), and never read the object again. The status page
   now shows it on the healthy P1 line. Still NOT thresholded and NOT folded
   into the age - that refusal was reviewed and upheld, because 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 target operating point. The whole argument for
   leaving it to a human requires the human being able to see it.

2. The "equivalent mutant" note on the content_type guard was wrong, and the
   comment is downgraded to say so. web.Response(text=...) defaults to
   text/plain, so the fake meter CAN serve valid JSON under the wrong mimetype.
   Test added; shipped behaviour was already correct.

3. A timed-out poll logged an empty reason: str(asyncio.TimeoutError()) is "",
   so the status page read "last error:" and then nothing, on a hung meter, at
   the moment the battery had just gone to 0 W. Falls back to the class name.
   Note str(err), not `err or ...` - an exception object is always truthy.

4. submit() sat outside the try in poll_once() and run() had no except, so a
   raise would kill the poll task permanently and SILENTLY - safe (the age
   climbs, the controller commands 0 W) but indistinguishable from a dead meter.
   Both wrapped; poll_s is already the retry cadence, so no backoff.

Also a comment at the parse_homewizard range(phases) slice: a 3-phase meter
configured as 1-phase understates the capacity-tariff figure. Filed separately,
not fixed here.

242 checks in test_p1.py (236 before, 6 new). test_control 55, test_arbiter 18,
test_maintenance 21, all untouched and green. Each new check proved non-vacuous:
six mutations, six named reds, no suite aborts, sources restored byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 21:17:36 +02:00
glenn schrooyenandClaude Opus 5 98109a9b91 TEL-05: read the meter, not Home Assistant's opinion of the meter
A fourth meter_source, `homewizard_local`, polling a HomeWizard P1's own
local API (GET /api/v1/data) instead of watching an HA entity.

The point is the age sensor. sensor.p1_sample_age_s is FW-01's watchdog
input, and on every transport we had it measured "time since the value
CHANGED", not "time since the meter REPORTED". Home Assistant offers
nothing better: a repeated reading emits no state_changed, advances
last_reported on neither serialiser, and state_reported cannot be
subscribed to at all. Measured twice - 70 s of a frozen meter on the
ENV-01 rig, and ten repeated readings against the live house. 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, so the age sensor would
have commanded 0 W on a perfectly healthy meter.

Here every HTTP response is an arrival. The meter answered, now, with
its current reading; whether the number moved is not consulted. Five
identical readings are five arrivals.

Reuses TEL-01's pipeline rather than restructuring it: same split_signed
sign convention as ha_signed, same make_sample, same ingest stamping,
meter_max_age_s, clock-recomputed age, plausibility bounds and the §20
unsigned-decode rejection. A failed or timed-out poll submits nothing,
so it is a missing reading - never 0 W - and does not reset the age.
meter_poll_s (default 5 s, the meter's own rate) is checked against
meter_max_age_s once at startup, like the ha_signed entity ids.

⚠️ An arrival stamp cannot see a FROZEN meter, and no arrival detector
can - one answering 200 OK with a stale number is arriving. The local
API does expose what HA 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 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 would rebuild
the false-trip limit cycle at the exact operating point we aim for.

test_p1.py 179 -> 236 checks. Still defaults to off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 20:51:23 +02:00
glenn schrooyenandClaude Opus 5 ad9c5772a4 TEL-04: a meter source for the meter this house actually has
TEL-01 shipped ha_dsmr and mqtt_p1. Neither fits the installed meter - a
HomeWizard P1 publishing one signed figure, where ha_dsmr requires two unsigned
registers and refuses negatives. So sensor.p1_sample_age_s could not be produced
here at all.

ha_signed is a subclass of HaDsmrSource overriding only _wanted() and build(),
so ingest timestamping, staleness, the clock-recomputed age, plausibility bounds
and unavailable-is-never-zero are inherited by construction rather than copied.
A reviewer traced every inherited member and confirmed nothing in the base
assumes two entities.

122 -> 179 checks. Sign convention asserted against real captures in
sim/scenarios/: -5710 W at 13:46 local under full sun, +775 W at midnight,
verified to the timestamp by two people independently.

WHAT THIS DOES NOT DO, documented in DOCS.md, the docstring and the CHANGELOG
rather than discovered later: the age it publishes measures time since the VALUE
CHANGED, not since the meter reported. Home Assistant exposes no arrival signal
for a repeated reading - proven on the rig against a frozen meter (no
state_changed, last_reported advancing on neither serialiser, state_reported
rejected outright) and confirmed independently against the live house, where ten
repeated values all left last_reported frozen. So this entity must NOT yet be
thresholded by the firmware watchdog. ha_dsmr has the same blind spot and
escapes only statistically, because a DSMR telegram moves several entities.

The fix is to read the meter's own API, where every response is an arrival.
That is TEL-05, and it is FW-01's real gate.

Two equivalent mutants are known and recorded: the per-phase split, documented
at the site, and the incomplete-phase-set guard, which is cosmetic - both paths
return False, one via an extra log line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:00:42 +02:00
12 changed files with 2270 additions and 82 deletions
+11 -2
View File
@@ -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 |
@@ -52,9 +52,18 @@ Run the checks before deploying. No framework needed:
```bash
cd goodwe_controller
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py \
&& python3 test_validate.py && python3 test_p1.py && python3 test_loop.py
```
⚠️ `test_loop.py` takes about **95 seconds** (measured 93 s) and that is not a bug. It
drives the real control loop, which sleeps 1 s a tick, eight times over, to prove a
wall-clock property about it (SAFETY-12); mocking the clock there would leave the
assertion testing the mock. The other five suites together are ~395 checks and finish in
seconds. If you shorten `test_loop.py`'s windows, re-measure and correct this figure —
an understated runtime is what makes a slow test look like a hung one, and a slow test
that looks hung gets deleted.
---
## 3. Known broken / unfinished
+250
View File
@@ -1,7 +1,257 @@
# Changelog
## 0.4.0
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
instead of logging a line and carrying on. `validate_startup_config()` runs in
`amain()` before the broker, the web server or any task exists, so a refusal
cannot leave half a controller running behind a UI.
It refuses only where continuing produces one of two outcomes: **(a)** a stated
limit is untrue - the operator configured a bound and a lower layer silently
enforces a different one - or **(b)** the add-on is inert and indistinguishable
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
become unsafe, it becomes a convincing impression of a working controller.
Four things qualify:
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
that changed behaviour. It used to warn `the device wins` and run anyway, with
`write_setpoint()` silently clamping, so the configured ceiling was never the
real one and the only evidence had scrolled off the log. The refusal names the
configured value, the device's value, and which wins.
- **No `setpoint_entity`.** A controller with no actuator: every write is a
no-op, the device rating is never read, and the ESP32 heartbeat that holds off
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
`meter_max_age_s`. These were already caught, but they only *disabled
ingestion*, which leaves the grid reading None forever: permanent "inputs
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
has sent anything to yet.
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
reading as fresh and keeps commanding, while the firmware has already vetoed
and is holding the inverter at 0 W - healthy meter, running controller,
nothing moving, and no line anywhere saying the number you set was overruled.
Structurally the same fault as the `max_w` case. Only *above* the veto is
refused: below it the add-on gives up first, which is fail-safe and visible on
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
be imported from a compile-time YAML substitution, so it carries a note about
the one direction in which it can go stale.
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
`validate_startup_config()` is now also the simulation suite's entry point, and
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
than as the typo it is.
The predicates for the meter group still live in `p1.validate_meter_options()`,
which `build_source()` also calls - one definition, two callers, not two
competing validators. Two transports gained a check they never had: `ha_dsmr`
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
which previously built a transport that could never read.
Deliberately **not** refused, because it is untidy rather than unsafe:
`integrator_max_w` above `max_w` (the arbiter still bounds the wire to ±max_w,
so the cost is unwind latency, not amperage - warned), a missing `soc_entity` /
`batt_entity` (fail-safe, and already a red NOT READY banner naming it), a
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
late; refusing would make a slow boot need a human - re-checked once the real
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
A refusal is legible as a refusal: a banner that says in words that this is a
configuration problem and nothing is broken, the numbered problems, no traceback,
and exit status 78 (`EX_CONFIG`).
**SAFETY-12.** The control loop now evaluates on a clock as well as on a change.
`run_control()` ran `cycle()` only when the meter value differed from the
previous poll, so a meter that repeated a value ran **no control cycles at
all** - while the heartbeat went on re-emitting the last command and the
firmware went on putting it on the wire every 2 s. Layer 1 stayed fed, every
indicator read healthy, and a command computed from an old reading kept being
executed through whatever the load did next. Measured on the rig during TEL-05:
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
**It was worse than a latency limit: it was a safety claim that could not
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
never called again - so that claim had no way to run. Constant meter, SoC entity
dying at t=4 s, same binary:
```
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
final target: 220.0 W reason: 'loop -> 220 W'
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
final target: 0.0 W reason: 'safety -> 0 W'
```
The pre-fix controller held 220 W through the entire input outage with its
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
behaviour was not "holds a stale command safely" - it was a failsafe that a
repeating meter switched off. `test_loop.py` asserts both halves.
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
meter it never elapses, so the cycle rate, the write sequence and therefore the
per-cycle gain the tuning was measured against are unchanged - asserted in
`test_loop.py` by driving the real loop twice on one binary (a huge
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
commanded values.
The reset is not the only thing holding that up, and the other argument is the
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
even a hypothetical floor racing the arrivals could not exceed the nominal
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
while `cycle_floor_s` stays above the meter's update interval, and the schema
minimum is 5 s - which is exactly the reference meter's interval. Nothing
enforces the relationship; `validate.py` is the obvious home for it and does not
have it yet.
Observed over six real minutes on the same rig, one binary, meter held at a
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
the command stuck at 220 W against a 350 W import for the rest of the window.
**After, 24 cycles**, worst gap 15.14 s, and the ordinary PI ramp doing its job -
220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W, one step per cycle,
rail at t=136 s and flat after. Three minutes with the meter parked at the
target instead: **1 cycle before, 12 after, and 19 writes either way - the same
19**. Three minutes on a normally-changing meter: **36 cycles and 49 writes on
both sides, the same values in the same order.** The heartbeat write count never
moves; the only writes the fix adds are the ten the loop was supposed to be
making all along.
15 s is bracketed, not round.
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
the poller steps over an update entirely - a ~10 s gap between changed values on
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
sits above the beat-skip so it does not fire on that alone; what it takes to
reach 15 s is a beat-skip *and* a failed poll coinciding.
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
guaranteed to have re-decided at least twice inside any window in which layer 1
might otherwise be the only thing deciding. ⚠️ The default is therefore not
independently justified at 15: if the firmware's veto ever moves, this moves
with it. Noted beside the constant in `main.py`.
It is also exactly `stale_input_s`, this system's existing answer to "how long
may we hold without re-deciding".
A floor cycle runs the **full** law on the same inputs, integrator included. In
P1 mode the freshness question is already answered upstream and answered harder
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
this system already calls fresh. ⚠️ That is transport-conditional: with
`meter_source: off`, `read_inputs()` takes the grid figure straight from
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
gone quiet reads as current on that path, with or without this change. Running
the full law is still right there - the alternative is a degraded second law
reasoning about an age nobody measured - but the floor does not make that path
fresh. The integrator advances per *cycle*, not per second,
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
that. A gentler second law for floor cycles would be a second set of dynamics
nobody can test on this hardware.
⚠️ **This is not a freeze detector**, and TEL-05 established one cannot be built
here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move
while the power figure legitimately repeats, so any threshold on `unchanged_s`
false-trips at the exact operating point we run at. Nothing in this change
decides whether a repeated reading is *true*. It stops "the number is the same"
from meaning "do not think". The consequence, stated plainly: against a meter
genuinely frozen at a wrong value the loop will now correct toward that wrong
value rather than hold a stale command, bounded by `max_w`, the arbiter and the
device rating. That was already what happened to a lying meter whose value
merely jittered; bit-exact repetition was never a safety property.
`DOCS.md`'s "Saturation is counted in cycles, not seconds" section documented
the old behaviour as an accepted deviation. It is **qualified, not deleted**:
the deviation survives (the window is still counted in cycles, and 3 x 15 s is
still not the specification's 10 s) but it is now bounded at 45 s instead of
open-ended, and the passage's second justification - "nothing accumulates while
it is stalled" - is called out as having covered integrator windup and not
staleness, which was the actual hazard. The same wrong argument in
`control.py`'s saturation comment is corrected too.
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
unsigned registers and refuse negatives, so neither fits the meter installed here.
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
directly. This exists because of something the HA paths cannot do: Home Assistant
fires no state change for a repeated reading and exposes no arrival signal at
all, so `sensor.p1_sample_age_s` on those transports measures time since the
*value changed*, not since the meter *reported*. Real captured data has gaps of
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
local API is a genuine arrival, so the age means what it says.
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
age never exceeding the poll cadence.
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
install that does not opt in.
Freeze detection is separate and deliberately not thresholded: a meter answering
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
energy registers carry that as `unchanged_s`, surfaced on the status line for a
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
about six minutes to move, so any threshold would false-trip at the target
operating point.
## Unreleased
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
(default 5 s, the meter's own update rate) sets the cadence.
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
the meter *reported*, and therefore the first one a firmware watchdog may
threshold.** Every HTTP response is an arrival: the meter answered, now, with
its current reading, and whether the *number* moved is not consulted. Home
Assistant cannot express that at all — a repeated reading emits no
`state_changed`, advances `last_reported` on neither serialiser, and
`state_reported` is not subscribable ("Event filter is required"). On
`ha_signed` that made a healthy meter under a flat load indistinguishable from
a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s
between changes — both past the default `meter_max_age_s` of 30, i.e. a false
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
mode.
Everything TEL-01 established is reused, not re-implemented: ingest
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
is a *missing* reading — never 0 W — and it does not reset the age.
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
freeze` injected. Still defaults to `off`.
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
detector can: a meter answering `200 OK` forever with a stale number is
arriving. The local API does expose what the HA path never had — the
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
`unchanged_s`, but that is deliberately **not** folded into the age and not
thresholded: this controller regulates grid power toward ~0 W, and at a
converged −10 W the export register needs six minutes to move by its 1 Wh
resolution while the power figure legitimately repeats. Thresholding it at 30 s
would rebuild the false-trip limit cycle at the exact operating point we aim
for. Freeze detection needs the low-power case solved first, separately. It is
shown on the status page's P1 line instead — leaving it unthresholded only
holds up if a human can read it, so now they can.
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
Assistant entity: positive = import, negative = export. That is the shape a
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
+108 -17
View File
@@ -70,6 +70,13 @@ 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
@@ -84,9 +91,11 @@ than in YAML nobody reviews underneath a safety input.
| option | default | meaning |
|---|---|---|
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket |
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket; `homewizard_local` polls a HomeWizard P1's own local API, bypassing Home Assistant |
| `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
| `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average |
| `meter_poll_s` | 5 | `homewizard_local` only. Seconds between polls. **Must be well under `meter_max_age_s`** — see below |
| `p1_host` | | `homewizard_local` only. The meter's own address, `host` or `host:port` (e.g. `192.168.2.250`) |
| `meter_mqtt_topic` | | `mqtt_p1` only |
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
| `p1_export_entity` | | The **unsigned** injection sensor |
@@ -143,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
@@ -189,8 +247,19 @@ behind it would trip the firmware watchdog on a system that is working fine.
> 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, i.e. reading
> the HomeWizard local API directly rather than through an HA entity.
> 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
@@ -203,9 +272,10 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
| `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. A cycle is one *changed* meter reading, not a fixed period - see the note below. **Do not set to 1** |
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1** |
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
| `auto_start` | false | Start controlling on boot (only after commissioning) |
@@ -215,22 +285,43 @@ The specification states the saturation window as **"> 10 s"**. This add-on coun
**cycles** instead, and that is a deliberate, accepted deviation rather than an
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.
A cycle here is one meter update **or `cycle_floor_s` elapsing, whichever comes
first**. At the reference P1's ~5 s update rate the default of 3 cycles is usually
around 15 s, and the worst case is bounded at `3 x cycle_floor_s` = 45 s. It is still
not the 10 s the specification asks for, so the deviation stands - but it is now a
bounded number rather than an open one.
Two reasons that is acceptable:
⚠️ **This passage used to say something different, and the second half of it was
wrong.** Until SAFETY-12 the loop ran only when the meter value *changed*, so a meter
that repeated a value stalled the counter for as long as it repeated, with no upper
bound at all. That was defended here on the grounds that "a stalled counter is a
detection-latency limit and not a runaway risk - the condition that stalls it stops the
whole loop, so nothing accumulates while it is stalled". That argument covers
**integrator windup and nothing else**. The hazard it misses is **staleness**: while the
loop was stopped the heartbeat kept re-emitting the last command and the firmware kept
putting it on the wire every 2 s, so a command computed from an old reading went on
being executed through a real load change, with every indicator reading healthy.
Measured on the rig during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals,
zero control cycles.
- the control law is a pure function with no clock, which is what makes it testable
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.
The half that survives is the first one: the control law is still a pure function with
no clock, and the wall-clock window still lives in `Controller`, which has one - see
`run_control()`. What changed is that the controller now enforces a floor on the gap
between cycles, so `saturation_cycles` finally means a bounded number of seconds.
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".
If a tighter window matters on your site, lower `cycle_floor_s` (not below your meter's
update interval, or the floor starts firing on a healthy meter) or raise
`saturation_cycles` for a fast meter.
**This is not a freeze detector, and it deliberately cannot be one.** Nothing here
decides whether a repeated reading is true — see `unchanged_s` in the meter section for
why no threshold on that is safe at the converged operating point. The floor only stops
"the number is the same" from meaning "do not think". The consequence is worth stating
plainly: against a meter that is genuinely frozen at a wrong value, the loop will now
correct toward that wrong value instead of holding a stale command. It is bounded by
`max_w`, the arbiter and the device rating, and it is the same thing that already
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
safety property.
#### Why `target_grid_w` is not zero
+16 -12
View File
@@ -95,19 +95,23 @@ 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). 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.
# "3 samples" (§10.3). This counts CYCLES, and a cycle is still not a unit
# of time - but it is now a BOUNDED one. run_control() runs a cycle on a
# changed meter value OR on `cycle_floor_s` elapsing, whichever comes first
# (SAFETY-12), so three cycles is at most 3 x cycle_floor_s = 45 s at the
# defaults, and usually ~15 s at the reference P1's ~5 s update rate.
#
# 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.
# ⚠️ THIS COMMENT USED TO CLAIM there was no upper bound and that it did not
# matter, "because the same condition that stalls the counter stalls the
# whole loop, so nothing accumulates in the meantime". The first clause was
# true and the second was the bug: a stalled loop does not stop commanding,
# it goes on emitting its last command through the heartbeat. Windup was
# never the hazard; staleness was.
#
# The wall-clock window lives in Controller, which has a clock, exactly as
# this comment always said it should. ponytail: this function stays
# clockless; the remaining ceiling is that saturation_cycles expresses a
# bound in seconds rather than an exact number of them.
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
sat_count = min(sat_count + 1, 10) if saturated_now else 0
frozen = sat_count >= tuning.saturation_cycles
+165 -11
View File
@@ -40,6 +40,7 @@ from .hass import HomeAssistant
from .maintenance import IDLE, MaintConfig, Maintenance
from .mqtt import MqttPublisher
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)),
@@ -97,6 +109,10 @@ class Controller:
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
max_age_s=float(opts.get("meter_max_age_s", 30)))
self.p1_enabled = is_enabled(opts)
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
self.p1_source = None
# live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
@@ -299,19 +315,112 @@ 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))
last_beat = 0.0
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not a
# second scheduler running alongside the arrival trigger. Every cycle
# resets it, whichever trigger fired, so on a healthy changing meter it
# never elapses and not one existing cycle moves - and therefore no
# change to the per-cycle gain the tuning was measured against.
#
# That is true twice over, and the second reason is the sturdier one:
# the combined cycle rate is max(arrival rate, 1/floor), never the sum.
# At the defaults 1/15 s is strictly slower than the ~1/5 s arrival
# rate, so even a floor that raced the arrivals could not push the loop
# past its nominal healthy rate. The re-tune is unreachable by
# construction, not merely by the reset. ⚠️ That argument holds only
# while floor > the meter's update interval - which is why the schema
# minimum of 5 s deserves the second look validate.py has not given it
# yet: 5 s IS the reference meter's interval, i.e. the one configurable
# value that would double the cycle rate.
#
# Why it has to exist, and it is not only about latency: `self.grid !=
# last_grid` alone means a meter that repeats a value runs NO control
# cycles at all. The heartbeat below keeps re-emitting the last command
# and the firmware keeps putting it on the wire, so every indicator
# reads healthy - and, worse, the `inputs missing -> 0 W` claim lives
# INSIDE cycle(), so on a repeating meter THAT SAFETY CLAIM CANNOT FIRE.
# Demonstrated in test_loop.py: constant meter, SoC entity dies at t=4 s,
# and the pre-fix controller holds 220 W through the whole outage with
# the status line still reading "loop -> 220 W". Layer 1 does not cover
# it on a `meter_source: off` install either - no P1 age sensor is
# published, so the firmware has nothing to veto on. Measured on the rig
# during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals, zero
# cycles.
#
# This is NOT a freeze detector, and deliberately so - see
# HomeWizardLocalSource.unchanged_s for why one cannot be built here (at
# the converged -10 W target a 1 Wh register needs ~6 minutes to move,
# so any freeze threshold false-trips at the exact operating point we
# run at). Nothing here decides whether a repeated reading is true. It
# only stops "the number is the same" from meaning "do not think".
#
# ⚠️ THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. It is under
# validate.FIRMWARE_INGEST_VETO_S (30 s) and under meter_max_age_s on
# purpose, so the loop re-decides at least twice inside any window in
# which layer 1 might otherwise be the only thing deciding. If the
# firmware's veto ever moves, this default moves with it - it is not
# independently justified at 15.
floor = float(self.o.get("cycle_floor_s", 15))
last_cycle = None
while not self.stopping:
await self.read_inputs()
# A cycle is one meter update, exactly as on the reference install.
if self.grid != last_grid:
now_mono = asyncio.get_running_loop().time()
# A cycle is one meter update, exactly as on the reference install -
# or the floor elapsing, whichever comes first.
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
if self.grid != last_grid or overdue:
last_grid = self.grid
last_cycle = now_mono
# ⚠️ The FULL cycle, not a degraded one. A floor cycle runs the
# same law on the same inputs as an arrival cycle, because in P1
# mode the freshness question is already answered upstream and
# answered harder: past `meter_max_age_s` (30 s) p1.net_w is
# None, which is "inputs missing", which becomes a P_SAFETY 0 W
# claim. So a floor cycle cannot run the PI on a 60 s old
# figure, because at 60 s there is no figure.
#
# ⚠️ THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so
# matters more than the guarantee: with `meter_source: off`,
# read_inputs() takes self.grid straight from hass.number() with
# NO age gate at all, so a Home Assistant entity that has gone
# quiet reads as a current value on that path with or without
# this change. Running the full law is still the right choice
# there - the alternative is a degraded second law reasoning
# about an age nobody measured - but the floor does not make
# that path fresh, and nothing here should be read as claiming
# it does. And the integrator
# advances per CYCLE, not per second (control.py), so bounding
# the gap bounds the accumulation: at most one extra
# gain x error per floor interval, under the same
# integrator_max_w as every other cycle. A second, gentler
# control law for floor cycles would be a second set of
# dynamics nobody can test on this hardware.
await self.cycle()
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
# longer than ~30 s as "the controller is gone" and zeroes the
@@ -333,7 +442,8 @@ class Controller:
"status": "running" if self.auto else "stopped",
}
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
# watchdog subscribes to sensor.p1_sample_age_s and forces the layer-1
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
# note in mqtt.py; the composed name is not the object_id - and forces layer-1
# failsafe once it reaches max_age_s. With meter_source off there is no
# ingester feeding it, so published_age_s would be time-since-startup
# climbing without bound - i.e. every existing install would cross the
@@ -365,10 +475,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:
@@ -498,6 +620,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
@@ -523,10 +665,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):
@@ -540,6 +682,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()
@@ -552,6 +695,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:
@@ -559,6 +706,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__":
+26 -7
View File
@@ -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),
]
+369 -25
View File
@@ -57,6 +57,7 @@ _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
@@ -88,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_HA_SIGNED
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)
@@ -798,6 +799,236 @@ class HaSignedSource(HaDsmrSource):
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
# --------------------------------------------------------------------------- #
@@ -813,15 +1044,133 @@ def is_enabled(opts: dict) -> bool:
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 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, {
"import": opts.get("p1_import_entity", ""),
@@ -830,33 +1179,28 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
"phase_export": opts.get("p1_phase_export_entities") or [],
})
if 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()]
# ⚠️ Both of these are checked ONCE here rather than per telegram. A
# misconfigured source otherwise fails silently in the only way that
# looks exactly like a healthy one that has not been sent anything yet:
# no samples, a climbing age, and the firmware watchdog holding the
# battery at 0 W with nothing in the log saying why.
if not net:
_LOG.error("meter_source %s needs p1_net_entity - P1 ingestion "
"disabled (sensor.p1_sample_age_s would otherwise be "
"announced with nothing feeding it)", SOURCE_HA_SIGNED)
return None
if phase_net and len(phase_net) != ingest.phases:
_LOG.error("p1_phase_net_entities has %d entities but meter_phases "
"is %d - P1 ingestion disabled. Every telegram would be "
"rejected on the phase-count check.",
len(phase_net), ingest.phases)
return None
return HaSignedSource(session, ingest,
{"net": net, "phase_net": phase_net})
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 one of %s - P1 ingestion disabled",
source, ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED)))
return None
+284
View File
@@ -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)
+17 -2
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.3.0"
version: "0.4.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -45,10 +45,21 @@ options:
# 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: ""
@@ -79,6 +90,7 @@ options:
integrator_max_w: 0
heartbeat_s: 10
stale_input_s: 15
cycle_floor_s: 15
auto_start: false
# --- maintenance -----------------------------------------------------------
@@ -109,14 +121,16 @@ schema:
batt_invert: bool
setpoint_entity: str
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed)
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:
@@ -138,6 +152,7 @@ schema:
integrator_max_w: int(0,15000)
heartbeat_s: int(2,25)
stale_input_s: int(5,120)
cycle_floor_s: int(5,60)
auto_start: bool
maintenance_enabled: bool
+213
View File
@@ -0,0 +1,213 @@
"""Runnable check for the control loop's cadence. `python3 test_loop.py`
SAFETY-12: the loop must evaluate on a clock as well as on a change. Before the
fix, `run_control()` ran `cycle()` only when the meter value differed from the
previous poll, so a meter that repeats a value ran NO control cycles while the
heartbeat kept re-emitting the last command - measured on the rig during TEL-05
as 70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
⚠️ THIS TEST TAKES ~95 SECONDS OF WALL CLOCK (measured 93 s), on purpose, and
that figure is the only thing standing between it and a future developer who
decides a slow test is a broken one. The thing under test is a wall-clock floor
inside a loop that sleeps 1 s; mocking the clock would leave the assertion
testing the mock. It drives the real `run_control()` and counts what actually
happened. Eight runs of the real loop is what the eight scenarios cost.
⚠️ If you shorten the windows, re-measure and update this number and the one in
HANDOVER.md. An understated runtime reads as a hang.
`cycle_floor_s` is read from options with no lower bound in code, so a huge
value reproduces the pre-fix behaviour exactly. Every claim below is therefore
an A/B on one binary, not a comparison against a remembered old one.
"""
import asyncio
import sys
import time
from app.main import Controller
fails = []
NEVER = 1e9 # cycle_floor_s so large the floor can never elapse: pre-fix
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class FakeHass:
"""A scripted meter and a plant that tracks the command instantly."""
def __init__(self, meter, soc_dies_at=None):
self.meter = meter
self.soc_dies_at = soc_dies_at
self.batt = 0.0
self.writes = []
self.t0 = time.monotonic()
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
now = time.monotonic() - self.t0
if entity == "sensor.grid":
return self.meter(now)
if entity == "sensor.batt":
return self.batt
if entity == "sensor.soc":
if self.soc_dies_at is not None and now >= self.soc_dies_at:
return None
return 50.0
return None
async def set_number(self, entity, value):
self.writes.append(float(value))
self.batt = float(value)
return True
class _Store:
data = {}
def set(self, *a):
pass
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 1000,
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
"heartbeat_s": 10, "stale_input_s": 15}
def drive(meter, floor, seconds, soc_dies_at=None, **over):
"""Run the real control loop for `seconds`; return (cycle times, writes)."""
async def go():
hass = FakeHass(meter, soc_dies_at)
c = Controller(dict(BASE, cycle_floor_s=floor, **over), hass, _Store(),
_Pub(), stop=asyncio.Event())
c.auto = True
stamps = []
real = c.cycle
async def counted():
stamps.append(time.monotonic() - hass.t0)
await real()
c.cycle = counted
task = asyncio.create_task(c.run_control())
await asyncio.sleep(seconds)
c.stopping = True
await asyncio.wait_for(task, timeout=10)
return stamps, hass.writes, c.target, c.reason
return asyncio.run(go())
def worst_gap(stamps, seconds):
"""Longest stretch with no cycle, including the tail after the last one."""
edges = [0.0] + list(stamps) + [seconds]
return max(b - a for a, b in zip(edges, edges[1:]))
# --------------------------------------------------------------------------- #
print("a meter that repeats the same value")
FLAT = lambda t: 350.0 # noqa: E731
FLOOR, SECONDS = 3.0, 12.0
pre_stamps, pre_writes, _, _ = drive(FLAT, NEVER, SECONDS)
# The defect itself, demonstrated rather than described: one cycle at startup
# and then nothing for the rest of the window, while writes keep happening.
check("pre-fix: a constant meter runs exactly one cycle, at startup",
len(pre_stamps) == 1)
check("...while the heartbeat keeps writing anyway, so nothing looks wrong",
len(pre_writes) >= 2)
post_stamps, post_writes, _, _ = drive(FLAT, FLOOR, SECONDS)
check("post-fix: the same constant meter runs cycles throughout",
len(post_stamps) >= int(SECONDS / FLOOR) - 1)
# The actual acceptance criterion: a BOUND on the gap, not a count.
gap = worst_gap(post_stamps, SECONDS)
check(f"...with no gap longer than the floor ({gap:.1f}s <= {FLOOR + 1.5:.1f}s)",
gap <= FLOOR + 1.5)
check("...and the loop actually corrected, instead of holding a stale command",
len(post_writes) > len(pre_writes))
# --------------------------------------------------------------------------- #
print("a meter that changes normally - nothing may move")
# ⚠️ THE REGRESSION GUARD, and the more important half. The floor is a deadline
# on the gap between cycles, reset by every cycle whichever trigger fired - not
# a second scheduler. On a healthy meter it must never elapse, so the cycle
# count, the write count and the commanded values must be IDENTICAL either side
# of the fix. Anything else is a new write cadence, which SAFETY-12 forbids:
# the deadband, step_w and the 10 s force-write decide what reaches the wire,
# and this change is only allowed to alter when the loop thinks.
STEPS = [0.0, 300.0, 850.0, -400.0, 120.0, -900.0, 60.0]
LIVE = lambda t: STEPS[int(t // 2) % len(STEPS)] # noqa: E731
a_stamps, a_writes, _, _ = drive(LIVE, NEVER, 12.0)
b_stamps, b_writes, _, _ = drive(LIVE, FLOOR, 12.0)
check("a changing meter runs the same number of cycles as before the fix",
len(a_stamps) == len(b_stamps))
check("...commanding exactly the same sequence of values", a_writes == b_writes)
# --------------------------------------------------------------------------- #
print("a meter parked at the target - the converged operating point")
# The case TEL-05 warned about: at the converged target the power figure
# legitimately repeats for minutes. The loop must still run, and must still
# write nothing but the heartbeat, because the deadband - not the arrival
# trigger - is what holds the command still.
AT_TARGET = lambda t: -10.0 # noqa: E731
c_stamps, c_writes, _, _ = drive(AT_TARGET, NEVER, 12.0)
d_stamps, d_writes, _, _ = drive(AT_TARGET, FLOOR, 12.0)
check("the loop evaluates at the converged point too", len(d_stamps) > len(c_stamps))
check("...and the deadband still holds the command, so writes are unchanged",
d_writes == c_writes)
# --------------------------------------------------------------------------- #
print("an input dies while the meter repeats - the safety claim must still fire")
# ⚠️ THE STRONGEST PROPERTY IN SAFETY-12, and the one that makes it a safety fix
# rather than a latency fix. The `inputs missing -> 0 W` claim lives INSIDE
# cycle() (main.py, the stale_input_s branch and the P_SAFETY claim below it).
# Pre-fix, on a meter whose value happened to repeat, cycle() was never called
# again - so that claim could not fire AT ALL. The controller held its last
# command through an input outage of unbounded length with the status line still
# reading "loop -> N W".
#
# Layer 1 does not cover this either on a `meter_source: off` install: no P1 age
# sensor is published (see Controller.publish), so the firmware has nothing to
# veto on, and the heartbeat keeps its watchdog fed the whole time.
#
# stale_input_s is shortened to 3 s here so the outage resolves inside a test
# rather than inside a quarter of a minute. The mechanism is the default one.
c2_stamps, c2_writes, pre_target, pre_reason = drive(
FLAT, NEVER, 10.0, soc_dies_at=4.0, stale_input_s=3)
check("pre-fix: a dead input during a constant meter never reaches the safety claim",
pre_target != 0.0 and "safety" not in pre_reason)
check(f"...so it holds its last command instead ({pre_target:g} W, {pre_reason!r})",
pre_target == 220.0)
d2_stamps, d2_writes, post_target, post_reason = drive(
FLAT, FLOOR, 10.0, soc_dies_at=4.0, stale_input_s=3)
check("post-fix: the same outage commands 0 W", post_target == 0.0)
check(f"...through the safety claim, not by accident ({post_reason!r})",
"safety" in post_reason)
check("...and the 0 W actually reached the device", 0.0 in d2_writes)
# --------------------------------------------------------------------------- #
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
+502 -6
View File
@@ -11,6 +11,7 @@ computes the wrong quarter-hour figure whenever the telegram cadence changes.
"""
import asyncio
import json
import sys
import time
from datetime import datetime, timedelta, timezone
@@ -18,9 +19,11 @@ from datetime import datetime, timedelta, timezone
import aiohttp # already required by app.p1, so this adds no new dependency
from app.p1 import (
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, QuarterAverager,
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_MQTT,
build_source, is_enabled, make_sample, parse_mqtt_payload, split_signed,
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, HomeWizardLocalSource,
QuarterAverager,
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD, SOURCE_MQTT,
build_source, is_enabled, make_sample, parse_homewizard, parse_mqtt_payload,
split_signed,
)
fails = []
@@ -862,6 +865,432 @@ check("no phase list at all is still fine - per-phase billing is optional",
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"},
P1Ingest(phases=3), None, None), HaSignedSource))
# --------------------------------------------------------------------------- #
print("homewizard_local: parsing the meter's own /api/v1/data document")
# The same JSON `sim/hwsim.py` serves and the same JSON the real meter at
# 192.168.2.250 serves. One SIGNED connection figure plus three signed legs, so
# it goes through the same split_signed as ha_signed - the sign convention is
# not re-derived on this transport, it is reused.
def hw_doc(w, l1=None, l2=None, l3=None, imp_kwh=1234.567, exp_kwh=890.123):
return {"wifi_ssid": "sim", "smr_version": 50, "meter_model": "SIM-P1",
"total_power_import_kwh": imp_kwh, "total_power_export_kwh": exp_kwh,
"active_power_w": w,
"active_power_l1_w": l1, "active_power_l2_w": l2,
"active_power_l3_w": l3, "total_gas_m3": 0.0}
s = parse_homewizard(hw_doc(775.0, l1=775.0), 1)
check("the overnight base load (+775 W) is import on this transport too",
s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0)
check("the sample is tagged homewizard_local", s.source == SOURCE_HOMEWIZARD)
s = parse_homewizard(hw_doc(-5710.0, l1=-5710.0), 1)
check("the midday PV peak (-5710 W) is export, not a 5.7 kW draw",
s.net_w == -5710.0 and s.export_w == 5710.0)
check("a single-phase document still yields its one leg", s.per_phase_w == (-5710.0,))
# The TEL-04 three-phase survey reading, served the HomeWizard way.
s = parse_homewizard(hw_doc(187.0, 2301.0, 468.0, -2582.0), 3)
check("signed legs keep the exporting phase negative",
s.per_phase_w == (2301.0, 468.0, -2582.0))
check("per-phase import clamps the exporting leg out of the billed figure",
s.per_phase_import_w == (2301.0, 468.0, 0.0))
check("the legs sum to 2769 W while the connection nets 187 W",
sum(s.per_phase_import_w) == 2769.0 and s.net_w == 187.0)
# ⚠️ A single-phase HomeWizard serves `active_power_l2_w: null`. Unlike the HA
# transports there is no "wait for the rest" case - every field came out of ONE
# response, so a missing leg cannot be a STALE leg. Dropping the whole sample
# over it would turn a healthy meter into a climbing age, which is the exact
# false trip this transport exists to remove.
def parsed(doc, phases=1):
"""parse_homewizard(), with an escaping exception turned into a visible value.
Same reason as `built()` above: if the "all legs or none" guard goes missing,
parsing raises, and without this the suite aborts on a traceback instead of
reddening the NAMED check that says which rule died.
"""
try:
return parse_homewizard(doc, phases)
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" parse_homewizard raised {type(err).__name__}: {err}")
return err
s = parsed(hw_doc(500.0, l1=500.0), 3)
check("a meter serving fewer legs than meter_phases still gives a reading",
getattr(s, "net_w", None) == 500.0 and getattr(s, "per_phase_w", "?") is None)
check("...and does not fabricate a partial phase tuple",
getattr(s, "per_phase_import_w", "?") is None)
raises("a response that is not a JSON object is rejected",
lambda: parse_homewizard([1, 2, 3], 1))
raises("a document with no active_power_w is rejected, not read as 0 W",
lambda: parse_homewizard({"total_gas_m3": 0.0}, 1))
raises("a null active_power_w is rejected", lambda: parse_homewizard(hw_doc(None), 1))
raises("a string active_power_w is rejected", lambda: parse_homewizard(hw_doc("775"), 1))
raises("a NaN active_power_w is rejected",
lambda: parse_homewizard(hw_doc(float("nan")), 1))
raises("the 64954 signed-decode contamination is refused here too",
lambda: parse_homewizard(hw_doc(64954.0), 1))
raises("a non-numeric leg is rejected rather than quietly dropped",
lambda: parse_homewizard(hw_doc(600.0, "2000", 0.0, 100.0), 3))
# --------------------------------------------------------------------------- #
print("homewizard_local: every HTTP response is an arrival")
# THE WHOLE TICKET. sensor.p1_sample_age_s has to mean "time since the meter
# REPORTED", not "time since the value CHANGED". Home Assistant cannot express
# the first: a repeated reading emits no state_changed, advances last_reported
# on neither serialiser, and state_reported cannot be subscribed to at all
# ("Event filter is required"). Measured on the ENV-01 rig over 70 s of a frozen
# meter and again against the live house over ten repeated readings. Our own
# capture of this house's meter goes 42.2 s and 97.0 s between changes, both past
# the default meter_max_age_s of 30 - i.e. the age sensor would command 0 W on a
# perfectly healthy meter. Polling the meter itself removes that: the response is
# the arrival, and the number in it is not consulted.
class _FakeMeter:
"""hwsim in eight lines: serves whatever document it is told to, or a fault."""
def __init__(self, doc=None):
self.doc = doc
self.status = 200
self.body = None # set to raw text to serve something unparseable
self.delay = 0.0 # set to seconds to imitate a meter that hangs
self.hits = 0
async def handle(self, request):
from aiohttp import web
self.hits += 1
if self.delay:
await asyncio.sleep(self.delay)
# ⚠️ web.Response(text=...) defaults to text/plain. That is not just the
# "unparseable body" path: fed VALID json it serves a good document under
# the wrong mimetype, which is the only way to reach the content_type
# guard in poll_once - json_response can never produce it.
if self.body is not None:
return web.Response(text=self.body, status=self.status)
return web.json_response(self.doc, status=self.status)
async def _hw_rig(fn):
"""Run `fn(make_source, meter)` against a real HTTP server on localhost."""
from aiohttp import web
meter = _FakeMeter(hw_doc(350.0, l1=350.0))
app = web.Application()
app.router.add_get("/api/v1/data", meter.handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
async with aiohttp.ClientSession() as sess:
try:
return await fn(sess, port, meter)
finally:
await runner.cleanup()
async def _arrivals(sess, port, meter):
ing = P1Ingest(phases=1, max_age_s=30.0)
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
out = {}
out["first"] = await src.poll_once()
out["stamp1"] = ing.last.ingest_mono
# Four more polls of the IDENTICAL document - the meter has not moved a watt.
for _ in range(4):
await asyncio.sleep(0.05)
await src.poll_once()
out["ing"], out["src"], out["meter"] = ing, src, meter
return out
r = asyncio.run(_hw_rig(_arrivals))
ing, src = r["ing"], r["src"]
check("a poll of the meter's own API builds a sample",
r["first"] is True and ing.net_w == 350.0)
# ⚠️ THE acceptance criterion. Five identical readings, five arrivals. On
# ha_signed this whole sequence produces exactly ONE state_changed and then
# silence, and the age climbs to 30 s on a meter that is answering perfectly.
check("five identical readings are five arrivals, not one",
ing.samples == 5 and r["meter"].hits == 5 and src.polls == 5)
check("an unchanged value still stamps a NEW arrival time",
ing.last.ingest_mono > r["stamp1"])
check("...so the age resets on a response carrying an unchanged number",
ing.published_age_s < 1.0 and ing.stale is False)
check("no arrival was ever a parse error", ing.parse_errors == 0)
# --------------------------------------------------------------------------- #
print("homewizard_local: frozen vs steady, the pair HA cannot separate")
# ⚠️ Read this before changing anything here. A frozen meter (hwsim --fault
# freeze) answers 200 OK forever with a stale document. It is ARRIVING. So the
# age - an arrival detector, correctly - reads fresh on both, and that is not a
# defect in the age, it is the definition of the signal. What the local API adds
# that Home Assistant never had is the ENERGY REGISTERS: a meter under real load
# advances total_power_import_kwh (1 Wh resolution, ~10 s at 350 W) even when the
# power figure repeats, and a frozen one does not. That is the discriminator, and
# it is exposed as `unchanged_s` - deliberately NOT folded into the age, because
# this controller regulates grid power toward ~0 W and at a converged -10 W the
# export register needs six minutes to move. Thresholding unchanged_s at 30 s
# would rebuild the false-trip limit cycle at the exact operating point we aim
# for. See DOCS.md and HomeWizardLocalSource.unchanged_s.
async def _freeze_vs_steady(sess, port, meter):
ing_f = P1Ingest(phases=1, max_age_s=30.0)
frozen = HomeWizardLocalSource(sess, ing_f, "127.0.0.1", port=port, poll_s=1.0)
meter.doc = hw_doc(350.0, l1=350.0) # --fault freeze: never moves
for i in range(4):
if i:
await asyncio.sleep(0.1) # sleep BEFORE, so unchanged_s
await frozen.poll_once() # is read the instant a poll lands
ing_s = P1Ingest(phases=1, max_age_s=30.0)
steady = HomeWizardLocalSource(sess, ing_s, "127.0.0.1", port=port, poll_s=1.0)
kwh = 1234.567
for i in range(4):
if i:
await asyncio.sleep(0.1)
# A steady 350 W house: the power figure repeats, the register climbs.
kwh += 0.001
meter.doc = hw_doc(350.0, l1=350.0, imp_kwh=round(kwh, 3))
await steady.poll_once()
return (ing_f, frozen), (ing_s, steady)
(ing_f, frozen), (ing_s, steady) = asyncio.run(_hw_rig(_freeze_vs_steady))
check("a frozen meter keeps arriving, so both read the same power",
ing_f.net_w == 350.0 and ing_s.net_w == 350.0)
# ⚠️ Recorded as a rule, not a shortcoming: the age is an ARRIVAL detector and a
# frozen meter genuinely is arriving. Anyone tempted to make the age catch freeze
# is about to reintroduce the false trip on a steady house.
check("the age cannot separate them, and is fresh on both",
ing_f.published_age_s < 1.0 and ing_s.published_age_s < 1.0)
check("the frozen meter's measurement stands still", frozen.unchanged_s > 0.25)
check("...while the steady meter's energy register keeps advancing",
steady.unchanged_s < 0.05)
check("so the two ARE separable on the local API, which HA could not do",
frozen.unchanged_s > steady.unchanged_s * 3)
check("unchanged_s is None before the first response ever lands",
HomeWizardLocalSource(None, P1Ingest(), "h").unchanged_s is None)
# --------------------------------------------------------------------------- #
print("homewizard_local: a failed poll is a missing reading, never 0 W")
async def _failures(sess, port, meter):
ing = P1Ingest(phases=1, max_age_s=30.0)
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
await src.poll_once()
# Age the good sample by hand so a reset would be unmistakable.
ing.submit(make_sample(SOURCE_HOMEWIZARD, 350.0, 0.0, phases=1,
ingest_mono=time.monotonic() - 90.0))
out = {}
meter.status = 500
out["http500"] = await src.poll_once()
out["conn_after_500"] = src.connected
meter.status, meter.body = 200, "<html>gateway</html>"
out["notjson"] = await src.poll_once()
meter.body = None
meter.doc = {"total_gas_m3": 0.0} # 200 OK, no power in it
out["nopower"] = await src.poll_once()
out["ing"], out["src"] = ing, src
# And a meter that is not listening at all: `--fault down`.
dead = HomeWizardLocalSource(sess, P1Ingest(phases=1, max_age_s=30.0),
"127.0.0.1", port=1, poll_s=1.0)
out["down"] = await dead.poll_once()
out["dead"] = dead
return out
r = asyncio.run(_hw_rig(_failures))
ing, src = r["ing"], r["src"]
check("an HTTP 500 is not a reading", r["http500"] is False)
# A source that was connected and then failed must SAY it is disconnected -
# otherwise the diagnostic reads healthy while the age climbs, which is exactly
# the "connected: True, parse_errors: 0, samples: 0" state the rig recorded.
check("a poll that fails clears the connected flag", r["conn_after_500"] is False)
check("a 200 OK carrying something that is not JSON is not a reading",
r["notjson"] is False)
check("a 200 OK with no active_power_w in it is not a reading", r["nopower"] is False)
check("a meter that refuses the connection is not a reading",
r["down"] is False and r["dead"].connected is False)
check("every failure was counted as a parse error", ing.parse_errors == 3)
check("...and the transport records the transport-level ones separately",
src.poll_errors == 2 and src.polls == 4)
# ⚠️ The rule the safety chain rests on. A failed poll must not manufacture a
# balanced house, and must not reset the clock the watchdog reads.
check("a failed poll does not reset the age", ing.published_age_s > 89)
check("a failed poll leaves the last good value in place, and it is not 0 W",
ing.last.net_w == 350.0 and ing.stale is True and ing.net_w is None)
check("no failed poll ever became a sample", ing.samples == 2)
check("a 200 OK marks the transport connected even when its body is refused",
src.connected is True)
# --------------------------------------------------------------------------- #
print("homewizard_local: a wrong header, a hung meter, a submit that throws")
# Three failure shapes that all end the same way if they are mishandled - no
# sample, a climbing age, the battery at 0 W - and each of which would send the
# operator hunting the wrong device.
async def _header_and_timeout(sess, port, meter):
out = {}
# Valid JSON under text/plain: exactly what content_type=None is for.
meter.body = json.dumps(hw_doc(350.0, l1=350.0))
ing = P1Ingest(phases=1, max_age_s=30.0)
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
out["mimetype"] = await src.poll_once()
out["ing"] = ing
meter.body = None
# A meter that takes the connection and then does not answer.
meter.delay = 0.6
ing_t = P1Ingest(phases=1, max_age_s=30.0)
slow = HomeWizardLocalSource(sess, ing_t, "127.0.0.1", port=port,
poll_s=1.0, timeout_s=0.05)
out["timeout"] = await slow.poll_once()
out["timeout_err"] = ing_t.last_error
meter.delay = 0.0
return out
r = asyncio.run(_hw_rig(_header_and_timeout))
# ⚠️ A real firmware answering text/plain must not read as a dead meter. Drop
# content_type=None from poll_once and this goes red with "unexpected mimetype".
check("valid JSON under the wrong Content-Type is still a reading",
r["mimetype"] is True and r["ing"].samples == 1 and r["ing"].net_w == 350.0)
# ⚠️ str(asyncio.TimeoutError()) is the EMPTY STRING. Without the class-name
# fallback the status page reads "last error:" and then nothing, on a hung
# meter, at the moment someone is reading that line to find out why the battery
# went to 0 W.
check("a timed-out poll names the fault instead of logging an empty reason",
r["timeout"] is False and "TimeoutError" in (r["timeout_err"] or ""))
async def _submit_rejects(sess, port, meter):
"""submit() raising P1Error must be a rejection, not an escaping exception."""
class _P1Boom(P1Ingest):
def submit(self, sample):
raise P1Error("register went backwards")
ing = _P1Boom(phases=1, max_age_s=30.0)
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
try:
ok = await src.poll_once()
except Exception as err: # noqa: BLE001 - an escape IS the failure
ok = err
return ok, ing.parse_errors
ok, errs = asyncio.run(_hw_rig(_submit_rejects))
check("a submit that rejects the sample is handled, not left to escape",
ok is False and errs == 1)
async def _submit_explodes(sess, port, meter):
"""And an UNEXPECTED raise must not kill the poll task for good."""
class _Boom(P1Ingest):
def submit(self, sample):
raise RuntimeError("kaboom")
src = HomeWizardLocalSource(sess, _Boom(phases=1, max_age_s=30.0),
"127.0.0.1", port=port, poll_s=0.05)
task = asyncio.create_task(src.run())
await asyncio.sleep(0.3)
alive = not task.done()
task.cancel()
# ⚠️ BaseException, and the same reason built() exists: if run() loses its
# guard the task is already dead HOLDING the RuntimeError, and awaiting it
# re-raises - aborting the whole suite with a traceback instead of reddening
# the check that names the rule. The death is what `alive` records; catching
# it here is bookkeeping, not leniency.
try:
await task
except BaseException: # noqa: BLE001
pass
return alive, src.polls
# ⚠️ The silent-death case. A dead poll task fails SAFE - the age climbs and the
# controller commands 0 W - but it looks exactly like a dead meter, so the
# operator spends the outage power-cycling hardware that was never at fault.
alive, polls = asyncio.run(_hw_rig(_submit_explodes))
check("a raise inside a poll does not permanently kill the polling task",
alive is True and polls > 1)
# --------------------------------------------------------------------------- #
print("homewizard_local: selection by config")
check("homewizard_local is enabled", is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True)
sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "192.168.2.250"},
P1Ingest(), None, None)
check("meter_source homewizard_local selects the polling transport",
isinstance(sel, HomeWizardLocalSource))
# ⚠️ getattr, not attribute access, for the same reason `built()` exists: a
# startup guard that goes wrong returns None here, and `None.url` would abort the
# suite with a traceback instead of reddening the check that names the rule.
check("...pointed at the meter's own local API on the default port",
getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data")
check("the default cadence is the meter's own ~5 s update rate",
getattr(sel, "poll_s", None) == 5.0)
# ⚠️ The request must not outlive the poll interval: a backlog of queued requests
# behind a slow meter would land several arrivals for one measurement.
check("the request timeout is held under the poll interval",
getattr(sel, "timeout_s", 99) < getattr(sel, "poll_s", 0))
sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "10.0.0.9:8080"},
P1Ingest(), None, None)
check("an explicit host:port is honoured, which is how the sim is reached",
getattr(sel, "url", None) == "http://10.0.0.9:8080/api/v1/data")
sel = build_source({"meter_source": SOURCE_HOMEWIZARD,
"p1_host": "http://192.168.2.250/"}, P1Ingest(), None, None)
check("a pasted browser URL still resolves to the right host",
getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data")
# ⚠️ Caught once at startup, not once per poll - a source wired up wrong
# otherwise fails in the one way indistinguishable from a healthy one nobody has
# polled yet: no samples, a climbing age, the watchdog at 0 W, nothing in the log.
check("a blank p1_host is refused rather than silently never polling",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": ""},
P1Ingest(), None, None) is None)
check("...and whitespace does not sneak past it",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": " "},
P1Ingest(), None, None) is None)
check("an unparseable port is refused rather than guessed",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "meter:eighty"},
P1Ingest(), None, None) is None)
# ⚠️ The age can never be fresher than the poll interval, so a cadence at or past
# max_age_s means every reading is stale before its successor arrives: the
# controller would sit permanently on missing inputs while the meter is fine.
check("a poll cadence at meter_max_age_s is refused",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": 30}, P1Ingest(max_age_s=30), None, None) is None)
check("...and one past it too",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": 45}, P1Ingest(max_age_s=30), None, None) is None)
check("a cadence with headroom is accepted",
isinstance(build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": 5}, P1Ingest(max_age_s=30),
None, None), HomeWizardLocalSource))
check("a zero cadence is refused rather than spinning",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": 0}, P1Ingest(max_age_s=30), None, None) is None)
check("a non-numeric cadence is refused",
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
"meter_poll_s": "fast"}, P1Ingest(max_age_s=30), None, None) is None)
# The four modes must not bleed into each other.
check("ha_signed ignores p1_host and still selects the entity transport",
type(build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
"p1_host": "192.168.2.250"}, P1Ingest(), None, None))
is HaSignedSource)
check("homewizard_local ignores p1_net_entity and needs its own host",
build_source({"meter_source": SOURCE_HOMEWIZARD,
"p1_net_entity": "sensor.n"}, P1Ingest(), None, None) is None)
# --------------------------------------------------------------------------- #
print("the age sensor must not exist when P1 is off")
# ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog
@@ -877,6 +1306,8 @@ check("a missing meter_source is disabled", is_enabled({}) is False)
check("an empty meter_source is disabled", is_enabled({"meter_source": ""}) is False)
check("ha_dsmr is enabled", is_enabled({"meter_source": SOURCE_HA}) is True)
check("mqtt_p1 is enabled", is_enabled({"meter_source": SOURCE_MQTT}) is True)
check("homewizard_local is enabled here too",
is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True)
# The entity id SAFETY-01's firmware subscribes to, pinned by object_id.
row = [s for s in SENSORS if s[0] == "p1_age"]
@@ -915,6 +1346,17 @@ check("with P1 on the age sensor IS announced",
from app.main import Controller # noqa: E402
def _never():
"""A stop Event these publish-only tests never set.
`stop` is a REQUIRED keyword argument (CFG-01): a Controller that cannot
stop itself used to be the default one, and on the late-refusal path that
meant a live process with no control loop and no exit. These cases only call
publish(), so any Event will do - but they may not skip it.
"""
return asyncio.Event()
class _Store:
data = {}
@@ -937,14 +1379,14 @@ class _Pub:
pub_off = _Pub()
Controller({"meter_source": "off"}, None, _Store(), pub_off).publish()
Controller({"meter_source": "off"}, None, _Store(), pub_off, stop=_never()).publish()
check("with P1 off, p1_age is absent from the published payload",
"p1_age" not in pub_off.last)
check("...while the normal status keys are still published",
"setpoint" in pub_off.last and "grid" in pub_off.last)
pub_on = _Pub()
Controller({"meter_source": SOURCE_HA}, None, _Store(), pub_on).publish()
Controller({"meter_source": SOURCE_HA}, None, _Store(), pub_on, stop=_never()).publish()
check("with P1 on, p1_age is published", "p1_age" in pub_on.last)
check("...as a number, so has_state() becomes true only once we feed it",
isinstance(pub_on.last["p1_age"], float))
@@ -953,10 +1395,64 @@ check("...as a number, so has_state() becomes true only once we feed it",
# age sensor is a hard prerequisite for the FW-01 flash, and it has to appear on
# the transport that can actually read the meter in this house.
pub_sig = _Pub()
Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig).publish()
Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig, stop=_never()).publish()
check("with ha_signed selected, p1_age is published too",
"p1_age" in pub_sig.last and isinstance(pub_sig.last["p1_age"], float))
# ⚠️ And on homewizard_local, where it is the one age FW-01 may actually
# threshold - every other transport's age measures when the VALUE changed.
pub_hw = _Pub()
Controller({"meter_source": SOURCE_HOMEWIZARD}, None, _Store(), pub_hw, stop=_never()).publish()
check("with homewizard_local selected, p1_age is published",
"p1_age" in pub_hw.last and isinstance(pub_hw.last["p1_age"], float))
# --------------------------------------------------------------------------- #
print("unchanged_s has somewhere an operator can read it")
# ⚠️ The counter is deliberately NOT thresholded and NOT folded into the age -
# at the converged -10 W this controller aims for, a 1 Wh register needs six
# minutes to move, so any limit false-trips at the target operating point. That
# refusal only holds up if a HUMAN can interpret the number instead, and DOCS.md
# tells them "the transport tracks it as unchanged_s". Before this, nothing ever
# read the transport object back: connected, polls, poll_errors and unchanged_s
# were all write-only, and the documented signal existed nowhere an operator
# could see it.
def p1_rows(ctl):
"""The P1 status line(s), or the exception that stopped checks() making one.
Same reason as built(): a status line that raises would abort the suite with
a traceback instead of reddening the check that names the rule.
"""
try:
return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")]
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" checks() raised {type(err).__name__}: {err}")
return err
def _fed(source):
ctl = Controller({"meter_source": source}, None, _Store(), _Pub(), stop=_never())
ctl.p1.submit(make_sample(source, 350.0, 0.0, phases=1,
ingest_mono=time.monotonic()))
return ctl
hw_ctl = _fed(SOURCE_HOMEWIZARD)
hw_src = HomeWizardLocalSource(None, hw_ctl.p1, "127.0.0.1")
hw_src._note(hw_doc(350.0, l1=350.0))
hw_ctl.p1_source = hw_src # what amain() does once the transport exists
rows = p1_rows(hw_ctl)
check("the healthy P1 status line reports the transport's unchanged_s",
isinstance(rows, list) and len(rows) == 1 and "unchanged" in rows[0])
# ⚠️ getattr, not attribute access: p1_source is None until amain() builds one,
# and ha_dsmr/ha_signed/mqtt_p1 have no such counter at all. Reaching for it
# directly would turn the whole status page into a 500 on every other transport.
ha_rows = p1_rows(_fed(SOURCE_HA))
check("...and the line is unharmed on a transport that has no such counter",
isinstance(ha_rows, list) and len(ha_rows) == 1 and "unchanged" not in ha_rows[0])
print()
if fails:
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")
+309
View File
@@ -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")