Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b270a66a5 | ||
|
|
bbc903b2b4 | ||
|
|
d8e02ad3f8 | ||
|
|
4482cd8201 | ||
|
|
3527c8a04c | ||
|
|
8790c4d38f | ||
|
|
1d393ced22 | ||
|
|
3516947b5e | ||
|
|
86eaef2517 | ||
|
|
26c5ee8adc | ||
|
|
f72c48dbfd |
@@ -1,7 +1,140 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0
|
||||
|
||||
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
|
||||
[[SAFETY-32]] adds `bms_soc_floor: int(0,50)` as a **required** per-entry key on
|
||||
the `inverters` roster - no trailing `?`. A config that declares a roster and
|
||||
omits it on any entry is REFUSED at startup. An install running `inverters: []`,
|
||||
which is every single-inverter install including the reference one, is
|
||||
unaffected: the flat `bms_soc_floor` still describes the one pack.
|
||||
|
||||
CFG-04 recorded the rule this follows - the Supervisor keys the built image by
|
||||
`version:`, so a schema change without a bump is a schema nobody reads.
|
||||
|
||||
Contents, in merge order since 0.6.0 (`2d025ee`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3934ad7` | MAINT-05 - one recalibration per pack, migration graded against the roster |
|
||||
| `67e649a` | SPIKE-04a - inverter B and pack B recorded, from datasheet and serial |
|
||||
| `3993f9f` | SAFETY-32 - per-pack `bms_soc_floor`, rule 8 per unit, package-wide roster pin |
|
||||
| `f72c48d` | UX-01 - add-on configuration in English and Dutch |
|
||||
| `86eaef2` | TEL-07 - a second MQTT device for inverter B; A's identity byte-identical |
|
||||
| `4482cd8` | SAFETY-41 - the clamp alarm stops narrating normal anti-windup |
|
||||
|
||||
⚠️ **0.6.0 WAS NEVER SECTIONED.** Its content sits below this heading rather than
|
||||
under one of its own, because the bump shipped without cutting the section. That
|
||||
is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` and
|
||||
the table above is what is new since it. Splitting it retroactively would mean
|
||||
guessing a boundary; saying so is more useful than a guess.
|
||||
|
||||
## Unreleased
|
||||
|
||||
**`test_peakguard.py` answered two of its own checks with the wall clock
|
||||
(CI-06).** The file sits on the required `checks / unit (pull_request)` context
|
||||
and had a measured ~20 % flake rate. It cost a wrong merge diagnosis (parents
|
||||
green, merge red — read as a semantic merge conflict, it was the flake), a false
|
||||
red on two reviewers' CONTROL runs, and a false "mutation caught" that reached a
|
||||
merged card in four places. **A flaky check does not waste a re-run; it
|
||||
manufactures evidence for whichever hypothesis the reader already holds.**
|
||||
|
||||
Both causes are the control law's own gain `900 / remaining` applied to a
|
||||
wall-clock gap the file never intended to have. Neither fix widens anything.
|
||||
|
||||
* **The cross-time projection.** The "one cycle of the intent form is exactly
|
||||
the delivered form plus the command standing on the wire" check decided the
|
||||
intent form from a **second** `PeakTracker.project(..., datetime.now())` taken
|
||||
after the real cycle had returned, and compared it against a floor decided
|
||||
from the **first** projection — three lines under a comment saying both run
|
||||
"against the same frozen projection". `excess = 900 × (projected − ceiling) /
|
||||
remaining` moves at `excess / remaining` W/s, so at a 1500 W floor the 1.5 W
|
||||
tolerance bought exactly **`remaining / 1000` seconds**: 333 ms with 880 s
|
||||
left in the quarter, **6 ms with 10 s left**. The gap between the two
|
||||
statements measured **12.7–39.5 ms** over 26 instrumented runs. The check was
|
||||
decided by *where in the quarter it ran*. It now keeps the `Projection`
|
||||
**object** the real cycle graded and decides both forms from it — the clock is
|
||||
out of the comparison and the tolerance is **exact**, not widened. Measured on
|
||||
a real cycle: re-deciding the delivered form from that object reproduces the
|
||||
controller's own floor to **0.000000000000 W**.
|
||||
* **The fixture's own room.** `wired_over_limit()` laid telegrams with
|
||||
`range(0, span + 1, 5)`, leaving **0–5 s of the open block uncovered by
|
||||
construction**, and `excess` multiplies an uncovered second back up by
|
||||
`900 / remaining`. Measured on the shipped file: at **12 s of remaining a
|
||||
fixture that asked for a 1500 W floor delivered 613 W (−59 %)**, and at 2 s
|
||||
the guard **did not engage at all** — which fails "the real cycle files the
|
||||
peak claim", "…and the number written to the inverter is the guard's floor"
|
||||
and "the STATUS PAGE row for a floor-limited guard" **together**. The
|
||||
four-attempt retry could not help: four attempts take ~40 ms and all land in
|
||||
the same dying quarter. Fixed with one telegram stamped at `now` (what a real
|
||||
meter does, and what the docstring already claimed) plus `FIXTURE_ROOM_S`,
|
||||
**derived** from the 1 % accuracy the helper promises rather than picked:
|
||||
`net_w × lag / (remaining × want) ≤ 0.01 − lag/900` → **23.6 s → 24 s**, at
|
||||
the measured 0.1 s worst-case lag.
|
||||
|
||||
**Nothing was skipped, disabled or marked xfail** — the file still carries all
|
||||
of SAFETY-07 and SAFETY-25's forced-discharge SoC floor, and it gains a check:
|
||||
the delivered form re-decided from the cycle's own projection must reproduce the
|
||||
real controller's number to the bit, which pins the test's `decide()` kwargs as
|
||||
main.py's actual call rather than an approximation of it. **One tolerance got
|
||||
stricter** (1.5 W → exact), because removing the clock made exactness possible.
|
||||
|
||||
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
|
||||
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
|
||||
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
|
||||
to go. It has one now — **without touching a single entity that already
|
||||
exists.**
|
||||
|
||||
* **⭐ A's discovery payloads are byte-identical to `release/1.0`.** Same topics,
|
||||
same `unique_id`s, same payload bytes, same retain flags, in the same order —
|
||||
verified by **diffing the announced payloads**, not by reading the code:
|
||||
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
|
||||
`0 missing, 0 changed, 8 added` at a roster of two. That is what protects the
|
||||
live install's recorded history and the three watchdog automations that name
|
||||
its entities.
|
||||
* **⚠️ THE ASYMMETRY IS DELIBERATE — DO NOT "CLEAN IT UP".** The first roster
|
||||
entry's per-unit rows go on **the device it already has**; every later entry
|
||||
gets its **own** device (`via_device` the controller). "One device per
|
||||
inverter" is tidier and is the change that renames every entity on the live
|
||||
system — and because HA keeps the id it composed at first discovery, a rename
|
||||
produces a **new entity beside a dead one**: the history stays behind and
|
||||
every dashboard, template and automation goes on pointing at a sensor that
|
||||
quietly stopped updating. The composed ids come out symmetric anyway
|
||||
(`sensor.goodwe_inverter_a_battery_power` /
|
||||
`sensor.goodwe_inverter_b_battery_power`).
|
||||
* **Four per-unit rows** — setpoint, battery power, SoC and the SAFETY-02 sign
|
||||
latch — announced **only from a roster of two**. At N=1 nothing is announced
|
||||
and nothing is published: no second device, no renamed entity, no added topic,
|
||||
asserted rather than assumed.
|
||||
* **Site-scoped sensors stay site-scoped.** `grid` and `p1_sample_age_s` are
|
||||
announced **once**, on A's device, at every roster length 1–6. There is one P1
|
||||
and one grid connection, and `p1_sample_age_s` is a **watchdog input** — a
|
||||
second copy is a second answer to "is the meter fresh?", one of which is
|
||||
always the one nobody is looking at. Circulating power, controller status,
|
||||
maintenance phase and the capacity-tariff peak are site for the same reason.
|
||||
* **`inverter.roster_names()`** is now the one answer to "what are the units
|
||||
called", read by `build_roster()` **and** by the publisher — which has to know
|
||||
the roster **before** a `Controller` exists, because announcing happens on the
|
||||
broker's CONNACK.
|
||||
* **`test_mqtt.py`**, a new suite: the recorded `release/1.0` baseline
|
||||
(generated by running that commit's module, not transcribed), the payload
|
||||
diffs, the composed entity ids modelled **independently** of `app/mqtt.py` and
|
||||
anchored on the one id verified against the live system
|
||||
(`sensor.goodwe_p1_sample_age`), the announcement-vs-publication agreement on
|
||||
a real `Controller` with **different** SoC, battery sign and latch state on
|
||||
the two units, and **twelve mutations of the shipped module, all red, each on
|
||||
a named property**.
|
||||
* **MEASURED, NOT ASSERTED.** Three of those properties exist because the
|
||||
mutation table found their absence: dropping the unit from the *state topic*,
|
||||
moving `grid` into the per-unit set, and `units=()` in main()'s publisher call
|
||||
were all **green** on the first run. So was a roster fallback that named two
|
||||
nameless entries `inverter-a`. Sixteen mutations were run through
|
||||
`tools/mutation_run.sh`; the survivors are enumerated **with their size** in
|
||||
`test_mqtt.py` part 9.
|
||||
* ⚠️ **Inverter B does not physically exist** (~2026-09-12). Every fixture here
|
||||
is **simulated**, from the datasheet and serial in `NOTES.md` §1.1b. Nothing
|
||||
in this card was measured on a second inverter.
|
||||
|
||||
**The add-on configuration screen now speaks English and Dutch instead of raw
|
||||
option keys (UX-01).** There was no `translations/` directory at all, so Home
|
||||
Assistant fell back to rendering the option KEYS — an operator opening
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# TEL-07 — why inverter A does not get a device of its own, and B does
|
||||
|
||||
**Status: DECIDED 2026-09-05.** MQTT discovery announces **two** devices at a
|
||||
roster of two: the existing one, unchanged, plus one for the second inverter.
|
||||
It does **not** announce one per inverter. This document exists because the
|
||||
symmetrical design is the obvious one, is tidier, and would destroy the live
|
||||
system's recorded history — so the next person to read `app/mqtt.py` needs the
|
||||
reason written down where they will find it before they "fix" it.
|
||||
|
||||
Filed with TEL-07. Card `6a9bfa75f0a3550275ba4888`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The mechanism, in one paragraph
|
||||
|
||||
`app/mqtt.py`'s own note records the finding, tested on HA 2026.8: Home
|
||||
Assistant composes an entity id from the **device name plus the entity name**,
|
||||
and `object_id` in the discovery payload does **not** override it. So the device
|
||||
name is half of every entity id.
|
||||
|
||||
The second half of the mechanism is the half that makes it silent. Home
|
||||
Assistant keeps the entity id it composed at **first** discovery. Re-scoping the
|
||||
device therefore does not rename an entity in place; it creates a **new** entity
|
||||
beside the old one. The recorded history stays on the old id, the old id stops
|
||||
updating, and nothing errors. Every dashboard card, template and automation that
|
||||
names it — including `goodwe_master_watchdog_held_*` and
|
||||
`goodwe_master_parked_at_0_w_*`, which are live — keeps pointing at a sensor
|
||||
that has quietly gone flat.
|
||||
|
||||
`sensor.goodwe_p1_sample_age` is the sharp end of it: the ESP32's stale-input
|
||||
watchdog subscribes to that composed name, and its own comment calls this the
|
||||
failure that "FAILS SILENTLY AND LOOKS CORRECT".
|
||||
|
||||
## 2. The two designs
|
||||
|
||||
| | **one device per inverter** | **what shipped** |
|
||||
|---|---|---|
|
||||
| A's device | new: `inverter-a` | **the one it already has** |
|
||||
| B's device | new: `inverter-b` | new: `goodwe_rs485_controller_inverter_b` |
|
||||
| A's existing entity ids | **all renamed** | **byte-identical** |
|
||||
| A's recorded history | **stranded on dead ids** | kept |
|
||||
| watchdog automations | **silently broken** | untouched |
|
||||
| composed per-unit ids | `sensor.goodwe_inverter_a_*` / `..._b_*` | **the same** |
|
||||
| reads as | symmetrical and tidy | asymmetrical, and needs this file |
|
||||
|
||||
The last two rows are the whole argument. The tidy design's only real benefit is
|
||||
a pair of symmetric, predictable entity ids — and the shipped design produces
|
||||
**exactly the same ids** by putting the unit's name in the *entity* name for the
|
||||
first roster entry and in the *device* name for the others. There is nothing
|
||||
left to buy, and the price was the live system's history.
|
||||
|
||||
## 3. What is per-unit and what is site
|
||||
|
||||
**Per-unit:** setpoint (this unit's share of the site target, the number its own
|
||||
heartbeat re-emits), battery power (`Inverter.batt`, register `0x0518`, this
|
||||
unit's own reading), SoC (`Inverter.soc` — **this** pack; the site row is the
|
||||
*emptiest* pack, which at N≥2 may be the other one), and the SAFETY-02 sign
|
||||
latch, which is one instance per actuator by construction.
|
||||
|
||||
**Site:** `grid` and `p1_sample_age_s`. There is one P1 meter and one grid
|
||||
connection, so a per-inverter copy invents two of a thing there is one of — and
|
||||
`p1_sample_age_s` is a **watchdog input**, so a second copy is a second answer to
|
||||
"is the meter fresh?", one of which is always the one nobody is looking at.
|
||||
Circulating power is site because it *is* a property of the set
|
||||
(`sum(|Bᵢ|) − |sum(Bᵢ)|`); so are the controller status, the maintenance phase
|
||||
and the capacity-tariff peak.
|
||||
|
||||
**Named as absent rather than left looking finished:** the card also lists
|
||||
"inverter AC power" and per-unit heartbeats as per-unit. Neither has a source in
|
||||
the add-on today — nothing reads an AC-power register per unit, and
|
||||
`Inverter.last_beat` is a monotonic loop clock with no wall-clock age published
|
||||
anywhere. Announcing them would create entities nothing ever feeds, which is the
|
||||
hazard the `omit` note in `mqtt.py` exists to refuse. They are gaps.
|
||||
|
||||
## 4. Why a roster of one announces nothing
|
||||
|
||||
Every install running today is a roster of one, and at N=1 the site rows **are**
|
||||
that unit's rows: `battery` is its power, `soc` is its pack. Four extra entities
|
||||
would be four new rows carrying numbers already on the dashboard, on a live
|
||||
system, for no information. `announced_units()` answers `[]` below
|
||||
`PER_UNIT_MIN_ROSTER`, and `Controller.publish()` reads **that same list** —
|
||||
the empty list is the gate on both halves, rather than a predicate written twice
|
||||
that could later disagree.
|
||||
|
||||
## 5. How it was verified, and what that does not cover
|
||||
|
||||
The claim is a **diff of the announced payloads** against `release/1.0`,
|
||||
generated by running that commit's module rather than transcribed from it:
|
||||
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
|
||||
`0 missing, 0 changed, 8 added` at a roster of two. `test_mqtt.py` part 2 shows
|
||||
`_announce()` publishes exactly what is diffed, so the comparison is against the
|
||||
bytes paho is handed.
|
||||
|
||||
Sixteen mutations were run. Twelve are in the file as a running table, each
|
||||
named with the property it breaks. Three properties in that table exist only
|
||||
because the table found their absence — dropping the unit from the state topic,
|
||||
moving `grid` into the per-unit list, and `units=()` in main()'s publisher call
|
||||
were all green on the first run, as was a roster fallback that named two
|
||||
nameless entries `inverter-a`.
|
||||
|
||||
**What it does not cover is listed in `test_mqtt.py` part 9, with sizes.** The
|
||||
largest item: Home Assistant itself is not in the loop. The entity-id
|
||||
composition is a **model**, anchored on exactly one measured id
|
||||
(`sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26).
|
||||
Closing that needs a broker, an HA and the owner present, and this card is not
|
||||
allowed near the live install.
|
||||
|
||||
## 6. ⚠️ Inverter B does not exist
|
||||
|
||||
Not until ~2026-09-12. Every roster of two in this repository's checks is
|
||||
**simulated**; B's `max_w: 4600` is the datasheet's figure for a GW5048D-ES
|
||||
(serial `55048ESU22AW0992`), recorded in `goodwe/NOTES.md` §1.1b, and nothing
|
||||
about B has ever been measured. No entity described here has been seen on a
|
||||
broker. When the second inverter is installed, the first thing to check is that
|
||||
the eight ids in §1 are still updating — not that the new ones appeared.
|
||||
@@ -166,6 +166,53 @@ in the log rather than silent, but it is an outage until someone puts the
|
||||
single-inverter entity back in that field. **Before downgrading, write down the
|
||||
`setpoint_entity` of the unit you want to keep running.**
|
||||
|
||||
### What appears in Home Assistant at two inverters
|
||||
|
||||
**Nothing you already have changes.** The eight entities this add-on has always
|
||||
published — `Setpoint`, `Grid power`, `Battery power`, `Battery SoC`,
|
||||
`Circulating power`, `Maintenance phase`, `Controller status`, `P1 sample age` —
|
||||
keep their device, their `unique_id`s and therefore their **entity ids and their
|
||||
whole recorded history**. At a roster of one, nothing new is announced at all:
|
||||
no second device, no renamed entity, not one added topic.
|
||||
|
||||
Declare a second inverter and **four rows per unit** appear beside them:
|
||||
|
||||
| per-unit row | what it is |
|
||||
|---|---|
|
||||
| `Setpoint` | this unit's own share of the site target — the number its heartbeat re-emits |
|
||||
| `Battery power` | this unit's own reading, `+` = discharging |
|
||||
| `Battery SoC` | **this** pack. The site `Battery SoC` row is the *emptiest* pack, which may be the other one |
|
||||
| `Sign latch` | this unit's SAFETY-02 latch: `tripped` or `clear` |
|
||||
|
||||
⚠️ **The devices are deliberately asymmetric, and it is not an oversight.** The
|
||||
first inverter's rows are added to the **existing** *GoodWe* device; every later
|
||||
inverter gets its **own** device (*GoodWe Inverter B*), shown in Home Assistant
|
||||
as connected via the controller. Home Assistant composes an entity id from the
|
||||
**device name plus the entity name**, so giving the first inverter a device of
|
||||
its own would rename every entity you already have — and because HA keeps the id
|
||||
it composed at first discovery, you would get a new entity beside a dead one:
|
||||
the history stays on the old id and every dashboard, template and automation
|
||||
naming it silently stops updating. The entity ids come out symmetric anyway —
|
||||
`sensor.goodwe_inverter_a_battery_power` beside
|
||||
`sensor.goodwe_inverter_b_battery_power`.
|
||||
|
||||
**Do not rename the device.** Not in this add-on, and not in Home Assistant's
|
||||
device page. It is half of every entity id, including
|
||||
`sensor.goodwe_p1_sample_age`, which the ESP32's stale-input watchdog subscribes
|
||||
to; a rename disarms that guard by leaving it waiting for a state that never
|
||||
arrives, and it fails silently and looks correct.
|
||||
|
||||
**`Grid power` and `P1 sample age` stay site-scoped and are announced once.**
|
||||
There is one P1 meter and one grid connection; a per-inverter copy would invent
|
||||
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
|
||||
`Circulating power` (it is a property of the *set* of inverters), the controller
|
||||
status, the maintenance phase and the capacity-tariff peak.
|
||||
|
||||
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
|
||||
and `Inverter B` — are two names to the validator and one id once slugged. The
|
||||
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
|
||||
get then depend on roster order. Give your inverters plainly different names.
|
||||
|
||||
### P1 meter ingestion
|
||||
|
||||
`meter_entity` above expects one signed sensor, which usually means a template
|
||||
|
||||
@@ -145,14 +145,94 @@ from datetime import datetime
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
|
||||
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||
# that a pinned loop is never quiet for a whole billing quarter.
|
||||
REASSERT_S = 30.0
|
||||
# Re-assert the notification this often while the clamp stays engaged.
|
||||
#
|
||||
# ⚠️ RAISED 30 s -> 150 s on 2026-09-05, and the reason is a REGIME CHANGE, not a
|
||||
# change of mind about the alarm. 30 s was the planning cadence (2.3), chosen
|
||||
# when a loop-side engagement was a rare event: `main.py`'s note at the action
|
||||
# log records "60 cycles of the §10.3 runaway produce 0 write-path engagements
|
||||
# and 2 loop-side ones". On the live install the counter reached 48 loop-side
|
||||
# ENGAGEMENTS in ~25 minutes of ordinary operation - roughly one cycle in six,
|
||||
# not a pinned rail, but far more often than a guard sized for 2-in-60 assumed -
|
||||
# and the owner received a persistent notification about it all day. ⚠️ NOT
|
||||
# because of the 30 s re-assert - see the block below; those notifications never
|
||||
# consulted this constant at all.
|
||||
#
|
||||
# ⚠️ BOTH FIGURES ARE ENGAGEMENT COUNTS; only the regime differs - 2-in-60 was
|
||||
# measured over a §10.3 RUNAWAY, 48-in-305 was ordinary running on the live
|
||||
# install. The 48 is read off the live log, not measured in this repo.
|
||||
#
|
||||
# ⚠️ AND RAISING THIS CONSTANT ALONE DID NOT FIX THAT FLOOD - review found it
|
||||
# and the first version of this change was wrong. At ~5 s cycles a 1-in-6
|
||||
# engagement leaves ~25 s of clean cycles between engagements, past
|
||||
# CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED between almost every one
|
||||
# and came back as a fresh rising edge. The rising edge used to fire
|
||||
# unconditionally, so those notifications never consulted this constant at all:
|
||||
# 48 notifications at 30 s, and still 48 at 150 s. The fix was to rate-limit the
|
||||
# RISING EDGE too and let `last_alarm` survive a de-assert, so this interval
|
||||
# bounds the notification rate across episodes rather than only within one.
|
||||
# Measured on that same window afterwards: 51 episodes, 11 notifications.
|
||||
#
|
||||
# WHY the loop reaches its integrator bound so much more often than the guard's
|
||||
# author saw: `max_w` on the live install is 5000 W, while the loop's slew was
|
||||
# derived against a 1500 W clamp - admin/goodwe `NOTES.md` §"Simulated 2 kW load
|
||||
# step (gain 0.6, clamp 1500)". ⚠️ THAT DERIVATION IS IN THE OTHER REPO; nothing
|
||||
# in this one corroborates it, so do not go looking here. SAFETY-39 is the ticket
|
||||
# that re-derives it, and fixing that is what would stop the railing; this
|
||||
# constant only changes how often the alarm repeats.
|
||||
#
|
||||
# ⚠️ 150 s IS THE REQUIREMENT'S EDGE WITH MARGIN, AND THE CHECK NOW ENFORCES THAT
|
||||
# EDGE. `test_clamp.py` part 2 grades the cadence in wall-clock terms - a pinned
|
||||
# loop must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter
|
||||
# that bills the capacity tariff - which caps this constant at 900/5 = 180 s.
|
||||
# Measured on the worst sliding 900 s window: 180 s earns 5 alarms, 181 s earns
|
||||
# 4. 150 s earns 6.
|
||||
#
|
||||
# ⚠️ THAT CHECK USED TO PERMIT 224 s, AND THE FIX SHIPPED WITH THIS COMMIT. Its
|
||||
# sweep starts at i=0, where the rising edge is free, so it measured one alarm
|
||||
# more than a real quarter earns. A later editor raising this to 200 s would have
|
||||
# had a green suite and a requirement quietly unmet. `test_clamp.py` now grades
|
||||
# floor(QUARTER_S / REASSERT_S) directly.
|
||||
#
|
||||
# ⚠️ MIN_PER_QUARTER = 5 IS A TEST-LOCAL INTEGER, not a spec figure - it exists
|
||||
# only in `test_clamp.py` and the CHANGELOG entry that introduced it. The 900 s
|
||||
# quarter IS system-wide (`tariff.py`, `freeze.py`, `DOCS.md`). So the 180 s
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
REASSERT_S = 150.0
|
||||
|
||||
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||
CLEAR_CYCLES = 3
|
||||
|
||||
# ⚠️ SAFETY-41. Consecutive ENGAGED cycles after which an episode speaks even if
|
||||
# the notification budget is spent. THE ESCALATION EXISTS BECAUSE THE RATE LIMIT
|
||||
# ALONE DELAYS A RUNAWAY, and review measured it: a benign one-cycle clamp
|
||||
# followed by a continuous runaway held the runaway's first notification for up
|
||||
# to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary
|
||||
# railing spends the budget essentially all the time, a runaway arriving during
|
||||
# normal operation would be delayed with near-certainty. That is the "benign
|
||||
# noise suppresses the bug signal" shape this module's docstring is written
|
||||
# against.
|
||||
#
|
||||
# THE DISCRIMINATOR IS RUN LENGTH, and the two populations genuinely differ: the
|
||||
# live flood was runs of length ONE (engage, de-assert, engage), while a §10.3
|
||||
# runaway engages on EVERY cycle. So a sustained run speaks regardless of budget,
|
||||
# and scattered chatter does not.
|
||||
#
|
||||
# Measured, shipped-without vs shipped-with - it costs nothing it was protecting:
|
||||
# live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications
|
||||
# chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications
|
||||
# pinned 900 s quarter (floor is 5) 6 -> 6 alarms
|
||||
# runaway delay after a benign episode 140 s -> 10 s
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
@@ -258,6 +338,10 @@ class ClampWatchdog:
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
@@ -269,26 +353,72 @@ class ClampWatchdog:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
# EPISODE no matter how fast the episodes came - and
|
||||
# `REASSERT_S` never entered into it, because it only governs
|
||||
# the still-engaged path below.
|
||||
#
|
||||
# That is the shape the live install actually produced: 48
|
||||
# engagements in 25 minutes, scattered roughly one cycle in six.
|
||||
# At a ~5 s cycle the gaps are ~25 s of clean cycles, which
|
||||
# exceeds CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED
|
||||
# between almost every engagement and each one came back as a
|
||||
# fresh rising edge. Measured on this class over that window:
|
||||
# 48 episodes and 48 notifications at REASSERT_S = 30 - and
|
||||
# still 48 at REASSERT_S = 150. Raising the re-assert removed
|
||||
# NONE of them, because none of them came from the re-assert.
|
||||
#
|
||||
# So the interval now bounds the NOTIFICATION RATE rather than
|
||||
# the repeat rate within one episode: `last_alarm` survives a
|
||||
# de-assert, and a new episode speaks only if the interval has
|
||||
# elapsed. ⚠️ NOTHING IS DROPPED. Every engagement is still
|
||||
# counted, every episode still increments, the action-log row is
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
# once it has engaged SUSTAINED_CYCLES times in a row. A runaway
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
if not self.active:
|
||||
return False
|
||||
self.clean_cycles += 1
|
||||
if self.clean_cycles >= CLEAR_CYCLES:
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.last_alarm = None
|
||||
# ⚠️ `last_alarm` DELIBERATELY SURVIVES THE DE-ASSERT. Clearing it
|
||||
# here is what made the next rising edge speak immediately however
|
||||
# soon it arrived, which is the flood described at the rising edge
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
@@ -357,6 +357,37 @@ def roster_entries(opts: dict) -> list:
|
||||
return list(entries)
|
||||
|
||||
|
||||
def roster_names(opts: dict) -> list:
|
||||
"""The unit names, in roster order. THE one answer, for two callers.
|
||||
|
||||
⚠️ IT EXISTS BECAUSE MQTT DISCOVERY CANNOT WAIT FOR A ROSTER. TEL-07 gives
|
||||
every unit past the first its own MQTT device, and `MqttPublisher` is
|
||||
constructed in `main()` BEFORE `Controller.__init__` builds the roster -
|
||||
announcing happens on the broker's CONNACK, which can land first. Building a
|
||||
second roster there would construct a second set of `Inverter`s off the same
|
||||
Store; transcribing the naming rule there would be a third spelling of it.
|
||||
So the naming rule lives here, `build_roster()` reads it, and the two cannot
|
||||
drift.
|
||||
|
||||
⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries both
|
||||
called `inverter-a` would share one Store namespace, so one unit's SAFETY-02
|
||||
latch would be the other's - a fault on B latching A is the exact failure
|
||||
DEV-02's namespaced keys removed. Rule 9 refuses a nameless entry outright;
|
||||
this only decides what the refusal is allowed to call it in the meantime.
|
||||
|
||||
An EMPTY `inverters:` is the flat-key shape and its one unit is `UNIT_A`,
|
||||
which is what every install running today reads.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [UNIT_A]
|
||||
names = []
|
||||
for i, entry in enumerate(entries):
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
names.append(str(entry.get("name") or "").strip() or f"inverter-{i + 1}")
|
||||
return names
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
"""The site's inverters, in roster order.
|
||||
|
||||
@@ -414,15 +445,15 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
store=store, sign_cfg=sign_cfg)]
|
||||
|
||||
roster = []
|
||||
for i, entry in enumerate(entries):
|
||||
names = roster_names(opts)
|
||||
# ⚠️ NAMES COME FROM `roster_names()`, NOT FROM A SECOND READING OF THE
|
||||
# ENTRIES. TEL-07 announces one MQTT device and one `unique_id` per unit off
|
||||
# these names, and it has to build them BEFORE a `Controller` exists - so a
|
||||
# second spelling of the fallback here would be an entity announced under a
|
||||
# name no unit answers to. `zip` pairs them positionally, which is what
|
||||
# `roster_names()` is defined to be.
|
||||
for entry, name in zip(entries, names):
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
# ⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries
|
||||
# both called `inverter-a` would share one Store namespace, so one
|
||||
# unit's SAFETY-02 latch would be the other's - a fault on B latching A
|
||||
# is the exact failure DEV-02's namespaced keys removed. Rule 9 refuses
|
||||
# a nameless entry outright; this only decides what the refusal is
|
||||
# allowed to call it in the meantime.
|
||||
name = str(entry.get("name") or "").strip() or f"inverter-{i + 1}"
|
||||
roster.append(Inverter(
|
||||
name,
|
||||
setpoint_entity=entry.get("setpoint_entity", ""),
|
||||
|
||||
@@ -53,11 +53,12 @@ from .hass import HomeAssistant
|
||||
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
|
||||
# window an operator would look up here is the one the roster actually uses.
|
||||
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
roster_entries)
|
||||
roster_entries, roster_names)
|
||||
from .maintenance import (
|
||||
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
|
||||
)
|
||||
from .mqtt import MqttPublisher
|
||||
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
unit_state_key)
|
||||
from . import peakguard
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
@@ -3073,6 +3074,28 @@ class Controller:
|
||||
if self.p1_enabled:
|
||||
# Recomputed here, once a second, on purpose - see P1Ingest.
|
||||
values["p1_age"] = round(self.p1.published_age_s, 1)
|
||||
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
|
||||
#
|
||||
# ⚠️ THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY.
|
||||
# `announced_units()` answers `[]` at a roster of one, `zip` then yields
|
||||
# nothing, and the values dict comes out byte-identical to what shipped -
|
||||
# so the condition that decides what is ANNOUNCED is the same expression
|
||||
# that decides what is PUBLISHED. Written as a second predicate, the two
|
||||
# could disagree, and both directions are bad: an announced entity
|
||||
# nothing feeds (the hazard `omit` exists for) or, at N=1, a published
|
||||
# topic nothing announced, which AC 4 forbids outright.
|
||||
#
|
||||
# ⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and
|
||||
# `soc` stays the EMPTIEST pack; these are additions beside them, not a
|
||||
# re-pointing of them, because those two ids are on the live install's
|
||||
# dashboards and in its automations.
|
||||
names = announced_units([inv.name for inv in self.inverters])
|
||||
for inv, slug in zip(self.inverters, unit_slugs(names)):
|
||||
values[unit_state_key(slug, "setpoint")] = inv.target
|
||||
values[unit_state_key(slug, "battery")] = inv.batt
|
||||
values[unit_state_key(slug, "soc")] = inv.soc
|
||||
values[unit_state_key(slug, "sign_latch")] = (
|
||||
"tripped" if inv.sign_wd.tripped else "clear")
|
||||
self.mqtt.publish(values)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
@@ -3779,6 +3802,13 @@ async def amain() -> None:
|
||||
broker.get("username") if broker else None,
|
||||
broker.get("password") if broker else None,
|
||||
omit=() if is_enabled(opts) else ("p1_age",),
|
||||
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
|
||||
# would construct a SECOND set of `Inverter`s off the same
|
||||
# Store, minutes before `Controller.__init__` constructs the
|
||||
# real ones; `roster_names()` is the naming rule on its own and
|
||||
# is what `build_roster()` itself reads. At a roster of one this
|
||||
# announces nothing new - see `announced_units()`.
|
||||
units=roster_names(opts),
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
|
||||
|
||||
+232
-24
@@ -90,14 +90,239 @@ SENSORS = [
|
||||
BASE = "goodwe_ctl"
|
||||
AVAILABILITY = f"{BASE}/availability"
|
||||
|
||||
# --- TEL-07: a SECOND device, for inverter B, and NOT one per inverter -------
|
||||
#
|
||||
# ⚠️ READ THE DEVICE-NAME NOTE ABOVE BEFORE CHANGING ANYTHING HERE. The device
|
||||
# name is half of every entity_id, so re-scoping `DEVICE` renames every entity
|
||||
# the live install already has - and HA keeps the id it composed at FIRST
|
||||
# discovery, so what a rename actually produces is a NEW entity beside a dead
|
||||
# one: the history stays behind on the old id, and every dashboard, template
|
||||
# and automation that names it (including `goodwe_master_watchdog_held_*` and
|
||||
# `goodwe_master_parked_at_0_w_*`) keeps pointing at the entity that stopped
|
||||
# updating. That is silent. So:
|
||||
#
|
||||
# `DEVICE` IS NOT TOUCHED AND ITS ENTITIES ARE NOT TOUCHED. It is the SITE
|
||||
# device - the meter's view and the controller's own state - and at the
|
||||
# shipped roster of one it is also, in effect, inverter A's.
|
||||
#
|
||||
# THE FIRST ROSTER ENTRY GETS NO NEW DEVICE. Its per-unit rows are announced
|
||||
# ON THE DEVICE IT ALREADY HAS, under new `unique_id`s and new names. Nothing
|
||||
# existing is renamed, so nothing existing loses its history.
|
||||
#
|
||||
# EVERY LATER ENTRY GETS ITS OWN DEVICE (`via_device` the site one).
|
||||
#
|
||||
# ⚠️ THE ASYMMETRY IS THE POINT, AND IT WILL LOOK LIKE AN OVERSIGHT. "One
|
||||
# device per inverter", with A as `inverter-a` and B as `inverter-b`, is tidy,
|
||||
# symmetrical, and is the change that destroys the live system's recorded
|
||||
# history. DO NOT "CLEAN THIS UP". The composed entity_ids come out symmetric
|
||||
# anyway - `sensor.goodwe_inverter_a_battery_power` and
|
||||
# `sensor.goodwe_inverter_b_battery_power` - which is the whole benefit of the
|
||||
# tidy version, without the cost.
|
||||
#
|
||||
# WHAT IS PER-UNIT AND WHAT IS SITE, and why the split is where it is:
|
||||
#
|
||||
# PER-UNIT setpoint (this unit's share of the site target, the number its
|
||||
# own heartbeat re-emits), battery power (`inv.batt`, this unit's
|
||||
# own meter reading), SoC (`inv.soc`, THIS pack - the site row is
|
||||
# the EMPTIEST pack, which at N>=2 may be the other one), and the
|
||||
# SAFETY-02 sign latch, which is per actuator by construction.
|
||||
#
|
||||
# SITE `grid` and `p1_age`. There is ONE P1 meter and ONE grid
|
||||
# connection; announcing them per inverter would invent two of a
|
||||
# thing there is one of. `p1_sample_age_s` is additionally a
|
||||
# WATCHDOG INPUT (see `omit` below and the firmware note above), so
|
||||
# a second copy of it is a second copy of a safety signal - two
|
||||
# answers to "is the meter fresh?", one of which is always the one
|
||||
# nobody is looking at. Also site: circulating power (it is
|
||||
# `sum(|Bi|) - |sum(Bi)|`, a property of the SET), the controller
|
||||
# status, the maintenance phase, and the capacity-tariff peak.
|
||||
#
|
||||
# NOT ANNOUNCED, AND SAID OUT LOUD RATHER THAN LEFT LOOKING FINISHED: the
|
||||
# card also lists "inverter AC power" and per-unit HEARTBEATS as per-unit.
|
||||
# Neither has a source in this add-on today - nothing reads an AC-power
|
||||
# register per unit, and `inv.last_beat` is a monotonic loop clock with no
|
||||
# wall-clock age published anywhere. Announcing them would create entities
|
||||
# nothing ever feeds, which is exactly what the `omit` note below refuses to
|
||||
# do for `p1_sample_age_s`. They are gaps, not omissions.
|
||||
UNIT_SENSORS = [
|
||||
("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),
|
||||
("battery", "battery_power", "Battery power", "W", "power", "measurement", None),
|
||||
("soc", "battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
||||
("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),
|
||||
]
|
||||
|
||||
# ⚠️ A ROSTER OF ONE ANNOUNCES NOTHING NEW - not a device, not an entity, not a
|
||||
# topic. Every install running today is a roster of one, and "the upgrade added
|
||||
# four entities nobody asked for" is a change to a live system's entity list
|
||||
# for no information: at N=1 the site rows ARE this unit's rows, exactly.
|
||||
PER_UNIT_MIN_ROSTER = 2
|
||||
|
||||
|
||||
def _slug(text) -> str:
|
||||
"""Home Assistant's own object_id spelling: lowercase, `_`, no runs.
|
||||
|
||||
⚠️ ASCII-ONLY ON PURPOSE. `str.isalnum()` is true for 'é' and for '٤', and
|
||||
HA transliterates those rather than keeping them, so treating them as
|
||||
alphanumeric here would produce an id this module claims and HA does not.
|
||||
They become separators, which is conservative in the right direction: two
|
||||
names that differ ONLY outside ASCII collide, and `unit_slugs()` below is
|
||||
what stops a collision becoming two entities with one `unique_id`.
|
||||
"""
|
||||
out = []
|
||||
for ch in str(text).strip().lower():
|
||||
out.append(ch if (ch.isascii() and ch.isalnum()) else "_")
|
||||
slug = "".join(out)
|
||||
while "__" in slug:
|
||||
slug = slug.replace("__", "_")
|
||||
return slug.strip("_")
|
||||
|
||||
|
||||
def _title(name) -> str:
|
||||
"""`inverter-b` -> `Inverter B`. The half of the entity_id HA composes."""
|
||||
words = str(name).replace("-", " ").replace("_", " ").split()
|
||||
return " ".join(w[:1].upper() + w[1:] for w in words) or str(name)
|
||||
|
||||
|
||||
def unit_slugs(units) -> list:
|
||||
"""One id-safe slug per roster entry, in roster order, ALL DISTINCT.
|
||||
|
||||
⚠️ THE DE-COLLISION IS NOT DECORATION. `validate.py` rule 9(c) refuses two
|
||||
entries with the SAME name; it does not refuse `inverter-b` beside
|
||||
`Inverter B`, and those two slug to one string. Two entities published with
|
||||
one `unique_id` is not an error in HA - the second one is DROPPED, so one
|
||||
inverter's telemetry silently stops existing. The suffix is the roster
|
||||
INDEX, so it is stable for a given roster and does not depend on what the
|
||||
other unit is called.
|
||||
"""
|
||||
slugs, seen = [], set()
|
||||
for i, name in enumerate(units):
|
||||
slug = _slug(name) or f"inverter_{i + 1}"
|
||||
while slug in seen:
|
||||
slug = f"{slug}_{i + 1}"
|
||||
seen.add(slug)
|
||||
slugs.append(slug)
|
||||
return slugs
|
||||
|
||||
|
||||
def announced_units(units) -> list:
|
||||
"""The roster, in order - or EMPTY below `PER_UNIT_MIN_ROSTER`.
|
||||
|
||||
⚠️ ONE ANSWER TO "WHICH UNITS GET PER-UNIT ENTITIES", read by the
|
||||
announcement here AND by `Controller.publish()` in main.py. Two spellings of
|
||||
the same predicate is how an entity gets announced that nothing publishes
|
||||
to, or published to on a topic nothing announced - and at N=1 the second
|
||||
would be an added topic, which AC 4 forbids outright.
|
||||
"""
|
||||
names = [str(u) for u in units]
|
||||
if len(names) < PER_UNIT_MIN_ROSTER:
|
||||
return []
|
||||
return names
|
||||
|
||||
|
||||
def unit_state_key(slug: str, key: str) -> str:
|
||||
"""The `publish()` key - i.e. the state topic under `BASE` - for one unit.
|
||||
|
||||
Written once, here, because main.py builds it and this module announces it;
|
||||
a second transcription is a topic announced and never fed.
|
||||
"""
|
||||
return f"unit/{slug}/{key}"
|
||||
|
||||
|
||||
def unit_device(name, slug: str) -> dict:
|
||||
"""B's OWN device. ⚠️ Never returned for the first roster entry."""
|
||||
return {
|
||||
"identifiers": [f"{DEVICE['identifiers'][0]}_{slug}"],
|
||||
"name": f"{DEVICE['name']} {_title(name)}",
|
||||
"manufacturer": DEVICE["manufacturer"],
|
||||
"model": DEVICE["model"],
|
||||
# Hangs B under the controller in HA's device tree rather than beside
|
||||
# it. Cosmetic, and the one line that says in the UI what the asymmetry
|
||||
# above means: there is one controller, and it drives both.
|
||||
"via_device": DEVICE["identifiers"][0],
|
||||
}
|
||||
|
||||
|
||||
def _payload(name, object_id, unique_id, state_topic, device,
|
||||
unit, dev_class, state_class, icon) -> dict:
|
||||
"""One discovery payload.
|
||||
|
||||
⚠️ KEY ORDER IS PART OF THE OUTPUT. `json.dumps` writes keys in insertion
|
||||
order, so this dict's construction order IS the bytes on the wire, and AC 1
|
||||
is a BYTE comparison against what `release/1.0` publishes. Do not reorder.
|
||||
"""
|
||||
cfg = {
|
||||
"name": name,
|
||||
"object_id": object_id,
|
||||
"unique_id": unique_id,
|
||||
"state_topic": state_topic,
|
||||
"availability_topic": AVAILABILITY,
|
||||
"device": device,
|
||||
}
|
||||
if unit:
|
||||
cfg["unit_of_measurement"] = unit
|
||||
if dev_class:
|
||||
cfg["device_class"] = dev_class
|
||||
if state_class:
|
||||
cfg["state_class"] = state_class
|
||||
if icon:
|
||||
cfg["icon"] = icon
|
||||
return cfg
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=()) -> list:
|
||||
"""Every `(topic, payload)` this add-on announces, in announcement order.
|
||||
|
||||
Split out of `_announce()` so that the acceptance criterion can be graded as
|
||||
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
|
||||
reading of the code. `_announce()` publishes exactly this list and nothing
|
||||
else, which is itself checked.
|
||||
"""
|
||||
omit = set(omit)
|
||||
out = []
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in omit:
|
||||
continue
|
||||
out.append((
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
names = announced_units(units)
|
||||
slugs = unit_slugs(names)
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` below can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
|
||||
unique_id = f"{BASE}_unit_{slug}_{key}"
|
||||
out.append((
|
||||
f"homeassistant/sensor/{unique_id}/config",
|
||||
_payload(prefix + label,
|
||||
f"{site_slug}_{slug}_{oid}",
|
||||
unique_id,
|
||||
f"{BASE}/{unit_state_key(slug, key)}",
|
||||
device, unit, dev_class, state_class, icon)))
|
||||
return out
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
def __init__(self, host, port, username=None, password=None, omit=()):
|
||||
def __init__(self, host, port, username=None, password=None, omit=(),
|
||||
units=()):
|
||||
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||
# sensor that nothing will ever publish to is not harmless here:
|
||||
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||
# never fed is a worse signal than one that does not exist at all.
|
||||
self.omit = set(omit)
|
||||
# TEL-07. The roster's NAMES, in roster order - `inverter.roster_names()`,
|
||||
# not a second reading of the option. Defaults to empty, which
|
||||
# `announced_units()` treats exactly as a roster of one: nothing
|
||||
# per-unit is announced, so every caller that has not been taught about
|
||||
# the roster keeps publishing what it published before.
|
||||
self.units = [str(u) for u in units]
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -130,29 +355,12 @@ class MqttPublisher:
|
||||
self.enabled = False
|
||||
|
||||
def _announce(self) -> None:
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in self.omit:
|
||||
continue
|
||||
cfg = {
|
||||
"name": name,
|
||||
"object_id": object_id,
|
||||
"unique_id": f"{BASE}_{key}",
|
||||
"state_topic": f"{BASE}/{key}",
|
||||
"availability_topic": AVAILABILITY,
|
||||
"device": DEVICE,
|
||||
}
|
||||
if unit:
|
||||
cfg["unit_of_measurement"] = unit
|
||||
if dev_class:
|
||||
cfg["device_class"] = dev_class
|
||||
if state_class:
|
||||
cfg["state_class"] = state_class
|
||||
if icon:
|
||||
cfg["icon"] = icon
|
||||
self.client.publish(
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
json.dumps(cfg), retain=True,
|
||||
)
|
||||
# ⚠️ NO PAYLOAD IS BUILT HERE. `discovery_payloads()` is the whole
|
||||
# announcement, so the check that diffs it against `release/1.0` is
|
||||
# diffing what actually goes on the wire - and a payload built here
|
||||
# instead would be one this add-on publishes and no check has ever seen.
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit):
|
||||
self.client.publish(topic, json.dumps(cfg), retain=True)
|
||||
self.client.publish(AVAILABILITY, "online", retain=True)
|
||||
|
||||
def publish(self, values: dict) -> None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.6.0"
|
||||
version: "0.7.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
|
||||
@@ -78,7 +78,8 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from app.allocator import Allocation, UnitPlan, plan
|
||||
@@ -239,11 +240,112 @@ print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
|
||||
check(f"a loop pinned for a whole billing quarter speaks at least "
|
||||
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
|
||||
alarms >= MIN_PER_QUARTER)
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded
|
||||
# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the
|
||||
# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one
|
||||
# alarm more than a real quarter earns. Measured on this very check:
|
||||
# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst
|
||||
# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and
|
||||
# REASSERT_S = 225 -> 4 alarms, red 181 gives 4)
|
||||
# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and
|
||||
# anyone raising the constant to 200 s would get a green suite and conclude the
|
||||
# floor was met when a real quarter would earn 4 alarms. Grade the cadence
|
||||
# DIRECTLY, so the check enforces the ceiling its own comment claims for it.
|
||||
check(f"...and the CADENCE itself honours the floor, independent of where the "
|
||||
f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = "
|
||||
f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}",
|
||||
QUARTER_S // REASSERT_S >= MIN_PER_QUARTER)
|
||||
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
|
||||
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
|
||||
check("...while every single cycle is still counted",
|
||||
pinned.engagements == int(QUARTER_S))
|
||||
|
||||
# --- THE SCATTERED REGIME, WHICH IS THE ONE THE OWNER ACTUALLY HIT -----------
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it falsified the first version of this card.
|
||||
# The rising edge used to fire UNCONDITIONALLY, so REASSERT_S governed only the
|
||||
# still-engaged path. A condition that engages and clears repeatedly therefore
|
||||
# raised one notification PER EPISODE at any value of the constant.
|
||||
#
|
||||
# That is what the live install produced: 48 engagements in ~25 minutes,
|
||||
# scattered about one cycle in six. At the measured ~5 s cycle the gaps are ~25 s
|
||||
# of clean cycles, past CLEAR_CYCLES x 5 s = 15 s, so the alarm de-asserted
|
||||
# between almost every engagement. Raising REASSERT_S 30 -> 150 removed NONE of
|
||||
# them. The interval now bounds the notification RATE across episodes.
|
||||
#
|
||||
# The stimulus is written in wall-clock seconds and in the shape the field
|
||||
# actually produced, so it does not move when the constant does.
|
||||
SCATTER_CYCLE_S = 5.0
|
||||
SCATTER_ONE_IN = 6
|
||||
SCATTER_CYCLES = 305
|
||||
|
||||
scat = ClampWatchdog()
|
||||
scat_alarms = sum(1 for i in range(SCATTER_CYCLES)
|
||||
if scat.note(i % SCATTER_ONE_IN == 0, "scattered",
|
||||
T0 + timedelta(seconds=SCATTER_CYCLE_S * i)))
|
||||
_scat_span = SCATTER_CYCLE_S * SCATTER_CYCLES
|
||||
print(f" scattered 1-in-{SCATTER_ONE_IN} over {_scat_span:.0f} s at "
|
||||
f"{SCATTER_CYCLE_S:.0f} s cycles: {scat.episodes} episodes, "
|
||||
f"{scat.engagements} engagements, {scat_alarms} alarm(s)")
|
||||
check(f"a condition that de-asserts between engagements is rate-limited ACROSS "
|
||||
f"episodes, not once per episode ({scat_alarms} alarms for "
|
||||
f"{scat.episodes} episodes)",
|
||||
scat_alarms < scat.episodes)
|
||||
check(f"...and the rate honours the same interval the pinned case does: at most "
|
||||
f"ceil({_scat_span:.0f}/{REASSERT_S:.0f}) + 1 = "
|
||||
f"{int(_scat_span // REASSERT_S) + 1}",
|
||||
scat_alarms <= int(_scat_span // REASSERT_S) + 1)
|
||||
check("...while EVERY episode and EVERY engagement is still counted - the "
|
||||
"budget is on notifications, never on evidence",
|
||||
scat.episodes == scat.engagements == SCATTER_CYCLES // SCATTER_ONE_IN + 1)
|
||||
# ⚠️ THE COUNTERFACTUAL, AND THE FIRST VERSION OF IT COULD NOT FAIL FOR THE
|
||||
# DEFECT IT NAMED. It read `ClampWatchdog().note(True, ...) is True` - a FRESH
|
||||
# object, so it only ever exercised the `last_alarm is None` branch, which is the
|
||||
# trivially-true one. A fresh watchdog has no earlier episode to be held back BY.
|
||||
# That is the third instance in this file of the family recorded at line 216 and
|
||||
# at the 224 s sweep, and it was guarding the most safety-critical property in
|
||||
# the change. Review found it.
|
||||
#
|
||||
# The stimulus must therefore SPEND THE BUDGET FIRST: a benign one-cycle clamp,
|
||||
# a gap long enough to de-assert, then a continuous runaway. Without the
|
||||
# escalation clause the runaway's first notification is held for up to
|
||||
# REASSERT_S - measured at 140 s before it was added.
|
||||
_esc = ClampWatchdog()
|
||||
_esc.note(True, "benign", T0) # spends the budget
|
||||
for _i in range(1, 1 + CLEAR_CYCLES + 1): # de-assert
|
||||
_esc.note(False, "", T0 + timedelta(seconds=5.0 * _i))
|
||||
_RUNAWAY_AT = 5.0 * (CLEAR_CYCLES + 2)
|
||||
_esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
|
||||
for _j in range(200)
|
||||
if _esc.note(True, "runaway",
|
||||
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
|
||||
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
|
||||
print(f" budget spent by a benign episode, then a continuous runaway from "
|
||||
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
|
||||
f"(delay {_esc_delay:.0f}s)")
|
||||
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
|
||||
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
|
||||
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
|
||||
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
|
||||
check("...and it speaks at the escalation boundary rather than immediately, "
|
||||
"which is what keeps the scattered flood suppressed",
|
||||
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
|
||||
|
||||
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
|
||||
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
|
||||
# `last_alarm = now`, the chatter case collapses from 50 notifications to 1 - the
|
||||
# alarm never fires again. That is the "alarm that never fires" class, which is a
|
||||
# different bug from one that fires later, and the suite was green on it.
|
||||
_starve = ClampWatchdog()
|
||||
_starve_fires = sum(1 for _i in range(1600)
|
||||
if _starve.note(_i % 4 == 0, "chatter",
|
||||
T0 + timedelta(seconds=5.0 * _i)))
|
||||
print(f" chatter 1-engaged/{CLEAR_CYCLES}-clean over 8000 s: "
|
||||
f"{_starve.episodes} episodes, {_starve_fires} notification(s)")
|
||||
check(f"a de-assert does not RESET the notification budget, so repeated chatter "
|
||||
f"cannot starve the alarm to silence ({_starve_fires} notifications over "
|
||||
f"8000 s, not 1)",
|
||||
_starve_fires > 10)
|
||||
|
||||
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
|
||||
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
|
||||
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
"""Runnable check for TEL-07 - the SECOND MQTT device, and A's history.
|
||||
`python3 test_mqtt.py`
|
||||
|
||||
WHAT THIS FILE IS FOR, in one sentence: to prove by DIFFING THE ANNOUNCED
|
||||
PAYLOADS - not by reading the code - that giving inverter B its own MQTT device
|
||||
changes nothing about inverter A's.
|
||||
|
||||
⚠️ WHY THAT IS THE WHOLE CARD. `app/mqtt.py`'s own note records the finding:
|
||||
Home Assistant composes an entity_id from the DEVICE name plus the ENTITY name,
|
||||
and `object_id` in the discovery payload did NOT override it (tested on HA
|
||||
2026.8). So re-scoping the device renames every entity - and because HA keeps
|
||||
the id it composed at FIRST discovery, what a rename actually produces is a NEW
|
||||
entity beside a DEAD one. The recorded history stays behind on the old id, and
|
||||
every dashboard, template and automation that names it - including the three
|
||||
live watchdog automations `goodwe_master_watchdog_held_*` and
|
||||
`goodwe_master_parked_at_0_w_*` - goes on pointing at an entity that has quietly
|
||||
stopped updating. Nothing errors. That is the failure this file exists to make
|
||||
impossible to ship.
|
||||
|
||||
⚠️ AND THE TEMPTING DESIGN IS THE DESTRUCTIVE ONE. "One device per inverter" -
|
||||
A becomes `inverter-a`, B becomes `inverter-b` - is symmetrical and tidy and is
|
||||
exactly the change described above. A KEEPS THE DEVICE IT ALREADY HAS; only B
|
||||
gets a new one. Part 8 below writes that tidy version and shows it going red.
|
||||
|
||||
THE SHAPE OF THE EVIDENCE. Part 1 records what `release/1.0` actually publishes -
|
||||
generated by running THAT commit's module, not transcribed - and every later
|
||||
part is a diff against it, reported as `N topics, M missing, C changed, A added`.
|
||||
|
||||
Part 1 The recorded baseline, and the diff at a roster of one and of none.
|
||||
Part 2 `_announce()` publishes EXACTLY `discovery_payloads()` and nothing
|
||||
else, so parts 1 and 3-8 are diffing what goes on the wire.
|
||||
Part 3 A roster of two: A's nine messages byte-identical, eight added.
|
||||
Part 4 Zero overlap between A's entities and B's - topics, unique_ids,
|
||||
object_ids, state topics, and devices.
|
||||
Part 5 Site-scoped sensors stay site-scoped, at every roster length 1-6.
|
||||
Part 6 The composed entity_ids, modelled INDEPENDENTLY of `app/mqtt.py`, and
|
||||
anchored on the one composed id that was verified against the live
|
||||
system (`sensor.goodwe_p1_sample_age`, mqtt.py's own note).
|
||||
Part 7 ⚠️ THE ANNOUNCEMENT AND THE PUBLICATION AGREE, on a real `Controller`,
|
||||
with the two units carrying DIFFERENT numbers - so a swap is visible.
|
||||
Part 8 ⚠️ VERIFIED BY BREAKING IT. Twelve mutations of the shipped module,
|
||||
re-executed from mutated source, each one named with the property it
|
||||
breaks. A property nothing breaks is a property nothing is watching.
|
||||
Part 9 What these checks CANNOT see, enumerated and sized.
|
||||
|
||||
⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC,
|
||||
DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose
|
||||
two units are equal cannot observe a bug that swaps them - MUTATION.md habit 4,
|
||||
and the defect this repository has now shipped six cards in a row.
|
||||
|
||||
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every number attributed
|
||||
to it below is SIMULATED, from the datasheet and serial recorded in NOTES.md
|
||||
§1.1b (GW5048D-ES, serial 55048ESU22AW0992, max_w 4600 W). Nothing here is
|
||||
measured, and nothing here should ever be quoted as if it were.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md.
|
||||
Part 8 sidesteps the whole problem by exec'ing mutated SOURCE into a fresh
|
||||
namespace, so no `.pyc` is involved at any point.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app import mqtt
|
||||
from app.inverter import build_roster, roster_names
|
||||
from app.main import Controller
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
MQTT_SRC = (HERE / "app" / "mqtt.py").read_text(encoding="utf-8")
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The recorded baseline. ⚠️ GENERATED, NOT TRANSCRIBED. Reproduce it with:
|
||||
#
|
||||
# git show release/1.0:goodwe_controller/app/mqtt.py > /tmp/mqtt_base.py
|
||||
# python3 - <<'EOF'
|
||||
# import sys; sys.path.insert(0, "/tmp"); import mqtt_base as M
|
||||
# class F:
|
||||
# def __init__(self): self.m = []
|
||||
# def publish(self, t, p=None, retain=False): self.m.append((t, p, retain))
|
||||
# p = M.MqttPublisher.__new__(M.MqttPublisher)
|
||||
# p.omit, p.client, p.enabled = set(), F(), True
|
||||
# p._announce()
|
||||
# for row in p.client.m: print(row)
|
||||
# EOF
|
||||
#
|
||||
# A transcription would be a check that grades this branch against MY MEMORY of
|
||||
# release/1.0 rather than against release/1.0.
|
||||
# ---------------------------------------------------------------------------
|
||||
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
|
||||
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
|
||||
'"model": "ES/BP series"}')
|
||||
|
||||
BASELINE = [
|
||||
('homeassistant/sensor/goodwe_ctl_setpoint/config',
|
||||
'{"name": "Setpoint", "object_id": "goodwe_setpoint", "unique_id": "goodwe_ctl_setpoint", '
|
||||
'"state_topic": "goodwe_ctl/setpoint", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_grid/config',
|
||||
'{"name": "Grid power", "object_id": "goodwe_grid_power", "unique_id": "goodwe_ctl_grid", '
|
||||
'"state_topic": "goodwe_ctl/grid", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_battery/config',
|
||||
'{"name": "Battery power", "object_id": "goodwe_battery_power", "unique_id": "goodwe_ctl_battery", '
|
||||
'"state_topic": "goodwe_ctl/battery", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_soc/config',
|
||||
'{"name": "Battery SoC", "object_id": "goodwe_battery_soc", "unique_id": "goodwe_ctl_soc", '
|
||||
'"state_topic": "goodwe_ctl/soc", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "%", "device_class": "battery", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_circulation/config',
|
||||
'{"name": "Circulating power", "object_id": "goodwe_circulating_power", '
|
||||
'"unique_id": "goodwe_ctl_circulation", "state_topic": "goodwe_ctl/circulation", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", '
|
||||
'"icon": "mdi:sync-alert"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_phase/config',
|
||||
'{"name": "Maintenance phase", "object_id": "goodwe_maintenance_phase", '
|
||||
'"unique_id": "goodwe_ctl_phase", "state_topic": "goodwe_ctl/phase", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:battery-sync"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_status/config',
|
||||
'{"name": "Controller status", "object_id": "goodwe_controller_status", '
|
||||
'"unique_id": "goodwe_ctl_status", "state_topic": "goodwe_ctl/status", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:heart-pulse"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_p1_age/config',
|
||||
'{"name": "P1 sample age", "object_id": "p1_sample_age_s", "unique_id": "goodwe_ctl_p1_age", '
|
||||
'"state_topic": "goodwe_ctl/p1_age", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "s", "device_class": "duration", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('goodwe_ctl/availability', 'online', True),
|
||||
]
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Captures what `_announce()` actually hands paho."""
|
||||
|
||||
def __init__(self):
|
||||
self.msgs = []
|
||||
|
||||
def publish(self, topic, payload=None, retain=False):
|
||||
self.msgs.append((topic, payload, retain))
|
||||
|
||||
|
||||
def announce(units=(), omit=(), module=mqtt):
|
||||
"""The messages `_announce()` publishes, through the SHIPPED method.
|
||||
|
||||
⚠️ Not `discovery_payloads()` directly. Part 2 proves the two agree; every
|
||||
other part goes through this one, so what is diffed is what paho is told.
|
||||
"""
|
||||
pub = module.MqttPublisher.__new__(module.MqttPublisher)
|
||||
pub.omit = set(omit)
|
||||
pub.units = [str(u) for u in units]
|
||||
pub.enabled = True
|
||||
pub.client = FakeClient()
|
||||
pub._announce()
|
||||
return pub.client.msgs
|
||||
|
||||
|
||||
def diff(got, want=BASELINE):
|
||||
"""`(missing, changed, added)` between two announcement lists, by topic."""
|
||||
g, w = dict((t, (p, r)) for t, p, r in got), dict((t, (p, r)) for t, p, r in want)
|
||||
missing = [t for t in w if t not in g]
|
||||
changed = [t for t in w if t in g and g[t] != w[t]]
|
||||
added = [t for t in g if t not in w]
|
||||
return sorted(missing), sorted(changed), sorted(added)
|
||||
|
||||
|
||||
def report(label, got, want=BASELINE):
|
||||
m, c, a = diff(got, want)
|
||||
print(f" {label}: {len(want)} topics, {len(m)} missing, {len(c)} changed, "
|
||||
f"{len(a)} added")
|
||||
for t in m:
|
||||
print(f" MISSING {t}")
|
||||
for t in c:
|
||||
print(f" CHANGED {t}")
|
||||
for t in a:
|
||||
print(f" ADDED {t}")
|
||||
return m, c, a
|
||||
|
||||
|
||||
def cfgs(msgs):
|
||||
"""The discovery payloads, parsed, without the availability message."""
|
||||
return [json.loads(p) for t, p, _ in msgs if t.startswith("homeassistant/")]
|
||||
|
||||
|
||||
# The roster this card exists for. ⚠️ SIMULATED: inverter B does not physically
|
||||
# exist. Its rating is the datasheet's (NOTES.md §1.1b, GW5048D-ES, serial
|
||||
# 55048ESU22AW0992) and has never been measured.
|
||||
OPTS2 = {
|
||||
"meter_source": "off",
|
||||
"inverters": [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": 2000, "bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": 4600, "bms_soc_floor": 10},
|
||||
],
|
||||
}
|
||||
NAMES2 = roster_names(OPTS2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n1. the recorded baseline, and a roster of one")
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC 1 and AC 4. ⚠️ THE COMPARISON IS BYTE-FOR-BYTE ON THE PAYLOAD STRING, not
|
||||
# on a parsed dict. `json.dumps` writes keys in insertion order, so a reordered
|
||||
# payload is a different retained message on the broker even though the parsed
|
||||
# dicts compare equal - and a reordered payload is a re-announcement of every
|
||||
# entity on a live install.
|
||||
m, c, a = report("roster of one (`inverters:` empty, i.e. every install today)",
|
||||
announce(units=roster_names({})))
|
||||
check("⭐ AT THE SHIPPED ROSTER OF ONE THE ANNOUNCEMENT IS BYTE-IDENTICAL TO "
|
||||
"release/1.0: same topics, same unique_ids, same payload bytes, same "
|
||||
"retain flags, in the same order - no second device, no renamed entity, "
|
||||
"no added topic",
|
||||
(m, c, a) == ([], [], []) and announce(units=roster_names({})) == BASELINE)
|
||||
|
||||
check("...and the roster-of-one path is the one every install actually reads: "
|
||||
"an empty `inverters:` names exactly one unit",
|
||||
roster_names({}) == ["inverter-a"])
|
||||
|
||||
check("...and a publisher told NOTHING about the roster announces the same "
|
||||
"thing, so a caller that has not been taught about units cannot change "
|
||||
"what a live install sees",
|
||||
announce(units=()) == BASELINE)
|
||||
|
||||
m, c, a = report("roster of one, P1 off (`omit=('p1_age',)`)",
|
||||
announce(units=roster_names({}), omit=("p1_age",)),
|
||||
[row for row in BASELINE if "p1_age" not in row[0]])
|
||||
check("...and the `omit` path is unchanged too: with P1 ingestion off the "
|
||||
"watchdog input is still not announced, and nothing else moved",
|
||||
(m, c, a) == ([], [], []))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n2. what is diffed is what goes on the wire")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ WITHOUT THIS PART, PARTS 1 AND 3-8 GRADE A FUNCTION THE ADD-ON MIGHT NOT
|
||||
# USE. `_announce()` used to build its payloads inline; if it still did, every
|
||||
# diff here would be a diff of a second implementation that happens to sit in
|
||||
# the same file. This is the join.
|
||||
_direct = [(t, json.dumps(cfg), True)
|
||||
for t, cfg in mqtt.discovery_payloads(NAMES2)] + \
|
||||
[(mqtt.AVAILABILITY, "online", True)]
|
||||
check("`_announce()` publishes EXACTLY `discovery_payloads()`, in order, with "
|
||||
"retain set, plus the availability message - so every diff in this file "
|
||||
"is a diff of the bytes paho is handed",
|
||||
announce(units=NAMES2) == _direct)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n3. a roster of two: A untouched, B added")
|
||||
# ---------------------------------------------------------------------------
|
||||
TWO = announce(units=NAMES2)
|
||||
m, c, a = report("roster of two (inverter-a + inverter-b, SIMULATED)", TWO)
|
||||
check("⭐ AC 1: AT A ROSTER OF TWO, EVERY ONE OF A's NINE MESSAGES IS STILL "
|
||||
"BYTE-IDENTICAL - nothing missing, nothing changed. This is the criterion "
|
||||
"the live install's recorded history depends on, and it is a diff of the "
|
||||
"payloads, not a reading of the code",
|
||||
(m, c) == ([], []))
|
||||
check("...and the eight additions are all B's and A's own per-unit rows, and "
|
||||
"all of them are NEW topics", len(a) == 8)
|
||||
check("...and A's nine messages are still the FIRST nine, in the same order, "
|
||||
"so a broker replaying retained messages sees them unchanged",
|
||||
TWO[:len(BASELINE) - 1] == BASELINE[:-1])
|
||||
|
||||
site_ids = {c_["unique_id"] for c_ in cfgs(BASELINE)}
|
||||
unit_cfgs = [c_ for c_ in cfgs(TWO) if c_["unique_id"] not in site_ids]
|
||||
a_cfgs = [c_ for c_ in unit_cfgs if "inverter_a" in c_["unique_id"]]
|
||||
b_cfgs = [c_ for c_ in unit_cfgs if "inverter_b" in c_["unique_id"]]
|
||||
check("A's per-unit rows stay on THE DEVICE A ALREADY HAS - no new device is "
|
||||
"created for the first roster entry, which is the whole asymmetry",
|
||||
len(a_cfgs) == 4
|
||||
and all(c_["device"] == mqtt.DEVICE for c_ in a_cfgs))
|
||||
check("⭐ AC 2: B's rows are on B's OWN device, with its own identifiers",
|
||||
len(b_cfgs) == 4
|
||||
and all(c_["device"]["identifiers"] == ["goodwe_rs485_controller_inverter_b"]
|
||||
for c_ in b_cfgs))
|
||||
check("...and B's device hangs off the controller's via `via_device`, so HA "
|
||||
"shows one controller driving two inverters rather than two strangers",
|
||||
all(c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
|
||||
for c_ in b_cfgs))
|
||||
check("...and B's device carries the SAME manufacturer and model as the site "
|
||||
"device - it is the same installation, not a second product",
|
||||
all(c_["device"]["manufacturer"] == mqtt.DEVICE["manufacturer"]
|
||||
and c_["device"]["model"] == mqtt.DEVICE["model"] for c_ in b_cfgs))
|
||||
|
||||
# A regression pin on the NEW payloads. ⚠️ THIS IS NOT EVIDENCE OF CORRECTNESS -
|
||||
# it is generated from this branch, so it can only say "these bytes have not
|
||||
# changed since the PR was reviewed". It exists because B's payloads have no
|
||||
# released version to be diffed against, and a later edit that quietly changes
|
||||
# a per-unit `device_class` or state topic should have to be deliberate.
|
||||
_pin = hashlib.sha256(
|
||||
json.dumps(unit_cfgs, sort_keys=False).encode()).hexdigest()[:16]
|
||||
print(" the eight per-unit payloads, in announcement order:")
|
||||
for c_ in unit_cfgs:
|
||||
print(f" {c_['unique_id']:<40} {c_['object_id']:<34} "
|
||||
f"{c_['state_topic']}")
|
||||
print(f" regression pin (sha256 of the eight payloads): {_pin}")
|
||||
check("the per-unit payloads are the ones this PR was reviewed with "
|
||||
"(REGRESSION PIN, not a correctness claim - see the comment)",
|
||||
_pin == "eb5022b0455158d7")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n4. zero overlap between A's entities and B's")
|
||||
# ---------------------------------------------------------------------------
|
||||
def field(rows, key):
|
||||
return [r[key] for r in rows]
|
||||
|
||||
|
||||
for key in ("unique_id", "object_id", "state_topic"):
|
||||
av, bv = set(field(a_cfgs, key)), set(field(b_cfgs, key))
|
||||
sv = set(field(cfgs(BASELINE), key))
|
||||
check(f"⭐ AC 2: no `{key}` is shared between A's entities and B's, and "
|
||||
f"none of B's collides with a SITE entity that already exists",
|
||||
len(av) == 4 and len(bv) == 4 and not (av & bv) and not (bv & sv)
|
||||
and not (av & sv))
|
||||
|
||||
all_ids = field(cfgs(TWO), "unique_id")
|
||||
check("...and across the WHOLE announcement every unique_id is distinct - an "
|
||||
"HA discovery collision is not an error, it DROPS the second entity, so "
|
||||
"one inverter's telemetry would silently stop existing",
|
||||
len(all_ids) == len(set(all_ids)) == 16)
|
||||
|
||||
all_topics = [t for t, _, _ in TWO]
|
||||
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 17)
|
||||
|
||||
# ⚠️ THE SLUG COLLISION rule 9(c) DOES NOT REFUSE. `inverter-b` and `Inverter B`
|
||||
# are two different names by the validator's test and one string once slugged.
|
||||
COLLIDE = announce(units=["inverter-b", "Inverter B"])
|
||||
col_ids = field(cfgs(COLLIDE), "unique_id")
|
||||
print(" two names that slug alike ('inverter-b', 'Inverter B') ->",
|
||||
[c_["unique_id"] for c_ in cfgs(COLLIDE) if c_["unique_id"] not in site_ids])
|
||||
check("two roster names that differ only in case and punctuation - which "
|
||||
"`validate.py` rule 9(c) accepts, because it refuses only EXACT "
|
||||
"duplicates - still get distinct unique_ids",
|
||||
len(col_ids) == len(set(col_ids)))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n5. site-scoped sensors stay site-scoped")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ `p1_sample_age_s` IS A WATCHDOG INPUT (mqtt.py's `omit` note and the ESP32
|
||||
# firmware's ingest guard). A second copy of it is a second answer to "is the
|
||||
# meter fresh?", one of which is always the one nobody is looking at. `grid` is
|
||||
# the same argument without the safety edge: there is ONE grid connection.
|
||||
SITE_ONLY = ("goodwe_ctl_grid", "goodwe_ctl_p1_age", "goodwe_ctl_circulation",
|
||||
"goodwe_ctl_phase", "goodwe_ctl_status")
|
||||
rows = []
|
||||
for n in range(1, 7):
|
||||
names = [f"inverter-{chr(ord('a') + i)}" for i in range(n)]
|
||||
ids = field(cfgs(announce(units=names)), "unique_id")
|
||||
rows.append((n, [ids.count(s) for s in SITE_ONLY], len(ids)))
|
||||
print(f" roster of {n}: {len(ids)} entities, "
|
||||
+ ", ".join(f"{s}x{ids.count(s)}" for s in SITE_ONLY))
|
||||
check("⭐ AC 3: `grid` and `p1_sample_age_s` are announced EXACTLY ONCE at every "
|
||||
"roster length from 1 to 6 - announcing them per inverter would invent "
|
||||
"two of a thing there is one of, and would duplicate a safety signal",
|
||||
all(counts == [1] * len(SITE_ONLY) for _n, counts, _t in rows))
|
||||
check("...and the site set does not grow with the roster while the per-unit "
|
||||
"set does: 8 site entities always, 4 more per unit past the first",
|
||||
[t for _n, _c, t in rows] == [8, 16, 20, 24, 28, 32])
|
||||
|
||||
site_devices = {json.dumps(c_["device"], sort_keys=True)
|
||||
for c_ in cfgs(announce(units=NAMES2))
|
||||
if c_["unique_id"] in site_ids}
|
||||
check("...and every site entity is still on the SITE device, at a roster of two",
|
||||
site_devices == {json.dumps(mqtt.DEVICE, sort_keys=True)})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n6. the composed entity_ids")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THIS SLUGIFIER IS DELIBERATELY NOT `mqtt._slug`. Importing the module's own
|
||||
# spelling would make the model and the subject move together, so a mutation in
|
||||
# the slugifier would change both sides equally and be invisible - "a check
|
||||
# whose data cannot reach the failure it names", written in one import.
|
||||
def compose(device_name, entity_name):
|
||||
"""HA's entity_id for a discovery payload, per mqtt.py's own note."""
|
||||
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
|
||||
return "sensor." + s.strip("_")
|
||||
|
||||
|
||||
# The ONE anchor there is: mqtt.py records that the live entity is
|
||||
# `sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26,
|
||||
# and that the firmware subscribes to that composed name. If this model does not
|
||||
# reproduce that id, the model is wrong and every claim below it is worthless.
|
||||
p1 = [c_ for c_ in cfgs(BASELINE) if c_["unique_id"] == "goodwe_ctl_p1_age"][0]
|
||||
check("the model reproduces the ONE composed entity_id that was verified "
|
||||
"against the live system - `sensor.goodwe_p1_sample_age`, which the ESP32 "
|
||||
"ingest guard subscribes to - from the device name and the entity name",
|
||||
compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age")
|
||||
check("...and that id is NOT the `object_id` in the payload "
|
||||
"(`p1_sample_age_s`), which is the whole reason the device name is "
|
||||
"load-bearing and mqtt.py says so in capitals",
|
||||
p1["object_id"] != "goodwe_p1_sample_age")
|
||||
|
||||
composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(TWO)]
|
||||
print(" every composed entity_id at a roster of two:")
|
||||
for c_, eid in zip(cfgs(TWO), composed):
|
||||
print(f" {eid:<46} <- device {c_['device']['name']!r} + "
|
||||
f"name {c_['name']!r}")
|
||||
check("⭐ every composed entity_id at a roster of two is DISTINCT: no per-unit "
|
||||
"entity can land on top of a site entity that already has history",
|
||||
len(composed) == len(set(composed)) == 16)
|
||||
|
||||
base_composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(BASELINE)]
|
||||
check("...and the eight ids that exist today are unchanged by adding B, which "
|
||||
"is AC 1 restated in the units HA actually uses",
|
||||
composed[:8] == base_composed)
|
||||
|
||||
check("...and the per-unit ids come out SYMMETRIC even though the devices are "
|
||||
"not - sensor.goodwe_inverter_a_battery_power beside "
|
||||
"sensor.goodwe_inverter_b_battery_power - which is the entire benefit of "
|
||||
"the 'one device per inverter' design, without its cost",
|
||||
"sensor.goodwe_inverter_a_battery_power" in composed
|
||||
and "sensor.goodwe_inverter_b_battery_power" in composed)
|
||||
|
||||
check("...and for every per-unit entity `object_id` equals the composed id, so "
|
||||
"the two mechanisms agree and an HA that DID honour object_id would "
|
||||
"produce the same ids as one that does not",
|
||||
all("sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
|
||||
for c_ in unit_cfgs))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n7. the announcement and the publication agree")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THE HAZARD mqtt.py's `omit` NOTE NAMES: an entity that exists and is never
|
||||
# fed is a worse signal than one that does not exist. So the set of per-unit
|
||||
# topics ANNOUNCED and the set `Controller.publish()` actually writes have to be
|
||||
# the same set - and, because a matching set of KEYS is not a matching set of
|
||||
# VALUES, the two units carry DIFFERENT numbers here so that a swap is red.
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
def __init__(self):
|
||||
self.last = {}
|
||||
self.units = []
|
||||
|
||||
def publish(self, values):
|
||||
self.last = dict(values)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def controlled(opts, soc, batt, tripped):
|
||||
"""A real Controller with per-unit readings planted. SIMULATED values."""
|
||||
# ⚠️ `stop` is a REQUIRED keyword (CFG-01): a Controller that cannot stop
|
||||
# itself used to be the default one. These cases only call publish(), so any
|
||||
# Event will do - but they may not skip it.
|
||||
pub = _Pub()
|
||||
ctl = Controller(dict(opts), None, _Store(), pub, stop=asyncio.Event())
|
||||
for inv, s, b, t in zip(ctl.inverters, soc, batt, tripped):
|
||||
inv.soc, inv.batt, inv.target = s, b, -1.0 * b
|
||||
inv.sign_wd.tripped = t
|
||||
ctl.publish()
|
||||
return ctl, pub.last
|
||||
|
||||
|
||||
# A 41 / 88 %, A discharging 1200 W, B charging 300 W, and B latched, not A.
|
||||
# Every one of those six numbers differs from its sibling, which is what makes
|
||||
# the assertions below able to see a swap.
|
||||
ctl2, vals2 = controlled(OPTS2, [41.0, 88.0], [1200.0, -300.0], [False, True])
|
||||
announced_unit_topics = {c_["state_topic"] for c_ in unit_cfgs}
|
||||
published_unit_topics = {f"{mqtt.BASE}/{k}" for k in vals2 if k.startswith("unit/")}
|
||||
print(f" announced per-unit topics {len(announced_unit_topics)}, "
|
||||
f"published {len(published_unit_topics)}, "
|
||||
f"announced-but-never-fed {sorted(announced_unit_topics - published_unit_topics)}, "
|
||||
f"fed-but-never-announced {sorted(published_unit_topics - announced_unit_topics)}")
|
||||
check("⭐ every per-unit entity announced is one the controller actually "
|
||||
"publishes to, and every per-unit topic published is one that was "
|
||||
"announced - no entity that nothing feeds, no topic nothing declared",
|
||||
announced_unit_topics == published_unit_topics)
|
||||
|
||||
check("⭐ AND THE VALUES LAND ON THE RIGHT UNIT: A's 41 % on A's topic and B's "
|
||||
"88 % on B's, not the other way round",
|
||||
vals2["unit/inverter_a/soc"] == 41.0
|
||||
and vals2["unit/inverter_b/soc"] == 88.0)
|
||||
check("...and the same for battery power, whose SIGNS differ between the two "
|
||||
"units here (A discharging, B charging) so a swap cannot hide in a "
|
||||
"magnitude",
|
||||
vals2["unit/inverter_a/battery"] == 1200.0
|
||||
and vals2["unit/inverter_b/battery"] == -300.0)
|
||||
check("...and the SAFETY-02 sign latch is per unit: B is latched and A is not, "
|
||||
"and the two rows say so separately",
|
||||
vals2["unit/inverter_a/sign_latch"] == "clear"
|
||||
and vals2["unit/inverter_b/sign_latch"] == "tripped")
|
||||
check("...and each unit's setpoint row is its OWN share, not the site target",
|
||||
vals2["unit/inverter_a/setpoint"] == -1200.0
|
||||
and vals2["unit/inverter_b/setpoint"] == 300.0)
|
||||
|
||||
check("...and the SITE rows still mean what they meant: `soc` is the EMPTIEST "
|
||||
"pack (41 %, not 88 and not the mean) and `battery` is the SUM (900 W), "
|
||||
"so this card added rows beside them and did not re-point them",
|
||||
vals2["soc"] == 41.0 and round(vals2["battery"]) == 900)
|
||||
|
||||
_, vals1 = controlled({"meter_source": "off", "setpoint_entity": "number.sp"},
|
||||
[55.0], [700.0], [False])
|
||||
print(f" roster of one publishes keys: {sorted(vals1)}")
|
||||
check("⭐ AC 4 on the PUBLISH side: at a roster of one not one `unit/...` topic "
|
||||
"is written either, so the live install's broker sees no new topic at all",
|
||||
not [k for k in vals1 if k.startswith("unit/")])
|
||||
|
||||
# --- 7b. the wiring, which nothing above reaches -----------------------------
|
||||
# ⚠️ FOUND BY MUTATION, NOT BY DESIGN. `units=()` in main()'s `MqttPublisher(...)`
|
||||
# call survived every check in this file: parts 1-7 build the publisher
|
||||
# themselves, so a controller publishing eight per-unit topics beside a
|
||||
# publisher announcing none of them was GREEN. That is this repository's
|
||||
# recurring defect - a check whose data cannot reach the failure it names - in
|
||||
# the one line that joins the two halves together.
|
||||
check("`roster_names()` and `build_roster()` give the same names in the same "
|
||||
"order, on every shape - the flat keys, a declared roster, a nameless "
|
||||
"entry and a non-dict entry - so the names the publisher is handed are "
|
||||
"the names the units answer to",
|
||||
all([inv.name for inv in build_roster(o, None)] == roster_names(o)
|
||||
for o in ({}, {"setpoint_entity": "number.sp"}, OPTS2,
|
||||
{"inverters": [{}, {}]},
|
||||
{"inverters": [{"name": "roof west"}, "junk"]})))
|
||||
check("...and a nameless entry gets a POSITIONAL name, not `inverter-a` twice: "
|
||||
"two units under one name share one Store namespace, so one unit's "
|
||||
"SAFETY-02 latch would be the other's",
|
||||
roster_names({"inverters": [{}, {}]}) == ["inverter-1", "inverter-2"])
|
||||
|
||||
_call = [n for n in ast.walk(ast.parse((HERE / "app" / "main.py").read_text()))
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == "MqttPublisher"]
|
||||
_kw = [k for c_ in _call for k in c_.keywords if k.arg == "units"]
|
||||
print(f" main.py builds MqttPublisher {len(_call)} time(s); "
|
||||
f"units= is " + (ast.unparse(_kw[0].value) if _kw else "ABSENT"))
|
||||
check("⚠️ A SOURCE READ, AND THE WEAKEST CHECK IN THIS FILE - said so rather "
|
||||
"than dressed up. main()'s one `MqttPublisher(...)` hands it "
|
||||
"`roster_names(opts)`; nothing else here can reach that line, because "
|
||||
"main() opens a broker, a web server and a control loop",
|
||||
len(_call) == 1 and len(_kw) == 1
|
||||
and ast.unparse(_kw[0].value) == "roster_names(opts)")
|
||||
|
||||
# --- 7c. a name that is not ASCII -------------------------------------------
|
||||
# HA transliterates before it slugs; this module cannot, so it treats anything
|
||||
# outside ASCII as a separator. Either way the id must be an id.
|
||||
_odd = announce(units=["caf\u00e9-a", "caf\u00e9-b"])
|
||||
_odd_unit = [c_ for c_ in cfgs(_odd) if c_["unique_id"] not in site_ids]
|
||||
print(" a roster named with non-ASCII ->",
|
||||
sorted({c_["unique_id"] for c_ in _odd_unit}))
|
||||
check("every unique_id, object_id and state topic this module produces is "
|
||||
"lowercase ASCII, even from a roster name that is not - an id HA would "
|
||||
"not have composed is an entity nothing can subscribe to",
|
||||
_odd_unit
|
||||
and all(re.fullmatch(r"[a-z0-9_]+", c_[k])
|
||||
for c_ in _odd_unit for k in ("unique_id", "object_id"))
|
||||
and all(re.fullmatch(r"[a-z0-9_/]+", c_["state_topic"])
|
||||
for c_ in _odd_unit))
|
||||
|
||||
# The topics the SHIPPED controller actually feeds at a roster of two. Part 8
|
||||
# grades every mutant announcement against this, which is what turns "the
|
||||
# announcement changed" into "the announcement now claims an entity nothing
|
||||
# will ever publish to".
|
||||
FED = set(published_unit_topics)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n8. verified by breaking it")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ MUTATED SOURCE, EXEC'D INTO A FRESH NAMESPACE. No file is edited and no
|
||||
# `.pyc` is consulted, so TEST-15's stale-bytecode failure cannot produce a
|
||||
# false survivor here. `app/mqtt.py` imports nothing from this package, which is
|
||||
# what makes exec'ing it standalone honest rather than a stub.
|
||||
def load(pairs):
|
||||
"""`app/mqtt.py` with `pairs` applied, executed into a fresh module.
|
||||
|
||||
⚠️ EVERY ANCHOR MUST OCCUR EXACTLY ONCE. A mutation whose anchor has drifted
|
||||
out of the file would otherwise apply to nothing and be reported as a
|
||||
survivor - a false red on the instrument rather than on the module.
|
||||
"""
|
||||
src = MQTT_SRC
|
||||
for old, new in pairs:
|
||||
if src.count(old) != 1:
|
||||
raise AssertionError(f"mutation anchor not unique: {old!r}")
|
||||
src = src.replace(old, new)
|
||||
mod = type(sys)("mqtt_mutant")
|
||||
exec(compile(src, "<mutant>", "exec"), mod.__dict__)
|
||||
return mod
|
||||
|
||||
|
||||
def properties(mod):
|
||||
"""Every claim this file makes, as booleans, over ONE module."""
|
||||
out = {}
|
||||
try:
|
||||
base = announce(units=["inverter-a"], module=mod)
|
||||
two = announce(units=["inverter-a", "inverter-b"], module=mod)
|
||||
none = announce(units=(), module=mod)
|
||||
col = announce(units=["inverter-b", "Inverter B"], module=mod)
|
||||
tc = cfgs(two)
|
||||
uc = [c_ for c_ in tc if c_["unique_id"] not in site_ids]
|
||||
bc = [c_ for c_ in uc if "inverter_b" in c_["unique_id"]]
|
||||
ac = [c_ for c_ in uc if "inverter_a" in c_["unique_id"]]
|
||||
comp = [compose(c_["device"]["name"], c_["name"]) for c_ in tc]
|
||||
out["A byte-identical at N=1"] = base == BASELINE
|
||||
out["A byte-identical at N=2"] = diff(two)[:2] == ([], [])
|
||||
out["N=1 adds no topic"] = len(base) == len(none) == len(BASELINE)
|
||||
out["unique_ids all distinct"] = (
|
||||
len(set(c_["unique_id"] for c_ in tc)) == len(tc))
|
||||
out["slug-alike names still distinct"] = (
|
||||
len(set(c_["unique_id"] for c_ in cfgs(col))) == len(cfgs(col)))
|
||||
out["no composed entity_id collision"] = len(set(comp)) == len(comp)
|
||||
out["object_id == composed id"] = all(
|
||||
"sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
|
||||
for c_ in uc)
|
||||
out["A keeps the device it has"] = bool(ac) and all(
|
||||
c_["device"] == mqtt.DEVICE for c_ in ac)
|
||||
out["B has its own device"] = bool(bc) and all(
|
||||
c_["device"]["identifiers"] != mqtt.DEVICE["identifiers"] for c_ in bc)
|
||||
out["B is via_device the site"] = bool(bc) and all(
|
||||
c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
|
||||
for c_ in bc)
|
||||
out["site sensors announced once"] = all(
|
||||
[c_["unique_id"] for c_ in cfgs(announce(units=[f"i{j}" for j in range(n)],
|
||||
module=mod))].count(s) == 1
|
||||
for n in (1, 2, 3) for s in SITE_ONLY)
|
||||
# ⚠️ TWO PROPERTIES THAT WERE MISSING, AND THE INSTRUMENT FOUND THEIR
|
||||
# ABSENCE ITSELF: on the first run of this table, "the unit is dropped
|
||||
# from the state topic" and "`grid` is moved to the per-unit set" were
|
||||
# both GREEN. The first collapses A's and B's four topics onto one set
|
||||
# of four, so both inverters read one number; the second announces a
|
||||
# per-inverter grid sensor, which is the exact thing AC 3 forbids. The
|
||||
# ids stayed distinct through both, so every property above missed them.
|
||||
out["per-unit state topics all distinct"] = (
|
||||
len({c_["state_topic"] for c_ in uc}) == len(uc))
|
||||
# ⚠️ GRADED AGAINST THE SHIPPED `Controller.publish()`, on purpose. An
|
||||
# announcement is only honest if something feeds it - mqtt.py's `omit`
|
||||
# note - so a per-unit entity the controller will never publish to is a
|
||||
# defect however tidy its id looks. (A rename applied to BOTH halves at
|
||||
# once is invisible here and is disclosed in part 9; a rename applied to
|
||||
# one half is what actually happens, and is red.)
|
||||
out["every announced per-unit topic is fed"] = (
|
||||
{c_["state_topic"] for c_ in uc} == FED)
|
||||
except Exception as err: # a mutation that CRASHES proves nothing; say so
|
||||
out["did not raise"] = False
|
||||
out["__error__"] = repr(err)[:70]
|
||||
return out
|
||||
|
||||
|
||||
SHIPPED = properties(mqtt)
|
||||
check("the shipped module satisfies every property this instrument grades - "
|
||||
"so a red below is the mutation and not the instrument",
|
||||
all(v is True for k, v in SHIPPED.items()))
|
||||
|
||||
MUTATIONS = [
|
||||
("⭐ RE-SCOPE A's DEVICE NAME (the destructive change, AC 5)",
|
||||
[('"name": "GoodWe",', '"name": "GoodWe Inverter A",')]),
|
||||
("⭐ RE-SCOPE A's DEVICE IDENTIFIERS",
|
||||
[('"identifiers": ["goodwe_rs485_controller"],',
|
||||
'"identifiers": ["goodwe_inverter_a"],')]),
|
||||
("⭐ THE TEMPTING TIDY DESIGN: one device per inverter, A included",
|
||||
[("device = DEVICE if i == 0 else unit_device(name, slug)",
|
||||
"device = unit_device(name, slug)"),
|
||||
('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
|
||||
("B's rows land on A's device instead of B's own",
|
||||
[("device = DEVICE if i == 0 else unit_device(name, slug)",
|
||||
"device = DEVICE")]),
|
||||
("A's per-unit entity names lose the unit prefix",
|
||||
[('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
|
||||
("per-unit entities announced at a roster of ONE",
|
||||
[("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]),
|
||||
("the unit is dropped from the unique_id",
|
||||
[('unique_id = f"{BASE}_unit_{slug}_{key}"',
|
||||
'unique_id = f"{BASE}_unit_{key}"')]),
|
||||
("the unit is dropped from the state topic",
|
||||
[('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]),
|
||||
("the slug de-collision is removed (rule 9(c) does not cover this)",
|
||||
[("while slug in seen:", "while False and slug in seen:")]),
|
||||
("every unit takes the FIRST unit's slug",
|
||||
[("for i, (name, slug) in enumerate(zip(names, slugs)):",
|
||||
"for i, (name, slug) in enumerate(zip(names, [slugs[0]] * len(slugs))):")]),
|
||||
("`grid` is moved to the per-unit set (site sensor announced per inverter)",
|
||||
[(' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n',
|
||||
' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n'
|
||||
' ("grid", "grid_power", "Grid power", "W", "power", "measurement", None),\n')]),
|
||||
("B's device drops `via_device`",
|
||||
[(' "via_device": DEVICE["identifiers"][0],\n', "")]),
|
||||
]
|
||||
|
||||
survivors = []
|
||||
for label, pairs in MUTATIONS:
|
||||
got = properties(load(pairs))
|
||||
broke = sorted(k for k, v in SHIPPED.items()
|
||||
if v is True and got.get(k) is not True)
|
||||
if not broke:
|
||||
survivors.append(label)
|
||||
print(f" {'RED ' if broke else 'GREEN'} {label}")
|
||||
if broke:
|
||||
print(f" breaks: {', '.join(broke)}")
|
||||
elif got.get("__error__"):
|
||||
print(f" raised: {got['__error__']}")
|
||||
check(f"⭐ ALL {len(MUTATIONS)} MUTATIONS ARE RED, each on a NAMED property: "
|
||||
"re-scoping A's device, the tidy one-device-per-inverter design, B "
|
||||
"landing on A's device, per-unit entities at a roster of one, a unit "
|
||||
"dropped from an id or a topic, two units sharing a slug, and a site "
|
||||
"sensor announced per inverter. A mutation nothing catches is a claim "
|
||||
"nothing is watching",
|
||||
not survivors)
|
||||
if survivors:
|
||||
print(" SURVIVED:", "; ".join(survivors))
|
||||
|
||||
# ⚠️ AC 6, THE FIXTURE'S OWN NON-VACUITY. If A and B were given the same name
|
||||
# the whole of part 4 would still be green and would be observing nothing.
|
||||
same = announce(units=["inverter-a", "inverter-a"])
|
||||
swapped = announce(units=["inverter-b", "inverter-a"])
|
||||
check("...and the fixture's two units are genuinely distinguishable: swapping "
|
||||
"their order changes the announcement, and a roster of two IDENTICAL "
|
||||
"names - which rule 9(c) refuses, and which this file therefore never "
|
||||
"relies on - would still not collide",
|
||||
swapped != TWO
|
||||
and len({c_["unique_id"] for c_ in cfgs(same)}) == len(cfgs(same)))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n9. what this file CANNOT see, enumerated and sized")
|
||||
# ---------------------------------------------------------------------------
|
||||
for line in [
|
||||
"1. HA ITSELF IS NOT IN THE LOOP. `compose()` in part 6 is a MODEL of how "
|
||||
"HA 2026.8 builds an entity_id, anchored on exactly ONE measured id "
|
||||
"(sensor.goodwe_p1_sample_age, mqtt.py's note, verified 2026-08-26). If HA "
|
||||
"changes that rule, or if it differs for a device with `via_device`, every "
|
||||
"id claim here moves with it and this file stays green. SIZE: the 8 "
|
||||
"per-unit ids at N=2. It cannot be closed from this repository - it needs a "
|
||||
"broker, an HA, and the owner present.",
|
||||
"2. THE TICKET AND THE MODULE DISAGREE ABOUT THE LIVE IDs. The card quotes "
|
||||
"`sensor.goodwe_rs485_controller_goodwe_battery_soc`; mqtt.py records "
|
||||
"`sensor.goodwe_p1_sample_age`. Those cannot both come from one composition "
|
||||
"rule - the first is the id an EARLIER device name composed, and HA keeps "
|
||||
"whatever it composed at first discovery. This card does not need to "
|
||||
"resolve it, because it renames no device and no entity, and the byte-diff "
|
||||
"protects both spellings equally. SIZE: unresolved, 8 live entity ids; it "
|
||||
"wants one look at the live registry, which this branch must not take.",
|
||||
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE. A's are diffed against "
|
||||
"release/1.0; B's can only be diffed against this branch (the sha256 pin in "
|
||||
"part 3). A wrong `device_class` or unit on a per-unit sensor is therefore "
|
||||
"caught as a CHANGE from review, not as an error. SIZE: 4 sensors x 4 "
|
||||
"optional fields.",
|
||||
"4. NO CHECK HERE RUNS A BROKER. `_announce()` is graded through a fake "
|
||||
"paho client, so QoS, retain semantics on a real broker, and what HA does "
|
||||
"with a retained config for a device it has never seen are all out of "
|
||||
"scope. SIZE: the whole transport.",
|
||||
"5. THE SLUG DE-COLLISION IS ORDER-DEPENDENT. `inverter-b` before "
|
||||
"`Inverter B` gives the SECOND one the `_2` suffix; reordering the roster "
|
||||
"swaps which unit's ids move. Rule 9(c) does not refuse slug-alike names, "
|
||||
"so this is reachable from a config an operator can write. SIZE: 4 ids on "
|
||||
"one unit, and only when two names slug alike. Filed as a follow-up rather "
|
||||
"than fixed in validate.py here, because a new refusal at startup on a "
|
||||
"LIVE install is a bigger change than this card is allowed to be.",
|
||||
"7. PART 8 MUTATES `app/mqtt.py` ONLY. `Controller.publish()`'s four "
|
||||
"per-unit lines are graded by part 7 - with different SoC, different "
|
||||
"battery sign and different latch states on the two units, so a swap or a "
|
||||
"mis-paired slug is red - but no automated table mutates main.py here. "
|
||||
"Those mutations were run by hand through `tools/mutation_run.sh` and are "
|
||||
"recorded in the PR. SIZE: 4 lines. Also invisible: a rename of "
|
||||
"`unit_state_key()` applied to BOTH halves at once, which is harmless "
|
||||
"(the topics are internal) but would move every per-unit state topic "
|
||||
"silently.",
|
||||
"6. `inverter AC power` AND PER-UNIT HEARTBEAT AGE ARE NOT ANNOUNCED. The "
|
||||
"card lists both as per-unit; neither has a source in the add-on today "
|
||||
"(nothing reads an AC-power register per unit, and `inv.last_beat` is a "
|
||||
"monotonic loop clock). Announcing them would create entities nothing "
|
||||
"feeds. SIZE: 2 sensors x N units, deliberately absent, named in the PR.",
|
||||
]:
|
||||
print(" " + line)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -109,6 +109,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import inf, isinf
|
||||
from pathlib import Path
|
||||
@@ -1062,6 +1063,58 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green "
|
||||
# block with no seconds remaining - so the whole setup is retried on a fresh
|
||||
# Controller rather than left to flake. It is bounded at three attempts: a fourth
|
||||
# would mean something is wrong that is not a boundary.
|
||||
#
|
||||
# ⚠️⚠️ CI-06. AND THE QUARTER MUST HAVE ROOM LEFT TO SIZE A FIXTURE IN, WHICH IS
|
||||
# A SECOND AND MUCH WIDER RACE THAN THE BOUNDARY ONE ABOVE. It is where three of
|
||||
# this file's four known-flaky checks came from, and it is not a boundary
|
||||
# effect - it is the control law's own gain applied to this helper's arithmetic.
|
||||
#
|
||||
# Every second of the open block the telegrams do NOT cover is credited at the
|
||||
# HELD value instead of the measured one, so the projection loses `net_w x
|
||||
# uncovered / 900` W - and `excess = 900 x (projected - ceiling) / remaining`
|
||||
# multiplies that back up by `900 / remaining`. The floor this helper delivers
|
||||
# is therefore
|
||||
#
|
||||
# floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining
|
||||
#
|
||||
# and the second term is the whole story: at 600 s left one uncovered second
|
||||
# costs 6 W of a 1500 W floor, at 12 s left it costs 292 W, and at 3 s left it
|
||||
# takes the projection under the ceiling and THE GUARD DOES NOT ENGAGE AT ALL -
|
||||
# which fails "the real cycle files the peak claim", "...and the number written
|
||||
# to the inverter is the guard's floor" and "the STATUS PAGE row for a
|
||||
# floor-limited guard" together, for no reason except the minute the suite ran.
|
||||
# Measured on this tree as shipped: at 12 s left the fixture asked for 1500 W
|
||||
# and delivered 613 W, a -59 % fixture that stayed green and graded a different
|
||||
# site than the one its text names.
|
||||
#
|
||||
# TWO uncovered stretches produced it and both are closed here:
|
||||
#
|
||||
# 1. THE TAIL. `range(0, span + 1, 5)` stops at the last 5 s mark at or before
|
||||
# `now`, so 0-5 s of the block were never covered by construction. One
|
||||
# final telegram stamped at `now` closes it - which is also what a real
|
||||
# meter does, and what this helper's own docstring already claims it does.
|
||||
# 2. THE LAG between that last telegram and `cycle()`'s own `datetime.now()`.
|
||||
# That one is real work and cannot be removed, only bounded: measured at
|
||||
# 12-96 ms over 23 fixtures on this tree under contention.
|
||||
#
|
||||
# So the ROOM the helper needs is derived from what it promises rather than
|
||||
# picked. Requiring the delivered floor to be within 1 % of the floor asked for,
|
||||
# at the shipped ceiling and this file's smallest fixture (`net_w` ~ 3500 W,
|
||||
# `want` 1500 W), and taking the lag at 0.1 s - past every one of the 23
|
||||
# measurements:
|
||||
#
|
||||
# net_w x lag / (remaining x want) <= 0.01 - lag/900
|
||||
# remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s -> FIXTURE_ROOM_S
|
||||
#
|
||||
# Below that the helper WAITS OUT THE REST OF THE QUARTER rather than grading a
|
||||
# fixture the arithmetic cannot deliver. The wait is at most FIXTURE_ROOM_S and
|
||||
# is taken on about 2.6 % of runs; a suite that silently graded a -59 % fixture
|
||||
# instead was the alternative. ⚠️ This is not a widened tolerance - the checks
|
||||
# downstream are unchanged and the 1 % is a requirement on the FIXTURE, not on
|
||||
# any assertion.
|
||||
FIXTURE_ROOM_S = 24.0
|
||||
|
||||
|
||||
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
standing_w=None, soc=55.0, prepare=None):
|
||||
"""A real Controller driven one real cycle over a real over-limit quarter.
|
||||
@@ -1081,6 +1134,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
computed backwards from `remaining` at the moment of the run.
|
||||
"""
|
||||
for attempt in range(4):
|
||||
# ⚠️ ROOM FIRST, AND BEFORE THE CONTROLLER IS BUILT. See FIXTURE_ROOM_S.
|
||||
# Retrying does not help here - four attempts take ~40 ms each and all
|
||||
# four would land in the same dying quarter - so the tail of the block
|
||||
# is waited out once, which puts the next attempt at ~900 s of room.
|
||||
_room = QUARTER_S - (datetime.now(timezone.utc).timestamp() % QUARTER_S)
|
||||
if _room < FIXTURE_ROOM_S:
|
||||
time.sleep(_room + 0.05)
|
||||
ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts,
|
||||
store=_Store(store) if store is not None else None)
|
||||
ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w
|
||||
@@ -1099,6 +1159,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
|
||||
for i in range(0, span + 1, 5):
|
||||
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
|
||||
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
|
||||
# value over the interval to the NEXT sample, so without this the block
|
||||
# is covered only to the last 5 s mark and 0-5 s of it are extrapolated
|
||||
# from the held value. See the header: at 12 s of remaining that alone
|
||||
# turned a 1500 W fixture into a 613 W one. A real meter does not stop
|
||||
# sending at the last multiple of five either.
|
||||
ctl.p1.submit(sample(now, net_w))
|
||||
# ⚠️ ASSIGNED, not read back from `p1.net_w`. This helper stamps
|
||||
# `ingest_mono` with an epoch second so the averager (which keys on
|
||||
# `ingest_ts`) replays correctly, while `net_w` keys on a MONOTONIC age
|
||||
@@ -1273,6 +1340,23 @@ _shipped_decide = peakguard.decide
|
||||
|
||||
def _recording_decide(projection, **kw):
|
||||
_seen.update(kw)
|
||||
# ⚠️ CI-06. THE PROJECTION OBJECT ITSELF IS KEPT, AND THAT IS WHAT MAKES THE
|
||||
# COMPARISON BELOW A COMPARISON RATHER THAN A RACE. The check under
|
||||
# "THE CONSEQUENCE, ITERATED" says both forms are run "against the same
|
||||
# frozen projection", and until this line existed that sentence was FALSE:
|
||||
# the intent form was decided against a SECOND `PeakTracker.project(
|
||||
# b3.p1.averager, datetime.now())` taken after the cycle had returned, so
|
||||
# the two numbers came from two different instants of the wall clock. The
|
||||
# control law is `excess = 900 x (projected - ceiling) / remaining`, whose
|
||||
# derivative in time is `excess / remaining`, so at a 1500 W floor the two
|
||||
# instants separated at 1500 / remaining W per second and the check's 1.5 W
|
||||
# tolerance bought exactly `remaining / 1000` SECONDS - 900 ms at the top of
|
||||
# a quarter and 6 ms with ten seconds left of it. Measured on this tree, the
|
||||
# gap between the two statements is 13-40 ms idle and larger under load, so
|
||||
# the check was answered by WHERE IN THE QUARTER IT RAN about `gap_ms / 900`
|
||||
# of the time. Keeping the object removes the clock from the comparison
|
||||
# altogether: the tolerance below is EXACT, not widened.
|
||||
_seen["projection"] = projection
|
||||
return _shipped_decide(projection, **kw)
|
||||
|
||||
|
||||
@@ -1294,15 +1378,36 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
|
||||
# against the same frozen projection - the inverter never follows, so the meter
|
||||
# never moves - and the two are allowed to separate over time rather than being
|
||||
# judged at a single point where they need not differ.
|
||||
_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc))
|
||||
#
|
||||
# ⚠️ CI-06. "THE SAME FROZEN PROJECTION" IS NOW THE SAME OBJECT. `_proj3` is the
|
||||
# `Projection` the real cycle actually graded, captured by `_recording_decide`
|
||||
# above, and not a second projection of the same averager taken at a later
|
||||
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
|
||||
# tolerances that used to absorb the wall clock are gone.
|
||||
_proj3 = _seen["projection"]
|
||||
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
|
||||
soc_floor=SOC_FLOOR)
|
||||
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
|
||||
# The delivered form, re-decided from the SAME projection. It has to reproduce
|
||||
# the real controller's own floor EXACTLY or `_kw` is not the call main.py
|
||||
# makes - which is a second thing this now grades and did not before, and the
|
||||
# one that keeps the identity below honest rather than self-referential.
|
||||
_delivered = _shipped_decide(_proj3, batt_w=0.0, **_kw)
|
||||
check("the delivered form re-decided from the cycle's OWN projection is the "
|
||||
"number the real Controller wrote, to the bit. The kwargs below are "
|
||||
"main.py's call and not an approximation of it, so the identity that "
|
||||
"follows is a statement about `decide()` and not about this fixture",
|
||||
near(_delivered.floor_w, b3.peak_guard.floor_w))
|
||||
check("one cycle of the intent form is exactly the delivered form plus the "
|
||||
"command standing on the wire - which is the whole defect in one line: "
|
||||
"watts that were asked for and never arrived, counted as if they had",
|
||||
near(_intent.floor_w - b3.peak_guard.floor_w, STANDING, 1.5))
|
||||
"watts that were asked for and never arrived, counted as if they had. "
|
||||
"⚠️ EXACT, not within 1.5 W: both forms are decided from one Projection "
|
||||
"object, so a tolerance here would only be hiding a clock. CI-06 - the "
|
||||
"1.5 W it used to carry bought `remaining / 1000` seconds and made this "
|
||||
"line answer by where in the quarter the suite happened to run",
|
||||
near(_intent.floor_w - _delivered.floor_w, STANDING)
|
||||
and not _intent.saturated and not _delivered.saturated)
|
||||
|
||||
_i_cmd, _d_batt = STANDING, 0.0
|
||||
_i_track, _d_track, _i_sat_at = [], [], None
|
||||
|
||||
Reference in New Issue
Block a user