Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c5ee8adc | ||
|
|
f72c48dbfd | ||
|
|
fef6ef20a4 | ||
|
|
3993f9f32b | ||
|
|
19decf433f | ||
|
|
8beba0a3e7 | ||
|
|
a4c95f83ce | ||
|
|
ffbabea8ce | ||
|
|
f9e7fbf86d | ||
|
|
67e649ad6b |
@@ -2,6 +2,146 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**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
|
||||
*Configuration* read `unit_sign_disagree_w`, `saturation_cycles`,
|
||||
`integrator_max_w`, seventy of them, in file order, with no description and no
|
||||
way to tell a constant derived from 1890 closed-loop test runs from their own
|
||||
inverter's rating. **This card changes presentation only: not one default, not
|
||||
one range and not one line of behaviour moved.**
|
||||
|
||||
* **`translations/en.yaml` and `translations/nl.yaml`**, with a `name` and a
|
||||
`description` for **all 70 options** and for **all 7 per-entry keys** of the
|
||||
`inverters:` repeatable section. The Dutch is written for a Flemish
|
||||
homeowner rather than transliterated: *afname* and *injectie*, *kwartierpiek*
|
||||
and *capaciteitstarief*, *laadniveau*, *uitschakelgrens* — the words that are
|
||||
on the bill and on the meter.
|
||||
* **⭐ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM AND WHETHER TO TOUCH IT.**
|
||||
`unit_sign_disagree_s` now reads *"derived from 1890 closed-loop test runs
|
||||
(SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs
|
||||
produce… do not change it without re-deriving"*; `circulation_w` cites its 924
|
||||
runs and the measured 400 W knee; `meter_freeze_s` cites the 2399 s capture;
|
||||
`bms_soc_floor` says it is register `0x0560` read off the pack. The two
|
||||
numbers that are genuinely the operator's — `max_w` and `peak_cap_w` — say
|
||||
**"THIS IS YOUR NUMBER"** instead, and `cycle_cost_threshold_eur`, which is
|
||||
neither measured nor the operator's, admits it is an open product question
|
||||
(OPT-08) rather than borrowing the others' authority.
|
||||
* **`config.yaml` reordered into task order** — hardware/entities → meter →
|
||||
ratings and limits → tariff → maintenance → safety → advanced — in **both**
|
||||
halves, with every comment carried with its key. ⚠️ The rendered order is the
|
||||
**`schema:`** half's (`App.schema_ui` is `UiOptions(...)(data["schema"])`);
|
||||
the `options:` half is reordered to match so the file reads the way the tab
|
||||
renders. ⚠️ **`test_inverter.py` part 12's first-match trap is preserved**:
|
||||
the nested per-entry `max_w:` still appears BEFORE the flat one under
|
||||
`schema:`, and `inverters: []` still ships in the `options:` half. Moving the
|
||||
nested block below the flat one is a **1-byte** edit and it was run: red in
|
||||
`test_inverter.py` and in `test_translations.py`.
|
||||
* **MEASURED, NOT ASSERTED.** `tools/supervisor_shape_probe.py` gains a section
|
||||
F that loads both files with Supervisor 2026.08.0's *own*
|
||||
`_read_app_translations()` out of the release image (a file that fails
|
||||
`SCHEMA_APP_TRANSLATIONS` is **dropped with a log warning and nothing else**),
|
||||
runs its own `UiOptions`, replays the frontend's label resolution transcribed
|
||||
from the shipped bundle, and prints every rendered row in both languages.
|
||||
* **⚠️ ONE MEASURED NEGATIVE RESULT, RECORDED RATHER THAN GLOSSED.** The
|
||||
per-entry keys inside the `inverters:` repeatable section still render RAW.
|
||||
`_convertSchemaElement` sends `type: "schema"` **with** `multiple` to
|
||||
`ha-selector-object`, which labels its fields `fields[name].label || name`,
|
||||
and the converter sets neither `label` nor `translation_key`. The `fields:`
|
||||
translations are shipped and validated because that is the shape Supervisor's
|
||||
own schema defines for exactly this, and a frontend that wires `label` through
|
||||
would consume them — but on 20250925.1 they do not reach the screen, and this
|
||||
card does not claim they do. The `inverters` label and description themselves
|
||||
do.
|
||||
* **A new check, `test_translations.py`, so this cannot rot.** A translation key
|
||||
naming an option that does not exist, or an option with no translation, fails
|
||||
CI — in both directions, for both languages, top-level and per-entry. It is
|
||||
**mutation-tested both ways**: eleven mutations applied in-process, each
|
||||
required to NAME the key it broke rather than merely to go red, plus a
|
||||
negative control that the unmutated input is clean. Two of the eleven are the
|
||||
ones that cost the WHOLE FILE rather than one label — an unrecognised
|
||||
top-level block, and a `network:` value that is not a string. Supervisor
|
||||
validates each translation file through `SCHEMA_APP_TRANSLATIONS` and, on
|
||||
refusal, **logs a warning and drops all of it**, so those silently return the
|
||||
entire tab to raw keys; neither is reachable through the `configuration:`
|
||||
coverage, which is why they were hunted for and closed. Six more were run **on disk**, through the
|
||||
purge-and-re-read protocol: deleting a translation (−65 B), renaming an option
|
||||
in `config.yaml` (+4 B), adding an orphan key (+47 B), emptying a Dutch
|
||||
description (−711 B), breaking one label's group prefix (−4 B) and deleting a
|
||||
provenance citation (−19 B) each go red.
|
||||
* **The parser has a licence.** There is no PyYAML in the add-on's CI image, so
|
||||
`tools/translations_lint.py` reads these files by hand — and a hand parser
|
||||
that quietly disagrees with the real loader is the defect this repository
|
||||
keeps finding. The probe imports **that same module** and asserts, key by key
|
||||
and string by string, that it returns what `_read_app_translations()` returns.
|
||||
The parser is deliberately strict and **raises** on anything outside the
|
||||
subset; nine malformed shapes are fed to it to prove it does.
|
||||
* **⚠️ WHAT THIS CHECK CANNOT SEE, stated with its size.** It grades coverage
|
||||
and provenance *citations*, not truth. Falsifying a figure inside a
|
||||
description that is not one of the cited tokens survives (run: *"8.5 times"* →
|
||||
*"3 times"*, −2 B, green), and swapping two options' descriptions survives
|
||||
(0 B, green). Separately and out of this card's scope: changing a shipped
|
||||
default that no suite pins survives — `step_w: 10` → `20` (0 B) leaves
|
||||
`test_control.py`, `test_clamp.py`, `test_validate.py` and
|
||||
`test_peakguard.py` green. That is the same family as CFG-04's DEFECT 2 and
|
||||
the UX-01 rescope moved it to its own card on purpose.
|
||||
|
||||
**Inverter B's rating is 4600 W, FROM ITS DATASHEET, and every documented copy
|
||||
of it now says so (SPIKE-04a).** Three places stated B's rating — `config.yaml`'s
|
||||
commented roster example, `DOCS.md`'s, and `tools/supervisor_shape_probe.py`'s
|
||||
@@ -179,6 +319,284 @@ two power-moving phases on one grid connection pulling against each other.
|
||||
is loop and allocator territory. Pinned as a running check
|
||||
(`test_maintenance.py` part 12) so it cannot be forgotten.
|
||||
|
||||
**`bms_soc_floor` describes a BATTERY, so at two packs it stops being one number
|
||||
(SAFETY-32).** The peak guard's energy backstop rests on two figures: the EMS
|
||||
floor `peak_soc_floor` (the depth a money-saving discharge may never reach) and
|
||||
`bms_soc_floor`, the percent **that pack's own BMS** cuts out at — register
|
||||
`0x0560`, read as 10 on the reference GW5000S-BP. Rule 8 refuses an EMS floor at
|
||||
or below the cut-out, because such a floor can never bind: the BMS wins and the
|
||||
status page reports a backstop that does not exist. With ONE cut-out for two
|
||||
packs of different models that rule graded the EMS floor against whichever
|
||||
battery happened to be written into the flat key, and a site with cut-outs of
|
||||
10 % and 30 % under `peak_soc_floor: 20` **started** — the guard would then
|
||||
command a forced discharge that pack B's BMS, not the operator's floor, is the
|
||||
thing that stops. Rule 8's own failure mode, reached through rule 8.
|
||||
|
||||
* **`bms_soc_floor` becomes a per-entry roster key, and it is REQUIRED there.**
|
||||
Nothing is inherited: an entry that omits it has no cut-out, does not borrow
|
||||
the flat one, and is refused by name at startup. A per-entry *default* was
|
||||
considered and declined — a default is one number for every pack, which is the
|
||||
defect this card removes; `DECISIONS/SAFETY-32-per-pack-bms-floor.md` records
|
||||
the decision and its migration cost. The flat key is unchanged and is now
|
||||
documented as the single-inverter shape's own cut-out.
|
||||
* **Rule 8 grades EVERY unit and NAMES the one it refuses**, through a new
|
||||
`unit_bms_floors_indexed()` that carries the **roster** index the value came
|
||||
off. CFG-04 had just fixed a rule 5/6 bug of exactly this shape — a caps-list
|
||||
index fed to a function that indexes the roster, so the message named the wrong
|
||||
inverter — and this reader skips nothing, so there is no second list for an
|
||||
index to desynchronise from. Graded on a roster whose offender is the SECOND
|
||||
entry *and* on the same two numbers in the other order.
|
||||
* **The permission is answered PER PACK.** `Controller.pack_floor_soc` — the
|
||||
emptiest measured pack — is untouched and is still what `decide()` grades;
|
||||
what changed is the floor. `peakguard.pack_permissions()` answers one
|
||||
`(name, reason)` per pack against that pack's own floor, and the floor-limited
|
||||
notification now names the pack that stopped the site rather than "the
|
||||
battery". At a roster of one the alarm is byte-for-byte what SAFETY-25 shipped.
|
||||
* **An unstated cut-out withholds rather than defaults.** `None`, not `0.0` —
|
||||
`discharge_permitted()` already treats an unknown floor as no permission, so
|
||||
the absence costs a shave and never a pack, including on `sim/inject.py`'s path
|
||||
which does not run the startup gate.
|
||||
* ⚠️ **Stated rather than hidden:** under any config rule 8 accepts, the per-pack
|
||||
*floor* reduces to `peak_soc_floor` for every pack — rule 8 makes it so. The
|
||||
reduction is written for `unit_caps_indexed()`'s reason ("unreachable" is a
|
||||
claim about rule ordering, not a property of a function) and is graded on both
|
||||
sides: 1775 rule-8-valid `(peak_soc_floor, bms_soc_floor)` pairs answer the
|
||||
option, and the pair rule 8 refuses answers the cut-out. `peak_soc_floor`
|
||||
itself is still one number for the site, so the "deeper pack needlessly
|
||||
withheld" half is filed as an open question, not closed.
|
||||
* **`tools/supervisor_shape_probe.py` re-run and repaired.** MUTATION.md habit 8:
|
||||
a committed transcript is a check and it decays. The required key broke §C and
|
||||
left §D printing PASS on a refusal about a *different* key. §D is now an
|
||||
isolated counterfactual over an accepted, merged baseline, one key varied per
|
||||
case, asserting the key **name** in the refusal — `all cases passed` on
|
||||
`amd64-hassio-supervisor:2026.08.0`.
|
||||
* ⚠️ **SECOND FIX ROUND — the round-2 fix carried the defect it was written to
|
||||
close, in its prose.** Two more rewrites survived a green 19-file suite on
|
||||
`8501e05`: `binding_soc_floor()`'s `max(floors)` → `max(floors[-2:])` (+5
|
||||
bytes) and the call site's `self.packs` → `self.packs[:2]` (+4 bytes). Both are
|
||||
checks whose TEXT claimed more than their FIXTURE could reach — 12c2's only
|
||||
counterfactual varied the **last** pack, which every window ending at the
|
||||
roster's end follows, and all three 12g runs drove a **two**-pack roster, where
|
||||
`[:2]` *is* the roster. **The general law, now stated in the file: a fixture of
|
||||
N packs cannot separate two reductions that agree on N packs**, and the regress
|
||||
does not terminate in a fixture — `[:1]` needed two packs, `[:2]` three, `[:3]`
|
||||
four. So the reduction is now graded by a **length sweep** (78 simulated
|
||||
rosters, lengths 1–12, deepest cut-out at every position, closing head and tail
|
||||
windows of width 1–11 at once) plus a **computed family table** of fourteen
|
||||
rewrites that goes red naming any member no roster kills; the call site is
|
||||
graded by **three-pack runs** with the deep cut-out at index 0, 1 and 2, plus
|
||||
an **exact structural pin** (the roster argument must unparse to `self.packs`)
|
||||
for the slice widths no bounded roster can reach. Round 3's own battery found
|
||||
`self.packs[:3]` / `[-3:]` surviving all six behavioural runs — the structural
|
||||
pin is what closes them — and one row is recorded **NOT CAUGHT**: a reduction
|
||||
over a window twelve packs wide, which needs a 13-pack roster to observe.
|
||||
No shipped behaviour changed in this round; only what observes it.
|
||||
* **Twelve source mutations, eleven red, one argued survivor**, through
|
||||
`tools/mutation_run.sh` (bytecode purged). The battery also found a real
|
||||
weakness of its own: `_flat_bms_floor()` had the default in two places and each
|
||||
covered for the other, so deleting either survived — masking, in three lines of
|
||||
option reading. One defaulting line now.
|
||||
* ⚠️ **FIX ROUND — that battery was INCOMPLETE, and the review found what it
|
||||
missed.** Neither of the two mutations below appears in the twelve rows above:
|
||||
the ledger graded "the reduction dropped" and "the bare option handed over"
|
||||
and never asked what happens when the reduction is simply the *wrong* one, or
|
||||
when the roster handed to it is *sliced*. No shipped behaviour changed here;
|
||||
two *checks* whose data could not reach the failure they named did. `max(floors)` → `min(floors)` in
|
||||
`binding_soc_floor()` (zero byte delta) and `self.packs` → `self.packs[:1]` at
|
||||
the `decide()` call site (+4 bytes) were each green across all 19 files. Both
|
||||
survived for the same reason: **under any rule-8-valid config every pack's
|
||||
`pack_floor_pct()` equals `peak_soc_floor`**, so `floors` is a list of
|
||||
identical numbers and `max`, `min`, first and last are four spellings of one
|
||||
answer — and the one check using a cut-out above the EMS floor ran on a roster
|
||||
of ONE, where every reduction agrees. The second also walked through part 12g,
|
||||
which asserted `"self.packs" in <the unparsed call site>`: a slice still
|
||||
contains that substring, so the pin passed on the mutation that grades the
|
||||
whole site against pack A. MUTATION.md habit 4 — a source-text test is a check
|
||||
on how code is spelled, not on what it does.
|
||||
* **New part 12c2** evaluates `binding_soc_floor()` on a roster whose per-pack
|
||||
floors **all differ** — four simulated cut-outs of 30/25/40/35 %, every one
|
||||
above `peak_soc_floor`, chosen so that `max` (40), `min` (25), first (30),
|
||||
last (35) and the mean (32.5) are five different numbers and the two extremes
|
||||
sit at *interior* roster positions. Two packs cannot separate those four;
|
||||
four is the smallest roster that can. The precondition is its own check
|
||||
(habit 1) and one pack — the last, neither deepest nor shallowest — is then
|
||||
raised past the old maximum and the answer follows it (habit 7).
|
||||
* **Part 12g is now behaviour, not source text.** Real `Controller.cycle()`s on
|
||||
a two-pack roster, both packs at the same SoC, with the cut-outs as the only
|
||||
thing varied: move the **non-first** pack's cut-out and the floor reaching
|
||||
`decide()` moves 20 % → 35 %, with the site's verdict flipping from a sized
|
||||
discharge to FLOOR-LIMITED. `self.packs[:1]` answers 20 % both times. Which
|
||||
option key the floor comes from stays pinned on the source where it already
|
||||
was, because that claim genuinely is about the code's shape.
|
||||
* ⚠️ **Two MORE survivors, found by this round's own battery and recorded
|
||||
rather than quietly repaired.** (a) The first cut of part 12g varied only
|
||||
pack B's cut-out, so `self.packs[-1:]` — grade the site against the **last**
|
||||
pack — agreed with the full roster on both runs and stayed green across all
|
||||
19 files (+5 bytes). A two-run fixture pins one slice and not the other.
|
||||
Part 12g now runs the **same pair of cut-outs in both orders**, so the
|
||||
deeper one is on the second pack in one run and on the first in another and
|
||||
the site floor is 35 % in both; no single-position slice passes all three
|
||||
runs. (b) `binding_soc_floor()`'s empty-roster branch rewritten to
|
||||
`return None` (−43 bytes) was also green — a defensive branch nothing in the
|
||||
suite reached. Both directions of it are pinned now, with the note that it
|
||||
cannot permit anything either way because `pack_floor_soc` is `None` at an
|
||||
empty roster.
|
||||
|
||||
* ⚠️ **THIRD FIX ROUND — the same family was alive at the SIBLING ARGUMENT of
|
||||
the same call.** Rounds 2 and 3 hardened the FLOOR axis; `decide()` is handed
|
||||
a second reduction over the same roster beside it, `soc=self.pack_floor_soc`,
|
||||
and `peakguard.py`'s own docstring names the two as a pair. Three rewrites of
|
||||
`Controller.pack_floor_soc` were green across all 19 files on `52380c0` and
|
||||
again after the rebase onto `3934ad7`: `min(float(v) for v in vals)` →
|
||||
`vals[:2]` (+4), `vals[-2:]` (+5) and `vals[:3]` (+4), while `[:1]`, `[-1:]`,
|
||||
`[1:]`, `[:-1]`, `max()` and the mean were all red. **The suite's separating
|
||||
power on the SoC axis stopped at exactly N=2** — the state the floor axis was
|
||||
in before round 3. It changes a real answer: a three-pack roster at
|
||||
50 / 60 / 11 % with the emptiest pack THIRD answers 11 % shipped and 50 %
|
||||
mutated, so the site is graded for discharge PERMISSION against a pack 39
|
||||
points fuller than the emptiest and spends a pack its own BMS stops. Round 3's
|
||||
machinery transfers directly — new **part 12i**: six real `cycle()`s with the
|
||||
emptiest pack at every position of a three-pack roster and both positions of a
|
||||
two-pack one (the observable read off `PeakDecision.soc`, cut-outs held flat
|
||||
so the floor axis cannot be what moved the answers); computed slice and
|
||||
non-slice tables that go red naming any member nothing kills; a **length
|
||||
sweep** of 78 real rosters, lengths 1–12, closing head and tail windows of
|
||||
width 1–11 and recording width 12 as honestly unreached; a **none-guard
|
||||
sweep** over the same 78; and **three exact structural pins** — the `vals`
|
||||
comprehension must iterate the bare `self.inverters`, and both `any()` and
|
||||
`min()` must reduce the whole `vals` — which close every slice width at every
|
||||
roster length. No shipped behaviour changed; only what observes it.
|
||||
* ⚠️ **SPIKE-04: production is DEGENERATE on the floor axis, so the
|
||||
differing-floor fixtures are the entire exposure.** Pack B is 4 × Dyness
|
||||
DL5.0C, DoD 90 → floor 10 % — **FROM THE DATASHEET AND THE SERIAL; inverter B
|
||||
does not exist and nothing about it is measured.** Pack A is DoD 90 → 10 % as
|
||||
well, and that one *is* measured. Both packs therefore floor at the same value
|
||||
in the field, `max(10, 10)` is 10 whatever it reduces, and a differing floor
|
||||
exists **only in the fixtures**. That is the argument for keeping them, not for
|
||||
simplifying them: anyone who collapses `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to
|
||||
one number deletes the only data this repository has that can see the
|
||||
reduction at all, and part 12c2's precondition check goes red if they do. The
|
||||
**SoC** axis has no such degeneracy — two batteries at the same state of
|
||||
charge is a coincidence, not a configuration — which is what makes
|
||||
`pack_floor_soc` the half of the pair that will be live from the day inverter
|
||||
B is wired up.
|
||||
* ⚠️ **The FAMILY enumerated rather than the member closed, and it found four
|
||||
more survivors in code this card never touched.** Every reduction over
|
||||
`self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||||
mutated to a slice against the full suite. Eighteen were already graded
|
||||
(`site_batt_w`, `site_rating_w`, `site_max_w`, `site_dev_bound`,
|
||||
`site_circulating_w`, `inputs_ok`, `units`, `Controller.packs`, `maint_unit`,
|
||||
the maintenance-due minimum, the store-migration roster, `validate_unit_ranges`
|
||||
and the round-2/3 sites). **Four were not, and three of them are SAFETY-30's,
|
||||
all `any(i.sign_wd.tripped for i in self.inverters)`** — `cycle()`'s
|
||||
stale-input hold, `publish()`'s status vocabulary and `status_payload()`'s
|
||||
banner — each +4 bytes and green across all 19 files, for the reason every
|
||||
blocker on this card was invisible: **the only fixture that reached them was a
|
||||
roster of ONE, where `self.inverters[:1]` IS `self.inverters`.** The first has
|
||||
teeth: a latched unit must take the roster past that hold or its `failsafe`
|
||||
SET stops being re-filed the moment a poll is missed, which is main.py's own
|
||||
sentence for it. New **test_watchdog.py part 8** grades all three with the
|
||||
latch at EACH position plus a no-latch counterfactual, and new **part 12j**
|
||||
pins all 26 roster comprehensions in `Controller` to iterating the roster
|
||||
whole — so a reduction added over a slice anywhere in the class is red on the
|
||||
line it is written, with no fixture required. Two rows are reported open
|
||||
rather than closed: `_grade_store_migration()`'s second disjunct (now red on
|
||||
12j, and argued to be an equivalent mutant because a unit tripped at startup
|
||||
implies its namespaced key is set) and `cycle()`'s undeliverable-unit **log
|
||||
line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names
|
||||
in one event message; the refusal itself is a different expression and is
|
||||
graded, so the site still stops.
|
||||
* ⚠️ **FOURTH FIX ROUND — the same family one MODULE over, and the pin widened
|
||||
until it discovers its own scope.** Round 4 enumerated the family and closed
|
||||
it inside the `Controller` class; the review found four more survivors
|
||||
immediately, in **validate.py**, on a 19/19 green suite: `min(caps)` →
|
||||
`min(caps[:1])` (+4) and `→ min(caps[-1:])` (+5) at `:779`, the caps
|
||||
comprehension over `caps_ix[:1]` (+4) at `:778`, and
|
||||
`min(caps_ix[-1:], key=…)` (+5) at `:893`. **None is an equivalent mutant.**
|
||||
With `max_w: 5000` on inverter-a beside `2000` on inverter-b and
|
||||
`sign_disagree_w: 3000`, the shipped validator refuses — *"the SAFETY-02
|
||||
sign-disagreement watchdog can never fire"* — and all four **accept**: the
|
||||
site starts with a watchdog reporting `armed` that cannot trip on pack B.
|
||||
Rule 5's own failure mode reached through rule 5; rule 6 loses its refusal the
|
||||
same way. **No shipped behaviour changed by the pin; what changed is that a
|
||||
hand-written scope stopped being trusted.**
|
||||
* **`test_peakguard.py` part 12j now parses every `.py` under `app/`** — no
|
||||
module is named in the check — and finds the roster by transitive closure
|
||||
from **one seed**, the `inverters` option key: through returns, through
|
||||
lists **accumulated in a loop over a roster** (which is what `caps`,
|
||||
`roster` and `problems` are, and what round 4's rule could not express),
|
||||
through attributes and properties, and **through call arguments**, which is
|
||||
how it walks into `peakguard`, `arbiter`, `allocator`, `circulation`,
|
||||
`maintenance` and `store` without being told they exist. A slice of a roster
|
||||
stays a roster so a mutation cannot cut the taint; an index is one unit and
|
||||
does not. **183 reductions across 9 modules** out of 23 files parsed — `main.py` 96,
|
||||
`allocator.py` 24, `validate.py` 19, `arbiter.py` 14, `circulation.py` 10,
|
||||
`maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, `store.py` 3.
|
||||
* **The hand-written module list would have been wrong in BOTH directions.**
|
||||
It named `watchdog.py`, which reduces over no roster at all — SAFETY-30's
|
||||
sign watchdogs are strictly per-unit — and omitted `maintenance.py` and
|
||||
`store.py`, which both do. A vacuous claim about one module and no claim
|
||||
about two others.
|
||||
* **The pin rediscovers all four blockers without being told where to look,**
|
||||
and finds a fifth beside them (`validate.py:783`, rule 5's message) that no
|
||||
review round named. Its counterfactual runs at two depths: every enumerated
|
||||
site's own expression mutated with each of the four cuts this card has been
|
||||
defeated by (732 spellings, all reported), and `[:1]` written into
|
||||
`app/validate.py` at each blocker position with the **whole** analysis
|
||||
re-run over the mutated source — a sliced site required at that exact line,
|
||||
where the clean package reports none.
|
||||
* **And the behavioural half, because a shape pin grades an identity and never
|
||||
a meaning.** `test_validate.py` grows a **roster SET** for the three rules
|
||||
that reduce over `unit_caps_indexed()` — widths 2, 3, 4, 5 with the one
|
||||
small cap at every position, 42 runs of the real validator — plus a
|
||||
**computed family table** of twelve candidate reductions each compared with
|
||||
what the shipped validator actually said on the same roster. Only `min`
|
||||
agrees everywhere; a member no roster kills is printed as a named survivor.
|
||||
* ⚠️ **A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's** —
|
||||
found by reading, **not** by the pin, which is the shape of what the pin
|
||||
cannot see. `unit_sign_disagree_w` was graded against
|
||||
`_number(opts, "max_w", 2000.0)` three lines under a branch that had already
|
||||
stopped doing that. **Both directions were measured:** two 2000 W units
|
||||
beside a leftover `max_w: 5000` and `unit_sign_disagree_w: 3000` produced
|
||||
**zero problems** — the site started with a per-unit watchdog that could not
|
||||
trip on either unit — and two 5000 W units with no flat key were **refused**
|
||||
against a 2000 W default the operator never wrote. It takes the weakest
|
||||
unit's own cap now and names that unit; at a roster of one it is the same
|
||||
comparison, boundary and sentence it always was.
|
||||
* ⚠️ **What the pin cannot see, with sizes**, in
|
||||
`DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d: semantics (`min` vs `max`
|
||||
is a zero-byte edit); direct index sites that are not reductions; a
|
||||
subscript by a *variable* (`weights[u]` is why); a slice through an
|
||||
intermediate call; anything outside `app/`, including `sim/inject.py`; and
|
||||
the taint's own seam at `*args` and unresolvable receivers, where everything
|
||||
downstream goes **unenumerated rather than reported unreachable**.
|
||||
`cycle()`'s undeliverable-unit log line is still green at +4 bytes and still
|
||||
filed open, unchanged from round 4.
|
||||
* **`tools/supervisor_shape_probe.py` re-run again (2026-09-05, 2026.08.0).**
|
||||
The rebase onto `67e649a` merged SPIKE-04a's `max_w: 4600` into the same
|
||||
§C/§D blocks this card had rewritten, and a transcript stitched by hand out
|
||||
of two runs is not a measurement. `all cases passed`, exit 0, with B's
|
||||
rating reading 4600 — its **datasheet** figure, still not a measurement of
|
||||
any unit.
|
||||
* ⚠️ **THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE.** All 183
|
||||
sites mutated to `[:1]`, one at a time, against the full 19-file suite in
|
||||
`goodwe-addon-ci` (`:ro`, `-B`, fresh rsync per mutant, 0 `.pyc` before and
|
||||
after): **183/183 RED** — which is tautological on its own, because the pin
|
||||
makes every slice red. The 51 whose first red was `test_peakguard.py` were
|
||||
re-run with the pin **neutralised**: **145 of 183 have a behavioural fixture
|
||||
that separates them from a slice of themselves; 38 have only the structural
|
||||
claim.** All 38 are listed by site, with what a `[:1]` there would cost, in
|
||||
§4d and in `test_peakguard.py` part 12j — 15 of them are rows and counts on
|
||||
the operator's page, 3 are `len(targets)` arithmetic beside a write path
|
||||
that IS graded, 6 are allocator internals ALLOC-01's fixtures reach but not
|
||||
with enough units to separate. **None of the 38 is closed behaviourally by
|
||||
this card, and none is counted as covered.**
|
||||
|
||||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||||
exist yet. Every second-pack figure — in fixtures, in the probe, in
|
||||
config.yaml's commented example — is **simulated and labelled**; only inverter
|
||||
A's 10 % is read off hardware.
|
||||
|
||||
**At two inverters the sign watchdog stops comparing one unit against the whole
|
||||
site meter, and becomes two checks instead of one (SAFETY-30).**
|
||||
`disagrees(commanded_w, grid_w, ...)` compared the command **this controller put
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
# SAFETY-32 — `bms_soc_floor` per pack, and the two judgement calls it left open
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-32`.
|
||||
**Card:** SAFETY-32 — *"One `bms_soc_floor` for two packs grades the shallower
|
||||
pack against the deeper one's cut-out"*, scoped by the card's own PM PROOFREAD
|
||||
to its **third** bullet only. Bullets 1 (aggregate anti-windup) and 2 (per-instance
|
||||
`achievable_w`) were verified as already shipped by DEV-02 and CFG-04 and were
|
||||
**not** touched — `main.py`'s `actual_w=self.site_batt_w` comment is the record
|
||||
of why the first of those must stay site-level, and it still reads as written.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, stated in the terms the code uses
|
||||
|
||||
`bms_soc_floor` **describes a battery**: it is the percent that pack's own BMS
|
||||
cuts out at, register `0x0560` on the reference GW5000S-BP, read as 10. It was a
|
||||
flat option because there was one pack.
|
||||
|
||||
`peak_soc_floor` **describes the site's policy**: the depth a money-saving
|
||||
discharge may never reach. Rule 8 refuses `peak_soc_floor <= bms_soc_floor`,
|
||||
because such a floor can never bind — the BMS wins and the status page reports a
|
||||
backstop that does not exist.
|
||||
|
||||
At two packs of different models the two cut-outs differ, and one number for both
|
||||
is wrong **in the direction that empties a pack**: with cut-outs of 10 % and 30 %
|
||||
and `peak_soc_floor: 20`, the add-on **started**, and the guard would command a
|
||||
forced discharge that pack B's *BMS* — not the operator's floor — is the thing
|
||||
that stops. That is rule 8's own failure mode, reached through rule 8.
|
||||
|
||||
---
|
||||
|
||||
## 2. DECISION 1 — required per entry, not defaulted from `PEAK_OPTION_DEFAULTS`
|
||||
|
||||
The card left this open and asked for the reason to be recorded.
|
||||
|
||||
**Decided: REQUIRED of every roster entry**, in `config.yaml`'s schema
|
||||
(`bms_soc_floor: int(0,50)`, no trailing `?`) and in `validate.py` rule 8, which
|
||||
refuses a missing one **by name**.
|
||||
|
||||
The reason is that a default here is not a convenience, it is **this card's own
|
||||
defect wearing a different hat**. A default is one number applied to every pack,
|
||||
which is exactly what the flat key was. An add-on that answers "10 %" for a pack
|
||||
nobody has told it about has invented a property of somebody's battery, and the
|
||||
failure mode of the invention is the one this card exists to remove — the
|
||||
shallower pack protected by a floor that belongs to the deeper one. CFG-03's
|
||||
rule ("defaults live in exactly one place") points the same way: the one mirror
|
||||
`PEAK_OPTION_DEFAULTS["bms_soc_floor"]` is the default for the **flat** key, i.e.
|
||||
for the single-inverter shape, and reusing it per entry would make one dict the
|
||||
default in two shapes with two meanings.
|
||||
|
||||
It is also the treatment `max_w` already gets, for the same reason and by the
|
||||
same argument (`inverter.py`'s `build_roster()` docstring): the per-unit numbers
|
||||
that describe hardware are required, and the per-unit *sensors* are optional
|
||||
because an absent sensor is a real state.
|
||||
|
||||
**The cost is stated rather than hidden.** This is a schema change: a roster
|
||||
config written before this branch is refused by the Supervisor until each entry
|
||||
gains the key, and every roster fixture in the suite needed it. Nothing in
|
||||
production carries a roster today — `inverters:` ships `[]` and the second
|
||||
inverter is ~8 days out — so the migration is paid by fixtures and by whoever
|
||||
writes the first real roster, at a startup refusal that names the entry.
|
||||
|
||||
**What is NOT inherited.** An entry that omits the key does not borrow the flat
|
||||
one. `Inverter.bms_soc_floor` is then `None`, `peakguard.pack_floor_pct()`
|
||||
answers `None`, and `discharge_permitted()` already treats an unknown floor as
|
||||
**no permission** — so even in the window before rule 8 runs (e.g. `sim/inject.py`,
|
||||
which does not run the startup gate) the absence costs a shave and never a pack.
|
||||
`0.0` was rejected explicitly: it is the most permissive value the key has.
|
||||
|
||||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||||
|
||||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||||
floors, each of them `max(peak_soc_floor, that pack's own cut-out)`, with a
|
||||
`max()`. The two reductions pair: a minimum SoC against a maximum floor is the
|
||||
conservative combination, and both move the verdict towards refusing a shave and
|
||||
away from spending a pack. `min(soc) <= max(floor)` is implied by "some pack is
|
||||
at or below its own floor", so the site verdict is never more permissive than
|
||||
grading each pack separately would be. The converse is not true and is not
|
||||
claimed — a site can be refused while every pack individually has headroom, which
|
||||
costs a shave.
|
||||
|
||||
### 3a. ⚠️ FIX ROUND — the reduction was written and argued, and NOT GRADED
|
||||
|
||||
PR #48 was rejected because the argument in §4 below justified *writing* the
|
||||
reduction and was then not carried into a check. Neither of the two mutations
|
||||
below appears anywhere in §6's twelve-row battery: it graded *"the reduction
|
||||
dropped"* and *"the bare option handed over"*, and never asked what happens when
|
||||
the reduction is the **wrong one**, or when the roster handed to it is
|
||||
**sliced**. Both were applied to `app/` by the reviewer and **all 19 files
|
||||
stayed green**:
|
||||
|
||||
| mutation | Δ | why it survived |
|
||||
|---|---|---|
|
||||
| `binding_soc_floor()`: `max(floors)` → `min(floors)` | **0 bytes** | every roster in the suite was rule-8-valid or one pack long, so `floors` held **identical numbers** and `max`/`min`/first/last were four spellings of one answer |
|
||||
| the `decide()` call site: `self.packs` → `self.packs[:1]` | **+4 bytes** | part 12g asserted `"self.packs" in <the unparsed expression>` — and `self.packs[:1]` **contains that substring** |
|
||||
|
||||
The zero-byte one was proved to have actually run rather than assumed:
|
||||
`dis.get_instructions` showed the `LOAD_GLOBAL`s as `[float, pack_floor_pct,
|
||||
any, min]`, and `binding_soc_floor([Pack("a",50,10), Pack("b",50,35)], 20.0)`
|
||||
returned `20.0` where the shipped code returns `35.0`.
|
||||
|
||||
**The common cause is this repository's recurring defect** — a check whose DATA
|
||||
cannot reach the failure it names. The one check that used a cut-out **above**
|
||||
the EMS floor (12c's `_n1_deep`) ran on a roster of **ONE**, where every possible
|
||||
reduction agrees. So no check in the suite ever evaluated
|
||||
`binding_soc_floor()` on two packs whose floors **differ**.
|
||||
|
||||
**And the rule-8 degeneracy is not a defence, by §4's own words.** If
|
||||
"unreachable is a claim about today's rule ordering, not a property of this
|
||||
function, and `validate_startup_config()` is also `sim/inject.py`'s entry point"
|
||||
is enough to *write* the reduction, it is enough to *grade* it. Through
|
||||
`sim/inject.py`, which does not run rule 8 at all, `min(floors)` lets the guard
|
||||
command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %.
|
||||
|
||||
**What was added — no shipped behaviour changed, only what observes it:**
|
||||
|
||||
* **`test_peakguard.py` part 12c2** evaluates `binding_soc_floor()` on a roster
|
||||
whose per-pack floors **all differ**. Four **simulated** cut-outs —
|
||||
`30, 25, 40, 35` %, every one strictly above `peak_soc_floor: 20`, so
|
||||
`pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in
|
||||
that order. The four candidate reductions are then four different numbers:
|
||||
|
||||
max = 40 <- the shipped answer
|
||||
min = 25
|
||||
first = 30 <- "grade the site against pack A", inside the function
|
||||
last = 35
|
||||
mean = 32.5
|
||||
|
||||
The extremes sit at **interior** roster positions (index 2 is the max, index 1
|
||||
the min). A two-pack roster structurally cannot separate these — there `max`
|
||||
and `last` coincide and so do `min` and `first` — and neither can three. Four
|
||||
is the smallest roster that separates all four. The precondition is its own
|
||||
check (habit 1), one pack is then raised past the old maximum (habit 7: the
|
||||
**last** pack, which was neither extreme, so the line can only move if the
|
||||
reduction read it), and `peak_soc_floor` is lifted above every cut-out to grade
|
||||
the other side of the conjunction.
|
||||
|
||||
* **Part 12g is behaviour now, not source text** (MUTATION.md habit 4). Real
|
||||
`Controller.cycle()`s on a two-pack roster, both packs held at the same SoC and
|
||||
everything else identical, with the packs' own cut-outs as the only thing
|
||||
varied:
|
||||
|
||||
cut-outs A 10 / B 18 % -> decide() got soc_floor=20 %, floor_limited=False
|
||||
cut-outs A 10 / B 35 % -> decide() got soc_floor=35 %, floor_limited=True
|
||||
|
||||
The observable is `PeakDecision.soc_floor` — the argument, echoed back by the
|
||||
shipped `decide()` — so the whole path from `Controller.inverters` to the
|
||||
guard's argument is under test, and the site's **verdict flips** as well as the
|
||||
number. `self.packs[:1]` answers 20 % both times. **Which** option key the floor
|
||||
comes from stays pinned on the source in part 4, because that claim genuinely
|
||||
is about the code's shape; that the option still governs is now graded
|
||||
behaviourally too.
|
||||
|
||||
* ⚠️ **A THIRD SURVIVOR, FOUND WHILE FIXING THE FIRST TWO, AND RECORDED RATHER
|
||||
THAN QUIETLY REPAIRED.** The first cut of part 12g varied only **pack B's**
|
||||
cut-out — so `self.packs[-1:]`, grade the site against the **last** pack,
|
||||
agreed with the full roster on both runs and stayed green across all 19 files
|
||||
(+5 bytes). A two-run fixture that only ever moves the second pack pins `[:1]`
|
||||
and not `[-1:]`; it is the same defect one step along, and it was in the fix
|
||||
for this defect. Part 12g now runs the **same pair of cut-outs in both
|
||||
orders**:
|
||||
|
||||
cut-outs A 10 / B 18 % -> soc_floor=20 %, floor_limited=False
|
||||
cut-outs A 10 / B 35 % -> soc_floor=35 %, floor_limited=True
|
||||
...the SAME PAIR SWAPPED, A 35 / B 18 % -> soc_floor=35 %, floor_limited=True
|
||||
|
||||
`[:1]` answers 20 % on the second run, `[-1:]` answers 20 % on the third, and
|
||||
the roster answers 35 % on both. No single-position slice passes all three.
|
||||
This is the mirror `12h` already ran on the SoC axis, which is where the shape
|
||||
was borrowed from.
|
||||
|
||||
* ⚠️ **AND A FOURTH, from the same battery: the EMPTY-roster branch.** Rewriting
|
||||
`binding_soc_floor([])`'s `float(soc_floor)` to `return None` (−43 bytes) was
|
||||
green across all 19 files. It is defensive code — `Controller.inverters` is
|
||||
never empty on a started controller, and `inputs_ok` refuses the cycle before
|
||||
the guard is reached if it were — so nothing in the suite went near it. It is
|
||||
**pinned rather than argued**, in both directions (the option when there is
|
||||
one, `None` when there is not), because a branch with no check is a branch
|
||||
that can be rewritten under a green suite. The check also records why it
|
||||
cannot permit anything either way: `pack_floor_soc` is `None` at an empty
|
||||
roster, and `discharge_permitted()` refuses on a `None` SoC whatever the floor
|
||||
is.
|
||||
|
||||
* **`wired_over_limit()` gained a `prepare(ctl)` hook**, inside its retry loop so
|
||||
every attempt grades the same site, which is the only window in which a second
|
||||
inverter can join the roster before the telegrams are fed.
|
||||
|
||||
* **In-process mutation rows 22, 22b and 23** record all four survivors, so a
|
||||
later edit that re-degenerates any of these fixtures goes red on a row rather
|
||||
than on nothing.
|
||||
|
||||
### 3b. ⚠️ SECOND FIX ROUND — the fix carried the defect it was written to close
|
||||
|
||||
Round 2 closed both named survivors, and both of round 3's blockers are the
|
||||
**same defect sitting inside that fix**: a check whose TEXT claims more than its
|
||||
FIXTURE can observe. Applied to `app/` on `8501e05`, **all 19 files stayed
|
||||
green** for both:
|
||||
|
||||
| mutation | Δ | the sentence that was too broad | why the fixture could not see it |
|
||||
|---|---|---|---|
|
||||
| `binding_soc_floor()`: `max(floors)` → `max(floors[-2:])` | **+5 bytes** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack (`_deeper[-1]`), and **every window that ends at the roster's end follows the last pack**. On `(30,25,40,35)` both answer 40; on `_deeper` both answer 45. |
|
||||
| the `decide()` call site: `self.packs` → `self.packs[:2]` | **+4 bytes** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `self.packs[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs being unable to separate max/min/first/last. |
|
||||
|
||||
Round 2's *report* stated the narrow, true claim ("no **single-position** slice
|
||||
passes all three runs"). The *check in the file* stated the broad one. That gap
|
||||
is the whole of round 3.
|
||||
|
||||
**THE GENERAL LAW, stated once so the next card can use it:**
|
||||
|
||||
> **A fixture of N packs cannot separate two reductions that agree on N packs.**
|
||||
> Two cannot separate max from min from first from last. Three cannot separate
|
||||
> max from max-of-the-last-two. A single counterfactual that varies one position
|
||||
> cannot separate the reduction from any window containing that position.
|
||||
|
||||
Its corollary is the reason this section is long: **the regress does not
|
||||
terminate in a fixture.** Closing `[:1]` took two packs; `[:2]` took three;
|
||||
`[:3]` takes four — and a four-pack run hands `[:4]` the same free pass. Round 3
|
||||
therefore does two different things rather than adding one more pack.
|
||||
|
||||
**What was added — no shipped behaviour changed, only what observes it:**
|
||||
|
||||
* **Part 12c2 gained a ROSTER SET and a computed FAMILY TABLE.** The same four
|
||||
simulated cut-outs with the **deepest moved to each of the four positions**,
|
||||
and fourteen enumerated rewrites of `max(floors)` each checked against every
|
||||
roster. The table is **computed and printed**, and a member with an empty kill
|
||||
list takes the check **red naming it** — so a family member can no longer be
|
||||
added without a fixture that reaches it. `max(floors[-2:])` is killed only by
|
||||
the rosters whose deepest sits at index 0 or 1, which is precisely the data the
|
||||
single last-pack counterfactual could not supply.
|
||||
|
||||
* **Part 12c2 gained a LENGTH SWEEP.** `binding_soc_floor()` is a pure function
|
||||
over a list, which is the one place a sweep is cheap: **78 simulated rosters,
|
||||
lengths 1 through 12, the deepest cut-out at every position of each.** That
|
||||
closes *every* head window `max(floors[:w])` and *every* tail window
|
||||
`max(floors[-w:])` of width **1 to 11** in one property rather than one
|
||||
mutation at a time — and the check asserts that width **12 is NOT reached**, so
|
||||
the limit is a red line rather than a silence.
|
||||
|
||||
* **Part 12g drives THREE-pack rosters**, with the deep cut-out at index 0, 1
|
||||
**and** 2. Every positional slice of a three-pack roster is a proper subset of
|
||||
`{0,1,2}` and so drops a position; the run whose deep pack sits there answers
|
||||
the option, 20 %, where the roster answers 35 %. `self.packs[:2]` goes red on
|
||||
the index-2 run. `_floor_reaching_decide()` now takes `*floors`, and the extra
|
||||
units join through the same `second_inverter()` hook the two-pack runs use.
|
||||
|
||||
* **Part 12g gained a POSITIONAL-SLICE TABLE**, computed against the **real**
|
||||
`Controller.packs` each of the six cycles actually ran and the **real**
|
||||
`PeakDecision.soc_floor` it produced — so it measures the shipped call site's
|
||||
own answer, not a re-derivation, and a survivor takes the check red.
|
||||
|
||||
* ⚠️ **AND A FIFTH AND SIXTH SURVIVOR, FOUND BY ROUND 3'S OWN BATTERY AND
|
||||
CLOSED STRUCTURALLY RATHER THAN BY ONE MORE PACK.** `self.packs[:3]` (+4) and
|
||||
`self.packs[-3:]` (+5) survive **all six** behavioural runs, because the
|
||||
longest roster those runs drive is three packs long. Part 12g therefore also
|
||||
pins the roster **argument's identity** on the AST: it must unparse to exactly
|
||||
`self.packs`, which is false of every slice at every length. ⚠️ This is **not**
|
||||
the substring test round 2 removed — `"self.packs" in expr` is *true* of every
|
||||
slice; `ast.unparse(arg) == "self.packs"` is *false* of all of them. The two
|
||||
halves are separated and each says what it reaches: the **runs** grade the
|
||||
VALUE and reach every slice up to N=3; the **pin** grades the ARGUMENT'S
|
||||
IDENTITY at any N and is blind to two things the runs are not — a rewrite of
|
||||
what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches
|
||||
that) and the reduction *inside* `binding_soc_floor()` (12c2's).
|
||||
|
||||
* ⚠️ **AND ONE ROW IS RECORDED AS NOT CAUGHT.** A reduction over a window
|
||||
**twelve packs wide** is indistinguishable from `max(floors)` on every roster
|
||||
in the file, because the sweep stops at twelve. Unlike the call site there is
|
||||
no identity claim to pin — the claim is semantic, "the maximum", and a shape
|
||||
pin would refuse honest refactors of the expression. It is reported **with its
|
||||
size** (it needs a 13-pack roster, on a site whose roster is ONE and whose
|
||||
second unit is ~8 days out) rather than hidden.
|
||||
|
||||
* **Two rewrites are EQUIVALENT, not uncaught.** `max(set(floors))` and
|
||||
`list(reversed(self.packs))` cannot change any answer — `max` is order- and
|
||||
duplicate-invariant. The second is nonetheless red on the structural pin.
|
||||
|
||||
* **In-process mutation rows 24 – 27** record all four of round 3's findings,
|
||||
including the not-caught one, so a later edit that re-degenerates any of these
|
||||
fixtures goes red on a row rather than on nothing.
|
||||
|
||||
## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists
|
||||
|
||||
**Under any configuration rule 8 accepts, `binding_soc_floor()` answers
|
||||
`peak_soc_floor` for every pack.** Rule 8 refuses `peak_soc_floor <= bms_soc_floor`
|
||||
on *every* unit, so `max(peak_soc_floor, bms_i) == peak_soc_floor` everywhere the
|
||||
add-on will actually start. The per-pack **floor** is therefore non-degenerate
|
||||
only against a config rule 8 refuses.
|
||||
|
||||
This is not a defect and it is not hidden. It is the same argument
|
||||
`validate.unit_caps_indexed()` makes about itself, in that function's own words:
|
||||
|
||||
> *"Rule 9(d) already refuses such a config so the two lists cannot desynchronise
|
||||
> in shipped use — but 'unreachable' is a claim about today's rule ordering, not
|
||||
> a property of this function, and `validate_startup_config()` is also
|
||||
> `sim/inject.py`'s entry point."*
|
||||
|
||||
CFG-04's index desynchronisation is what happened the last time that reasoning
|
||||
was left implicit. So the reduction is written, and it is graded on both sides:
|
||||
`test_peakguard.py` part 12c sweeps **1775** rule-8-valid `(peak_soc_floor,
|
||||
bms_soc_floor)` pairs and asserts the answer is the option in every one of them
|
||||
(that is AC 5, measured over the whole option space rather than at the shipped
|
||||
point), and then asserts that on the pair rule 8 *refuses* the answer is the
|
||||
**cut-out** — because without that second line the sweep would be satisfied by
|
||||
`soc_floor` typed straight through, and the mutation that deletes the `max()`
|
||||
could never bite. It does bite: M2 on the PR.
|
||||
|
||||
**What IS live at a rule-8-valid roster** is the rest of the card: rule 8 now
|
||||
refuses the two-model config that used to start; the permission is answered per
|
||||
pack, so the alarm names the pack that stopped the site rather than "the
|
||||
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
|
||||
|
||||
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
|
||||
|
||||
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
|
||||
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
|
||||
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
|
||||
**sibling argument of the same call**:
|
||||
|
||||
```python
|
||||
self.peak_guard = peakguard.decide(
|
||||
...,
|
||||
soc=self.pack_floor_soc, # MINIMUM <- round 4
|
||||
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
|
||||
```
|
||||
|
||||
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
|
||||
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
|
||||
its weaker half. On `52380c0`, with all 19 files green and verified again after
|
||||
the rebase onto `3934ad7`:
|
||||
|
||||
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|
||||
|---|---|---|---|
|
||||
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
|
||||
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
|
||||
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
|
||||
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
|
||||
|
||||
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
|
||||
precisely the state the floor axis was in before round 3, and which round 3
|
||||
itself named as its blocker 2. A fixture of N packs cannot separate reductions
|
||||
that agree on N packs.
|
||||
|
||||
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
|
||||
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. The
|
||||
site is then graded for discharge *permission* against a pack 39 points fuller
|
||||
than the emptiest, and spends a pack its own BMS stops — through
|
||||
`sim/inject.py`, which runs no validator rule at all. The verdict flips with it:
|
||||
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
|
||||
no forced discharge, where the mutant is permitted and sizes a positive one.
|
||||
|
||||
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
|
||||
|
||||
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
|
||||
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
|
||||
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
|
||||
one is measured*, off register `0x0560` on the reference GW5000S-BP.
|
||||
|
||||
So **production is degenerate on the floor axis**: both packs floor at the same
|
||||
value, `max(10, 10) == 10`, and a differing floor exists **only in the
|
||||
fixtures**. That is not an argument for simplifying the fixtures to matching
|
||||
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
|
||||
is observable *nowhere else*, and the moment a third pack, a replacement pack or
|
||||
a different chemistry arrives it is observable in the field. **Anyone who
|
||||
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
|
||||
data this repository has that can see the reduction at all**; part 12c2's
|
||||
precondition check goes red if they do, and this paragraph is why.
|
||||
|
||||
The **SoC axis has no such degeneracy**. Two batteries at the same state of
|
||||
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
|
||||
the pair that will be non-degenerate in the field from the day inverter B is
|
||||
wired up.
|
||||
|
||||
### The treatment, transferred from round 3
|
||||
|
||||
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
|
||||
every position of a three-pack roster and both positions of a two-pack one,
|
||||
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
|
||||
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
|
||||
cannot be what moved the answers.
|
||||
* **the positional-slice table and the non-slice table** — computed against the
|
||||
real `Controller.inverters` each cycle carried and the real decision it
|
||||
produced, printed, with a member that nothing kills taking the check red.
|
||||
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
|
||||
the empty pack at every position. Closes head and tail windows of width 1-11
|
||||
at once. Width 12 and wider is **honestly not reached**, and asserted to be
|
||||
unreached so a later sweep that lengthens the rosters must rewrite the
|
||||
sentence.
|
||||
* **the exact structural pin** — three of them, because there are three
|
||||
reductions over roster length in this one property: `vals` is built by one
|
||||
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
|
||||
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
|
||||
of either is red at **every** roster length, including the `[:3]` and `[:12]`
|
||||
the runs and the sweep cannot see.
|
||||
|
||||
Unlike the floor axis, **there is no uncaught row here**: the claim at
|
||||
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
|
||||
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
|
||||
and a shape pin there would refuse honest refactors. That asymmetry is why one
|
||||
carries an open row and the other does not.
|
||||
|
||||
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
|
||||
|
||||
This card has now been rejected three times for the same shape one step over, so
|
||||
round 4 stopped closing members and enumerated the **family**. Every reduction
|
||||
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||||
mutated to a slice, one at a time, against the full 19-file suite.
|
||||
|
||||
| # | site | reduction | consumer | mutation | result |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
|
||||
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
|
||||
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
|
||||
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
|
||||
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
|
||||
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
|
||||
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
|
||||
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
|
||||
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
|
||||
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
|
||||
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
|
||||
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
|
||||
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
|
||||
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||||
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||||
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
|
||||
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
|
||||
|
||||
**Rows 18-20 are the finding, and they are not this card's code.** All three are
|
||||
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
|
||||
all three survived a green 19-file suite for exactly the reason every blocker on
|
||||
this card survived: *the only fixture that reached them was a roster of ONE,
|
||||
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
|
||||
built a two-inverter roster with only inverter B latched since SAFETY-30
|
||||
shipped — the precise data all three needed — and none of them read it.
|
||||
|
||||
Row 18 is the one with teeth. A latched unit must take the whole roster past the
|
||||
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
|
||||
main.py's own comment says *"the trip would stop acting the moment a poll was
|
||||
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
|
||||
SoC returns early, and B's last share stands for the heartbeat to re-emit.
|
||||
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
|
||||
the no-latch counterfactual that proves the hold really fires.
|
||||
|
||||
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
|
||||
is now red on 12j's enumeration, but I could not construct a *reachable* state in
|
||||
which it changes an answer: `_grade_store_migration()` runs at startup, before
|
||||
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
|
||||
key was truthy — which makes `namespaced_latched` True and short-circuits the
|
||||
disjunct. I believe it is an equivalent mutant at that call site rather than a
|
||||
live hole, and I am recording the argument instead of asserting the conclusion.
|
||||
|
||||
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
|
||||
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
|
||||
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
|
||||
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
|
||||
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
|
||||
stops; what a `[:1]` costs is an operator being told one name when two units have
|
||||
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
|
||||
12j does not cover it and does not pretend to. Closing it needs a three-inverter
|
||||
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
|
||||
card's. **Filed as open, with its size and its consequence, rather than closed
|
||||
quietly or left unmentioned.**
|
||||
|
||||
### And the family closed rather than the member
|
||||
|
||||
`test_peakguard.py` part 12j enumerates the family from the AST and requires
|
||||
every member to take the roster **whole**, so a new reduction over a slice is red
|
||||
**on the line it is written**, at every roster length, with no fixture required.
|
||||
That check is what would have caught rounds 2, 3 and 4's blockers on the round
|
||||
they were written instead of one review round each; it is also what found rows
|
||||
18-21.
|
||||
|
||||
⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the
|
||||
26 roster comprehensions inside the `Controller` class, because that is where
|
||||
the four known sites were. Round 5's blockers were four reductions in
|
||||
`validate.py`, one module over, over a list *derived* from the roster three
|
||||
calls away from it. The pin now parses every `.py` under `app/` and finds the
|
||||
roster by transitive closure from one option key: **183 reductions across 9
|
||||
modules.**
|
||||
|
||||
## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope
|
||||
|
||||
Round 4 enumerated the family and closed it **inside the `Controller` class**,
|
||||
because that is where the four known sites lived. The review found four more
|
||||
survivors immediately, in `validate.py`, on a 19/19 green suite:
|
||||
|
||||
| site | mutation | delta | suite |
|
||||
|---|---|---|---|
|
||||
| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** |
|
||||
| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** |
|
||||
| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** |
|
||||
| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** |
|
||||
|
||||
**None of them is an equivalent mutant — a safety refusal disappears.** With
|
||||
`max_w: 5000` on inverter-a beside `2000` on inverter-b and
|
||||
`sign_disagree_w: 3000`, the shipped validator answers one problem —
|
||||
*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02
|
||||
sign-disagreement watchdog can never fire"* — and every one of the four answers
|
||||
**zero**. The site starts, with a watchdog reporting `armed` on the status page
|
||||
that cannot trip on pack B. That is rule 5's own failure mode reached **through**
|
||||
rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to
|
||||
prevent. Rule 6 loses its refusal the same way.
|
||||
|
||||
### Why round 4's pin could not see them, and why the fix is not four more checks
|
||||
|
||||
`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists
|
||||
**derived** from the roster, three function calls away from it, in a module the
|
||||
enumeration never opened. Closing them by hand would have produced a fifth
|
||||
rejection with the same shape one step over — that is the whole record of this
|
||||
card. **A hand-written scope is the same defect as a hand-written fixture**
|
||||
(MUTATION.md habit 12): a check whose data cannot reach the failure it names.
|
||||
|
||||
### The instrument
|
||||
|
||||
`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk —
|
||||
no module is named anywhere in the check — and finds the roster by transitive
|
||||
closure from **one seed**, the `inverters` option key:
|
||||
|
||||
* a function whose returns are roster-carrying is **roster-producing**
|
||||
(`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`);
|
||||
* a name assigned one carries it, **and so does a list accumulated in a loop
|
||||
over one** — which is exactly what `caps`, `roster` and `problems` are, and
|
||||
what round 4's rule could not express;
|
||||
* an attribute or property answering one carries it (`self.inverters`,
|
||||
`Controller.packs`, `Controller.units`, `live_units()`);
|
||||
* **an argument at a call site taints that parameter in the callee**, which is
|
||||
how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`,
|
||||
`allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and
|
||||
`store.migrate_maint_keys` without being told they exist;
|
||||
* and **a slice of a roster is still a roster**, so a mutation cannot cut the
|
||||
taint and hide every reduction downstream of itself. An **index** is one unit
|
||||
and is not.
|
||||
|
||||
It then requires, at every place a roster-carrying value is reduced or iterated,
|
||||
that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the
|
||||
roster and the reduction.
|
||||
|
||||
### What it found — and the hand-written list was wrong in BOTH directions
|
||||
|
||||
**183 reductions across 9 modules**, out of 23 `.py` files parsed:
|
||||
`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14,
|
||||
`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3,
|
||||
`store.py` 3 — and 14 modules with no roster reduction at all.
|
||||
|
||||
The card's own suggested module list named `watchdog.py`, which reduces over no
|
||||
roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is
|
||||
nothing to slice), and did **not** name `maintenance.py` or `store.py`, which
|
||||
both do. A pin built from that list would have made a vacuous claim about one
|
||||
module and no claim at all about two others. That is the argument for computing
|
||||
the scope, made with data rather than with an assertion.
|
||||
|
||||
The pin **independently rediscovers all four blockers** — it is told one option
|
||||
key and nothing else — and finds a **fifth** beside them at
|
||||
`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no
|
||||
review round named.
|
||||
|
||||
### The counterfactual, at two depths
|
||||
|
||||
* **Per site, all of them.** Every enumerated site's own expression, in its own
|
||||
scope, mutated with each of the four cuts this card has actually been defeated
|
||||
by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts
|
||||
the predicate **accepts** the legitimate wrapped forms the package really uses
|
||||
(`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not
|
||||
treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is
|
||||
what four false SLICED reports cost before `shortens()` was written.
|
||||
* **End to end, on the real files.** `[:1]` written into `app/validate.py` at
|
||||
each blocker position, the **whole** analysis re-run over the mutated source,
|
||||
and a sliced site required at that exact line — where the clean package
|
||||
reports none. This is the check that would have taken PR #48 red four rounds
|
||||
ago, with no fixture reaching it.
|
||||
|
||||
### The sweep, and the number that must not be quoted alone
|
||||
|
||||
All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each
|
||||
mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`,
|
||||
`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after —
|
||||
a read-only mount cannot write one, so TEST-15 is closed by construction here
|
||||
rather than by discipline). **183 of 183 RED.**
|
||||
|
||||
⚠️ **On its own that is tautological**: with the pin in the tree every slice is
|
||||
red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were
|
||||
re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the
|
||||
behavioural checks:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off |
|
||||
| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered |
|
||||
|
||||
The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1,
|
||||
`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs:
|
||||
|
||||
* **The operator's page — 15, the largest group.** `Controller.checks`
|
||||
(3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and
|
||||
`status_payload` (3556, 3566, 3578). A slice reports one unit's row where two
|
||||
exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is
|
||||
lost is the evidence a human uses to notice that it should.
|
||||
* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three
|
||||
`len(targets)` guards. The write itself iterates `targets` at 1287 and *is*
|
||||
graded (test_inverter.py); these three are its log arithmetic and refusal
|
||||
count.
|
||||
* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365),
|
||||
`plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01
|
||||
drives two and three units through the *weighted* pass; the last-resort pass
|
||||
and the largest-remainder tie-break are reached, but not with enough units for
|
||||
a slice to change the answer.
|
||||
* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831,
|
||||
`_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015,
|
||||
`check_output_clamp` 2445/2452, `check_circulating` 2701,
|
||||
`maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
|
||||
`circulation.opposing` 242.
|
||||
* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554)
|
||||
and `refuse()`'s numbering loop (1263) — both shape the words of a refusal
|
||||
that still happens.
|
||||
* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be
|
||||
an equivalent mutant at that call site. The argument is unchanged and is still
|
||||
an argument, not a proof.
|
||||
|
||||
⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the
|
||||
point.** Closing sites one at a time is what cost this card four review rounds.
|
||||
The pin closes the **family** — a slice at any of them is red on the line it is
|
||||
written — and this list is what a shape pin can never promise.
|
||||
|
||||
### And the behavioural half, because a shape pin cannot grade a meaning
|
||||
|
||||
The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a
|
||||
zero-byte edit and invisible to it, forever. So `test_validate.py` grows a
|
||||
**roster SET** for the three rules that reduce over `unit_caps_indexed()`:
|
||||
rosters of width 2, 3, 4 and 5 with the one small cap at **every position** —
|
||||
14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family
|
||||
table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and
|
||||
`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against
|
||||
what the shipped validator actually said on the same roster. Only `min` agrees
|
||||
everywhere; any member no roster kills is **printed as a named survivor** and
|
||||
takes the check red.
|
||||
|
||||
### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
|
||||
|
||||
Found by reading the three branches side by side — **not** by the pin, and said
|
||||
that way because it is the shape of what the pin cannot see. Rule 5's sibling
|
||||
branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was
|
||||
graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three
|
||||
lines under a branch that had already stopped doing that. Both directions were
|
||||
measured before the fix:
|
||||
|
||||
* a roster of two 2000 W units beside a leftover `max_w: 5000` from a
|
||||
single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero
|
||||
problems**. The site started, the status page said `armed`, and the per-unit
|
||||
watchdog could not trip on **either** unit. Identical sentence to the blocker,
|
||||
reached through the branch beside it;
|
||||
* and a roster of two 5000 W units with no flat key was **refused** at 3000 W
|
||||
against a 2000 W *default the operator never wrote*, for a threshold both
|
||||
units can reach.
|
||||
|
||||
It takes the same `weakest` the branch above takes, from the same list, and
|
||||
names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the
|
||||
shipped single-inverter refusal is the same comparison, the same boundary and
|
||||
the same sentence. Both directions are asserted in `test_validate.py`.
|
||||
|
||||
### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes
|
||||
|
||||
1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any
|
||||
shape pin. Only the behavioural sets above can see them, and they only see
|
||||
the rules they drive.
|
||||
2. **Direct index sites that are not reductions.** `self.inverters[0]` in
|
||||
`_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
|
||||
`maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant
|
||||
to be, graded where they live.
|
||||
3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in
|
||||
`allocator._fill` are keyed lookups into a roster-derived mapping and are
|
||||
accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in
|
||||
the family this card has been defeated by; buying it costs four false reports.
|
||||
4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a
|
||||
call result, not a roster reference. Nothing in `app/` is written that way
|
||||
today; if something is, it would not be seen.
|
||||
5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are
|
||||
not parsed — and `sim/inject.py` reaches `validate_startup_config()`.
|
||||
6. **The taint's own seam.** It crosses a call boundary only where the callee is
|
||||
a plain function this package defines and the argument is positional or
|
||||
keyword. A roster handed through `*args`, stored in a dict and read back, or
|
||||
passed to a method on an unresolvable object, is lost at that hop — and
|
||||
everything downstream goes **unenumerated** rather than reported unreachable.
|
||||
That is the shape of the remaining exposure: not zero, and not measurable
|
||||
from inside the check.
|
||||
7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at
|
||||
+4 bytes, unchanged from round 4 and still filed open.
|
||||
|
||||
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
|
||||
|
||||
`peak_soc_floor` is still **one number for the site**, so the card's own
|
||||
"or the deeper pack is needlessly withheld" half is not closed and this record
|
||||
does not pretend it is. With cut-outs of 10 % and 18 %, rule 8 obliges the
|
||||
operator to set the EMS floor above 18, and the 10 % pack is then held above its
|
||||
own need. Closing that means `peak_soc_floor` becoming a per-entry key too, which
|
||||
is a second option migration and a product decision about what the site-wide
|
||||
number then means. The card scoped itself to `bms_soc_floor` and explicitly told
|
||||
this developer not to widen it, so it is filed here as the open question it is
|
||||
rather than smuggled in.
|
||||
|
||||
**Also still not in this card, and named by the shipped code already:** the
|
||||
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
|
||||
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
|
||||
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
|
||||
per-pack bound. Nothing here touched it.
|
||||
|
||||
## 6. What was changed, and one thing that was found on the way
|
||||
|
||||
* `config.yaml` — `bms_soc_floor: int(0,50)` required per `inverters:` entry;
|
||||
the flat key and its default are untouched and are documented as the
|
||||
single-inverter shape's own cut-out.
|
||||
* `app/inverter.py` — `Inverter.bms_soc_floor`; `_entry_bms_floor()` (`None`, not
|
||||
a borrowed number and not `0.0`); `_flat_bms_floor()`.
|
||||
* `app/peakguard.py` — `Pack`, `pack_floor_pct()`, `pack_permissions()`,
|
||||
`binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
|
||||
one place the question is answered.
|
||||
* `app/validate.py` — `unit_bms_floors_indexed()`; rule 8 grades every unit and
|
||||
names it via the **roster** index the value came off.
|
||||
* `app/main.py` — `Controller.packs`; the `decide(soc_floor=…)` reduction; the
|
||||
floor-limited notification names the pack(s) at or below their own floor, at a
|
||||
roster of two or more only.
|
||||
* `tools/supervisor_shape_probe.py` — **re-run, and repaired.** MUTATION.md
|
||||
habit 8: a committed transcript is a check and it decays. Making the key
|
||||
required broke §C outright and, worse, left §D printing **PASS** on a refusal
|
||||
about the *wrong key* — its "no `max_w`" case was answered `Missing option
|
||||
'bms_soc_floor'`. §D is now an isolated counterfactual over a **merged,
|
||||
accepted** baseline with exactly one key varied per case, asserting the key's
|
||||
**name** in the refusal, and it carries a `bms_soc_floor` case in both
|
||||
directions. Re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`:
|
||||
`all cases passed`.
|
||||
* `app/inverter.py`, found by the mutation battery — `_flat_bms_floor()` was
|
||||
written with the default in **two** places (the `.get()` fallback *and* the
|
||||
blank test). Deleting either one survived, because each covered for the other:
|
||||
MUTATION.md's masking section in three lines of option reading. It has one
|
||||
defaulting line now, and M12 takes six checks red.
|
||||
|
||||
* **Round 3 changed no `app/` file at all.** `test_peakguard.py` only:
|
||||
parts 12c2 and 12g. Its battery was **46 mutations across
|
||||
`binding_soc_floor()`'s reduction, the `decide()` call site, `Controller.packs`
|
||||
and `pack_floor_pct()`'s conjunction** — bytecode purged and counted before and
|
||||
after each (23 → 0). **43 red, 3 survivors, all three recorded above and in the
|
||||
in-process rows**: `max(floors[:12])` and `max(floors[-12:])`, which need a
|
||||
13-pack roster to observe and are reported with that size rather than hidden;
|
||||
and `max(set(floors))`, which is **equivalent** — `max` is duplicate-invariant,
|
||||
so it cannot change an answer. `list(reversed(self.packs))` is equivalent too
|
||||
(`max` is order-invariant) and is nonetheless red on the structural pin.
|
||||
|
||||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||||
physically exist. Every second-pack number in a fixture, in the probe and in
|
||||
config.yaml's commented example is **simulated and labelled as such**; only the
|
||||
10 % on inverter A is read off hardware. The three-pack runs added in round 3 are simulated
|
||||
twice over: there is no inverter C at all, and the shallow slots carry inverter
|
||||
A's measured 10 % while the 35 % cut-out that moves along the roster is invented.
|
||||
|
||||
---
|
||||
|
||||
## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0
|
||||
|
||||
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
|
||||
|
||||
```
|
||||
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_shape_probe.py
|
||||
```
|
||||
|
||||
It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a
|
||||
throwaway container. It touches no live Supervisor, POSTs nothing and installs
|
||||
nothing.
|
||||
|
||||
⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged
|
||||
SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten,
|
||||
and MUTATION.md habit 8 says a committed transcript that a document quotes is a
|
||||
check. A hand-merged transcript is not a measurement of anything. The block
|
||||
below is the output of that run, not of the 2026-09-04 one: B's rating reads
|
||||
**4600** — its datasheet figure, still not a measurement of any unit — and every
|
||||
case still passes.
|
||||
|
||||
```
|
||||
== C. AC2: a two-inverter blob an operator would POST ==
|
||||
PASS two inverters
|
||||
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000, "bms_soc_floor": 10}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_so
|
||||
|
||||
== D. what the schema itself refuses, per entry ==
|
||||
PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from
|
||||
[{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, "bms_soc_floor": 10}]
|
||||
PASS an entry with no name is refused, and the refusal NAMES it
|
||||
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no setpoint_entity is refused, and the refusal NAMES it
|
||||
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no max_w is refused, and the refusal NAMES it
|
||||
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no bms_soc_floor (SAFETY-32) is refused, and the refusal NAMES it
|
||||
Missing option 'bms_soc_floor' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with max_w over the range is refused, and the refusal NAMES it
|
||||
value must be at most 5000.0
|
||||
PASS an entry with bms_soc_floor over the range (SAFETY-32) is refused, and the refusal NAMES it
|
||||
value must be at most 50.0
|
||||
PASS a JSON STRING where the list belongs is refused
|
||||
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
|
||||
== E. the ROLLBACK direction, and the operator's UI ==
|
||||
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
|
||||
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
|
||||
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
|
||||
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
|
||||
|
||||
== all cases passed ==
|
||||
```
|
||||
@@ -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.
|
||||
@@ -40,6 +40,30 @@ hardware holds it there.
|
||||
|
||||
## Configuration
|
||||
|
||||
### What the Configuration tab actually shows you (UX-01)
|
||||
|
||||
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
|
||||
so Home Assistant's *Configuration* tab renders human labels and a description
|
||||
for every option instead of the raw option keys. What that does and does not buy
|
||||
you is worth stating plainly, because it was **measured against the Supervisor
|
||||
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
|
||||
rather than assumed — `tools/supervisor_shape_probe.py` section F prints the
|
||||
whole rendered list, in both languages.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **All 70 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||||
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
||||
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
||||
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
||||
| **The description says whether to touch it** | A number that was **derived** names the campaign it came from and says not to change it without re-deriving; a number that is genuinely yours (`max_w`, `peak_cap_w`) says **"THIS IS YOUR NUMBER"**; and `cycle_cost_threshold_eur`, which is neither, says it is an open product question |
|
||||
| **Dutch falls back per key** | `translations[your language]?.name \|\| translations["en"]?.name \|\| the raw key`. A key missing from `nl.yaml` degrades to English; a key present but wrong does not degrade at all |
|
||||
| **⚠️ The per-entry keys inside `inverters:` still render raw** | Measured, and negative. A list-of-objects becomes an `object` selector with `multiple: true`, and `ha-selector-object` labels its fields `fields[name].label \|\| name` — the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do **not** reach the screen. The `inverters` label and description themselves do |
|
||||
|
||||
`test_translations.py` is what stops this rotting: a translation naming an
|
||||
option that does not exist, or an option with no translation, fails CI — in
|
||||
either direction, and the check is mutation-tested both ways.
|
||||
|
||||
### Sources
|
||||
|
||||
| option | required | meaning |
|
||||
@@ -142,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
|
||||
@@ -565,7 +636,7 @@ help - see "Why the tuning is what it is".
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all |
|
||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
|
||||
@@ -300,6 +300,69 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
assert the **shape** of that second report, not only its verdict, so the
|
||||
check cannot silently revert to grading the first path.
|
||||
|
||||
10. **A fixture of N members cannot separate two reductions that agree on N
|
||||
members — so grade the FAMILY, and grade it from the SOURCE.** This is the
|
||||
only lesson SAFETY-32 cost four review rounds to learn, and it generalises
|
||||
past packs to every roster, list and window in this repository. Two packs
|
||||
cannot tell `max` from `min` from first from last. Three cannot tell `max`
|
||||
from `max(x[-2:])`. **The regress does not terminate in a fixture**: `[:1]`
|
||||
needs two members, `[:2]` three, `[:3]` four, and adding one more member
|
||||
moves the hole rather than closing it. Three things follow, and all three
|
||||
are now in `test_peakguard.py` parts 12c2, 12g, 12i and 12j:
|
||||
* the fixture is a **SET** — the extreme at every position of the roster,
|
||||
not one roster with the extreme somewhere;
|
||||
* the family table is **COMPUTED and PRINTED**, so a member no fixture kills
|
||||
shows up as a named survivor and takes the check red, instead of being
|
||||
absent from a list nobody re-derived;
|
||||
* where the claim is an **IDENTITY** ("this roster", "this list"), pin it
|
||||
**exactly on the AST** — `ast.unparse(arg) == "self.packs"` is false of
|
||||
every slice, where `"self.packs" in src` is true of all of them. Where the
|
||||
claim is **SEMANTIC** ("the maximum"), a shape pin would refuse honest
|
||||
refactors, so report the unreached width **with its size** instead.
|
||||
|
||||
11. **When a check of this shape is found, mutate its SIBLINGS before shipping
|
||||
the fix.** SAFETY-32 was rejected three times for the same defect one step
|
||||
over: the reduction, then the roster handed to it, then the reduction handed
|
||||
to the *other argument of the same call*. Enumerating the family from the
|
||||
AST and mutating each member took one afternoon and found **four more
|
||||
survivors in code the card never touched** — three of SAFETY-30's
|
||||
`any(i.sign_wd.tripped for i in self.inverters)` reductions, invisible
|
||||
because the only fixture that reached them was a roster of ONE, where
|
||||
`self.inverters[:1]` **is** `self.inverters`. Ask of every fix: *what is the
|
||||
sibling of the thing I just hardened, and does any fixture separate it from
|
||||
a slice of itself?*
|
||||
|
||||
12. **When the same shape has been found twice, STOP CLOSING SITES AND BUILD
|
||||
THE INSTRUMENT — and make it DISCOVER its own scope.** SAFETY-32 was
|
||||
rejected four times, and each round closed exactly the site it was handed:
|
||||
the reduction, then the roster handed to it, then the *sibling argument* of
|
||||
the same call, then the same family one *module* over, in validate.py.
|
||||
Round 4 did enumerate — and enumerated inside the `Controller` class,
|
||||
because that is where the four known sites lived. **A hand-written scope is
|
||||
the same defect as a hand-written fixture**: it is a check whose data cannot
|
||||
reach the failure it names, and it goes on passing while the thing it was
|
||||
supposed to cover sits in the file nobody added to the list. Round 5's pin
|
||||
parses every `.py` under `app/`, seeds itself from ONE option key, and
|
||||
propagates through returns, accumulator loops, attributes and *call
|
||||
arguments* to find the roster wherever it went — 183 reductions across 9
|
||||
modules. It rediscovered all four blockers without being told where to look,
|
||||
and it named two modules the hand-written list would not have contained
|
||||
(`maintenance.py`, `store.py`) while *omitting* one the list did contain
|
||||
(`watchdog.py`, which is strictly per-unit and reduces over no roster at
|
||||
all). **The list was wrong in both directions**, which is exactly what a
|
||||
computed scope is for.
|
||||
|
||||
13. **Say what the instrument CANNOT see, with sizes, in the same breath.** A
|
||||
structural pin grades an **identity** ("this whole roster arrives"); it can
|
||||
never grade a **meaning** (`min` vs `max` is a zero-byte edit and invisible
|
||||
to it). Ship the pin AND the behavioural set, name the gap, and give every
|
||||
unreached case its byte delta — `cycle()`'s undeliverable-unit log line
|
||||
(+4 B, still green), a subscript by a *variable* rather than a constant, a
|
||||
slice taken through an intermediate call, anything outside `app/`. **An
|
||||
enumerated list with an honest gap is worth more than a claim of
|
||||
completeness a reviewer falsifies in ten minutes**, and every round of this
|
||||
card that claimed the latter was rejected for it.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -15,6 +15,11 @@ The scalars, and what each one was silently asserting:
|
||||
SAFETY-24's pack floor and arbitrage's bounds. An
|
||||
average lets the emptier pack be driven below its own
|
||||
floor; a maximum parks the fuller one.
|
||||
`bms_soc_floor` SAFETY-32. ONE cut-out percent for every pack, and it
|
||||
describes A BATTERY. Two packs of different models
|
||||
have different cut-outs, so the flat key graded the
|
||||
shallower pack against the deeper one's BMS - wrong
|
||||
in the direction that empties a pack.
|
||||
`batt` ONE battery power into the anti-windup saturation
|
||||
detector - see the SITE/UNIT note at the bottom of
|
||||
this docstring, because this one goes the OTHER WAY.
|
||||
@@ -117,6 +122,12 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
# ticket exists to prevent. peakguard imports arbiter/control/tariff and none
|
||||
# of those imports this module, so the direction is one-way.
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .store import unit_key
|
||||
from .watchdog import SignWatchdog, SignWatchdogConfig
|
||||
|
||||
@@ -170,7 +181,7 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
store=None, sign_cfg=None):
|
||||
bms_soc_floor=None, store=None, sign_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -179,6 +190,19 @@ class Inverter:
|
||||
# The supervised cap on what may be written to THIS setpoint entity.
|
||||
# See the module docstring on why this is a number and not a `Tuning`.
|
||||
self.max_w = float(max_w)
|
||||
# SAFETY-32. THIS PACK'S OWN BMS CUT-OUT, in percent, or None when the
|
||||
# roster entry did not state one.
|
||||
#
|
||||
# ⚠️ NOT DEFAULTED HERE, AND None RATHER THAN A NUMBER. A cut-out is a
|
||||
# property of the battery in the cupboard; inventing one for a pack that
|
||||
# did not state it is precisely this card's defect - a pack graded
|
||||
# against a floor that belongs to a different battery - rebuilt in the
|
||||
# constructor. `peakguard.pack_floor_pct()` treats None as an unknown
|
||||
# floor and `discharge_permitted()` treats an unknown floor as NO
|
||||
# permission, so the absence costs a shave; validate.py rule 8 refuses
|
||||
# the config by name long before that, at startup.
|
||||
self.bms_soc_floor = (None if bms_soc_floor is None
|
||||
else float(bms_soc_floor))
|
||||
|
||||
# --- the device's own reported range, read from its own entity -------
|
||||
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
||||
@@ -333,14 +357,46 @@ 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.
|
||||
|
||||
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
||||
`inverters:` empty?
|
||||
|
||||
EMPTY the five flat keys - `setpoint_entity`, `soc_entity`,
|
||||
`batt_entity`, `batt_invert`, `max_w` - describe the ONE inverter,
|
||||
EMPTY the six flat keys - `setpoint_entity`, `soc_entity`,
|
||||
`batt_entity`, `batt_invert`, `max_w`, `bms_soc_floor` (SAFETY-32)
|
||||
- describe the ONE inverter,
|
||||
exactly as the shipped scalars did. This is what every install
|
||||
that upgrades into this version reads, without an operator
|
||||
touching anything, and it is byte-identical to what DEV-02
|
||||
@@ -353,8 +409,8 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
|
||||
because "this unit's number came from somewhere else" is the
|
||||
defect DEV-02 spent a card removing and a fallback here would
|
||||
rebuild it in three lines. `name`, `setpoint_entity` and `max_w`
|
||||
are required of every entry - the schema enforces it on the
|
||||
rebuild it in three lines. `name`, `setpoint_entity`, `max_w` and
|
||||
`bms_soc_floor` (SAFETY-32) are required of every entry - the schema enforces it on the
|
||||
Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py
|
||||
and the checks, which reach this function with any dict at all.
|
||||
|
||||
@@ -380,18 +436,24 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
||||
# - the one pack the flat options describe is the one pack whose
|
||||
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
|
||||
# mirror exactly as validate.py rule 8 defaults it, so an install
|
||||
# that never set the key reads the same 10 % in both places.
|
||||
bms_soc_floor=_flat_bms_floor(opts),
|
||||
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", ""),
|
||||
@@ -401,10 +463,54 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
|
||||
# not state is not the flat one, it is a rating rule 9 refuses.
|
||||
max_w=_entry_max_w(entry),
|
||||
# ⚠️ NOT `opts["bms_soc_floor"]`, FOR `max_w`'S REASON AND THIS
|
||||
# CARD'S. An entry that omits its cut-out has NOT got the flat one;
|
||||
# it has none, and rule 8 refuses it by name. Borrowing here would be
|
||||
# SAFETY-32's own defect written in three lines - the shallower pack
|
||||
# graded against the deeper pack's BMS.
|
||||
bms_soc_floor=_entry_bms_floor(entry),
|
||||
store=store, sign_cfg=sign_cfg))
|
||||
return roster
|
||||
|
||||
|
||||
def _flat_bms_floor(opts: dict):
|
||||
"""The single-inverter shape's own cut-out, or None if it is unparseable.
|
||||
|
||||
Defaulted from `PEAK_OPTION_DEFAULTS` when the key is absent or blank, which
|
||||
is `validate._number()`'s behaviour on the same key - the two have to agree
|
||||
or the validator would grade a number the roster does not build.
|
||||
"""
|
||||
# ⚠️ ONE defaulting line, not two. The first cut wrote the fallback into the
|
||||
# `.get()` AND into the blank test below it, and a mutation that deleted
|
||||
# either one survived - each was covering for the other, which is
|
||||
# MUTATION.md's masking section in three lines of configuration reading.
|
||||
raw = opts.get("bms_soc_floor")
|
||||
if raw is None or raw == "":
|
||||
return float(PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _entry_bms_floor(entry: dict):
|
||||
"""One entry's OWN cut-out, or None when it did not state a usable one.
|
||||
|
||||
None rather than a borrowed number and rather than 0.0 - `_entry_max_w()`
|
||||
can answer 0.0 because a zero rating commands nothing, but a zero FLOOR is
|
||||
the most permissive value the key has and would read as "this pack may be
|
||||
emptied". Rule 8 refuses the missing key by name; until it does, None
|
||||
withholds.
|
||||
"""
|
||||
raw = entry.get("bms_soc_floor")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _entry_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1065,6 +1066,26 @@ class Controller:
|
||||
return None
|
||||
return float(min(float(v) for v in vals))
|
||||
|
||||
@property
|
||||
def packs(self) -> list:
|
||||
"""SAFETY-32. Each pack as its own THREE numbers, in roster order.
|
||||
|
||||
`pack_floor_soc` above is the site's ONE verdict and stays exactly what
|
||||
DEV-02 made it - the emptiest measured pack. This is the thing that
|
||||
reduction cannot produce: by the time `min()` has answered, WHICH pack
|
||||
the number came from and how far that pack was from ITS OWN cut-out have
|
||||
both been thrown away, and two packs at 15 % against cut-outs of 10 % and
|
||||
20 % have opposite answers that are indistinguishable afterwards.
|
||||
|
||||
⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC
|
||||
did not answer is a pack nothing can grade, and dropping it from this
|
||||
list would let the site spend the packs that DID answer while one sat
|
||||
unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no
|
||||
permission, which is where that must be decided.
|
||||
"""
|
||||
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
|
||||
for inv in self.inverters]
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings.
|
||||
@@ -1881,9 +1902,21 @@ class Controller:
|
||||
# protect. None stays None: `decide()` treats it as floor-limited,
|
||||
# which costs a shave rather than a pack.
|
||||
soc=self.pack_floor_soc,
|
||||
soc_floor=float(self.o.get(
|
||||
# ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN
|
||||
# CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is
|
||||
# still read from that one key with CFG-03's one mirror behind it;
|
||||
# what `binding_soc_floor()` adds is the conjunction with each pack's
|
||||
# OWN `bms_soc_floor`, so the floor this site is graded against can
|
||||
# never be below the deepest cut-out on the roster. MAXIMUM here
|
||||
# pairs with the MINIMUM in `pack_floor_soc`: both reductions move
|
||||
# the verdict towards refusing a shave and away from spending a pack.
|
||||
# Under any config rule 8 accepts it IS the option, for every pack -
|
||||
# asserted over the whole schema range in test_peakguard.py part 12
|
||||
# rather than assumed, because "another rule refuses that" is a claim
|
||||
# about rule ordering and sim/inject.py does not run rule 8 at all.
|
||||
soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
|
||||
"peak_soc_floor",
|
||||
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
|
||||
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))),
|
||||
max_hold_s=self.p1.averager.max_hold_s,
|
||||
# ⚠️ SAFETY-20. The device range goes in TOO, and this call site is
|
||||
# the one that needed it most: `standing` is `last_write`, i.e. the
|
||||
@@ -2596,6 +2629,22 @@ class Controller:
|
||||
# notification that matters into a traceback.
|
||||
_at_floor = ("at the floor" if g.soc_floor is None
|
||||
else f"at {float(g.soc_floor):.0f} %")
|
||||
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
|
||||
# the decision was actually taken with, so this re-grades nothing and
|
||||
# cannot disagree with the alarm it is printed inside. At a roster of one
|
||||
# the sentence is omitted entirely and this notification is byte-for-byte
|
||||
# what SAFETY-25 shipped - there is exactly one pack and naming it would
|
||||
# be noise on every existing install.
|
||||
_blocked = [name for name, why
|
||||
in peakguard.pack_permissions(self.packs, g.soc_floor)
|
||||
if why is not None]
|
||||
_which = ("" if len(self.inverters) < 2 else
|
||||
f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against "
|
||||
f"that pack's own BMS cut-out (bms_soc_floor), and the "
|
||||
f"pack(s) at or below their own floor right now are "
|
||||
f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}"
|
||||
f". The other packs on the roster are not the reason the guard "
|
||||
f"stopped and raising their cut-out will not change this.")
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_peak_soc_floor",
|
||||
"title": "GoodWe: the capacity-peak guard has run out of battery",
|
||||
@@ -2615,7 +2664,7 @@ class Controller:
|
||||
f"{_at_floor} - ordinary self-consumption has no SoC "
|
||||
f"floor at all and will go on discharging past it, down to the "
|
||||
f"pack's own BMS cut-out. What has stopped is the guard buying "
|
||||
f"you a lower bill, not the pack being spent."),
|
||||
f"you a lower bill, not the pack being spent." + _which),
|
||||
})
|
||||
|
||||
async def check_circulating(self) -> None:
|
||||
@@ -3025,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:
|
||||
@@ -3731,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:
|
||||
|
||||
@@ -360,6 +360,29 @@ distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
|
||||
a loop-side guard would be the third - at which point the migration buys enough
|
||||
to be worth its cost.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SAFETY-32: ONE `bms_soc_floor` FOR TWO PACKS GRADES ONE AGAINST THE OTHER'S BMS
|
||||
|
||||
`bms_soc_floor` above describes A PACK, not a site, and it was a flat option
|
||||
because there was one pack. At two, two batteries of different models have
|
||||
different cut-outs, and one number for both is wrong in the direction that
|
||||
empties a battery: the SHALLOWER pack (the higher cut-out) is protected by a
|
||||
floor that belongs to the DEEPER one. So it became a roster key - `Pack` carries
|
||||
each unit's own value, `pack_floor_pct()` conjoins it with the site's EMS floor,
|
||||
and validate.py rule 8 grades EVERY unit and NAMES the one it refuses.
|
||||
|
||||
⚠️ `peak_soc_floor` DID NOT MOVE, AND THE RESIDUAL IS RECORDED RATHER THAN
|
||||
HIDDEN. It is one number for the site, so with cut-outs of 10 % and 18 % the
|
||||
operator must set it above 18 and the deeper pack is then withheld above its own
|
||||
need. That half - a per-pack EMS floor - is NOT this card and must not be
|
||||
smuggled in with it; DECISIONS/SAFETY-32-per-pack-bms-floor.md states it as the
|
||||
open question it is.
|
||||
|
||||
⚠️ AND THE MIN() OVER MEASURED SoC IS STILL RIGHT. `Controller.pack_floor_soc`
|
||||
reduces to the emptiest pack and this card does not touch it. What changed is
|
||||
the FLOOR it is graded against, and the addition of a per-pack answer beside the
|
||||
site's one verdict - see `pack_permissions()`.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
||||
|
||||
@@ -572,6 +595,110 @@ def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pack:
|
||||
"""SAFETY-32. ONE pack's three numbers for the floor question, NAMED.
|
||||
|
||||
`name` the roster name, because a refusal that does not say WHICH pack
|
||||
stopped the site sends an operator to the wrong device - the
|
||||
same defect CFG-04's `unit_caps_indexed()` was written for.
|
||||
`soc` THIS pack's measured state of charge. None when its sensor did
|
||||
not answer, and None is not a full battery.
|
||||
`bms_floor` THIS pack's OWN cut-out percent (`bms_soc_floor`), which is a
|
||||
property of the hardware in the cupboard and NOT of the site.
|
||||
None when the roster entry did not state one - see
|
||||
`pack_floor_pct()` for why that is withheld and not 0 %.
|
||||
|
||||
⚠️ IT CARRIES THE CUT-OUT, NOT THE EMS FLOOR. `peak_soc_floor` is one number
|
||||
for the whole site (the depth a money-saving discharge may never reach) and
|
||||
stays a flat option; `bms_soc_floor` describes a battery and became a roster
|
||||
key here, because two packs of different models have different cut-outs and
|
||||
grading both against one of them is wrong in the direction that empties the
|
||||
shallower one.
|
||||
"""
|
||||
|
||||
name: str
|
||||
soc: float | None
|
||||
bms_floor: float | None
|
||||
|
||||
|
||||
def pack_floor_pct(pack, soc_floor):
|
||||
"""The percent THIS pack may not be spent below, or None if that is unknown.
|
||||
|
||||
`max(the site's EMS floor, this pack's own cut-out)` - the CONJUNCTION, and
|
||||
the direction matters: the EMS floor is what the operator asked for and the
|
||||
cut-out is what the hardware will do anyway, so a pack is protected by
|
||||
whichever is higher. Taking the site floor alone is the shipped defect this
|
||||
card removes (the shallower pack graded against the deeper one's cut-out);
|
||||
taking the cut-out alone would throw SAFETY-24's margin away.
|
||||
|
||||
⚠️ AN UNSTATED CUT-OUT IS None, NOT ZERO, AND NOT THE FLAT OPTION. 0 % would
|
||||
be a floor that permits everything - the most permissive value there is,
|
||||
invented from an absence - and borrowing the flat `bms_soc_floor` is CFG-04's
|
||||
"nothing is inherited" broken in one line, which is this card's whole subject
|
||||
restated. `discharge_permitted()` already treats a None floor as NO
|
||||
permission, so an entry that forgot the key costs a shave rather than a pack.
|
||||
|
||||
⚠️ AND UNDER A rule-8-VALID CONFIG THIS IS `soc_floor`, FOR EVERY PACK.
|
||||
validate.py rule 8 refuses `peak_soc_floor <= bms_soc_floor` on EVERY unit,
|
||||
so `max()` selects the EMS floor everywhere the add-on will actually start -
|
||||
which is exactly why the reduction is written here rather than assumed at the
|
||||
call site. "Another rule refuses that config" is a claim about today's rule
|
||||
ordering, not a property of this function, and `validate_startup_config()` is
|
||||
also `sim/inject.py`'s entry point. `unit_caps_indexed()`'s docstring records
|
||||
the same argument, and CFG-04's index desynchronisation is what happened the
|
||||
last time it was left implicit.
|
||||
"""
|
||||
if soc_floor is None or pack.bms_floor is None:
|
||||
return None
|
||||
return max(float(soc_floor), float(pack.bms_floor))
|
||||
|
||||
|
||||
def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]:
|
||||
"""SAFETY-32. Per pack: None if it may be spent, else the reason it may not.
|
||||
|
||||
ONE `(name, reason)` pair per pack, in roster order, from the SAME
|
||||
`discharge_permitted()` the site verdict uses - a second transcription of
|
||||
"may this pack be spent" is a second answer to it, which is the argument
|
||||
that made that predicate public in the first place.
|
||||
|
||||
⚠️ THIS IS THE OBSERVABLE THE MIN() CANNOT PRODUCE. `Controller.pack_floor_soc`
|
||||
reduces the measured SoC to the emptiest pack BEFORE anything is graded, and
|
||||
that reduction is right for the site's one verdict - but it has thrown away
|
||||
which pack it came from and how far that pack was from ITS OWN floor by the
|
||||
time the answer exists. Two packs at 15 % against floors of 10 % and 20 %
|
||||
have opposite answers and are indistinguishable after the `min()`.
|
||||
"""
|
||||
return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor)))
|
||||
for p in packs]
|
||||
|
||||
|
||||
def binding_soc_floor(packs, soc_floor):
|
||||
"""The ONE floor `decide()` may grade `pack_floor_soc` against, at a roster.
|
||||
|
||||
MAXIMUM over the packs' own floors, and it is the conservative reduction that
|
||||
pairs with the MINIMUM `pack_floor_soc` reduction: `min(soc) <= max(floor)`
|
||||
is implied by "some pack is at or below its own floor", so this verdict is
|
||||
never more permissive than grading each pack separately would be. The other
|
||||
direction is not true and is not claimed - a site can be refused here while
|
||||
every pack individually has headroom, which costs a shave and never a pack.
|
||||
|
||||
None when ANY pack's floor is unknown, for `Controller.site_dev_bound()`'s
|
||||
reason and in the same direction: `discharge_permitted()` treats a None floor
|
||||
as no permission, so an unknown floor withholds rather than defaults.
|
||||
|
||||
⚠️ AT N=1 THIS IS `soc_floor` ITSELF under any config rule 8 accepts, and
|
||||
test_peakguard.py part 12 asserts that over the whole schema range rather
|
||||
than assuming it.
|
||||
"""
|
||||
if not packs:
|
||||
return None if soc_floor is None else float(soc_floor)
|
||||
floors = [pack_floor_pct(p, soc_floor) for p in packs]
|
||||
if any(f is None for f in floors):
|
||||
return None
|
||||
return max(floors)
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
@@ -132,7 +132,7 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
installed base is on a fixed or static tariff. Refusing to start without a
|
||||
price feed would lock those sites out of the add-on entirely.
|
||||
|
||||
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
|
||||
8. `peak_soc_floor` at or below `bms_soc_floor`, ON ANY UNIT. [(a)]
|
||||
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
|
||||
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
|
||||
is the EMS-side bound on how far down it may take the pack. The BMS has its
|
||||
@@ -142,6 +142,12 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
of binding while its status page reports one - rule 5's "a disarmed guard
|
||||
that advertises itself as armed", on the guard whose failure mode is a pack
|
||||
that is quietly never available when it is wanted.
|
||||
⚠️ SAFETY-32: `bms_soc_floor` DESCRIBES A BATTERY, so at a roster it is a
|
||||
per-entry key and this rule grades EVERY unit and NAMES the one it refuses.
|
||||
One number for two packs of different models refuses the wrong config and
|
||||
accepts the wrong one - cut-outs of 10 % and 30 % under `peak_soc_floor: 20`
|
||||
used to start. A missing per-entry cut-out is refused here rather than
|
||||
defaulted: a cut-out invented for a pack IS this defect.
|
||||
|
||||
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
|
||||
equal, the EMS floor and the pack's cut-out act at the same percent, so the
|
||||
@@ -386,6 +392,49 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
||||
return caps
|
||||
|
||||
|
||||
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
|
||||
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
|
||||
|
||||
`(roster index, the raw value as configured, the parsed float or None)`, one
|
||||
triple per unit. The raw value travels too because the refusal quotes what
|
||||
the operator actually wrote, and `!r` of a re-read `opts` key is the wrong
|
||||
string the moment the value came off a roster entry.
|
||||
|
||||
⚠️ THE INDEX IS THE ROSTER'S, NOT THIS LIST'S, AND THAT IS CFG-04'S BUG
|
||||
WRITTEN DOWN. Rules 5 and 6 fed a CAPS-list index into `_unit_label()`,
|
||||
which indexes the ROSTER, so on a roster whose first entry was malformed the
|
||||
refusal named the WRONG INVERTER - an operator sent to the wrong device by
|
||||
the very message that exists to stop that. `unit_caps_indexed()` exists
|
||||
because of it. This function goes one step further and SKIPS NOTHING: every
|
||||
entry produces a triple, including the ones with no usable floor, because a
|
||||
missing cut-out is itself what rule 8 has to refuse BY NAME. There is
|
||||
therefore no second list for an index to desynchronise from.
|
||||
|
||||
⚠️ AND NOTHING IS INHERITED. At a declared roster the FLAT `bms_soc_floor` is
|
||||
not read at all - CFG-04's rule, and this card's subject: a pack that borrows
|
||||
a sibling's cut-out is graded against a battery it is not. At the flat shape
|
||||
the flat key IS this one pack's cut-out, defaulted from CFG-03's single
|
||||
mirror exactly as `inverter._flat_bms_floor()` defaults it.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [(0, opts.get("bms_soc_floor"),
|
||||
_number(opts, "bms_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["bms_soc_floor"]))]
|
||||
out: list[tuple[int, object, object]] = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
# Rule 9(a)/(b) already refuses a non-dict entry by index, and it
|
||||
# runs FIRST. Naming it twice would hand the operator two sentences
|
||||
# about one line of YAML.
|
||||
continue
|
||||
raw = entry.get("bms_soc_floor")
|
||||
out.append((index, raw,
|
||||
None if raw is None or raw == ""
|
||||
else _number(entry, "bms_soc_floor", None)))
|
||||
return out
|
||||
|
||||
|
||||
def _unit_label(opts: dict, index: int) -> str:
|
||||
""""" at a single-inverter config, " (inverter-b)" at a roster.
|
||||
|
||||
@@ -757,21 +806,48 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# second inverter is added - which is the day nobody is watching the startup
|
||||
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
|
||||
# prevents is silent.
|
||||
#
|
||||
# ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32
|
||||
# ROUND 5. This branch used to compare against `max_w = _number(opts,
|
||||
# "max_w", 2000.0)`, the FLAT option, which on a roster config is either
|
||||
# ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the
|
||||
# site) or a leftover from a single-inverter install (so it compared against
|
||||
# a number no unit has). Both directions were live and both were measured:
|
||||
#
|
||||
# a roster of two 2000 W units beside a leftover `max_w: 5000` and
|
||||
# `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED,
|
||||
# with a per-inverter watchdog reporting `armed` on the status page and
|
||||
# incapable of tripping on EITHER unit. That is the identical sentence the
|
||||
# branch above exists to refuse, reached through the branch beside it;
|
||||
#
|
||||
# and a roster of two 5000 W units with no flat key was REFUSED at
|
||||
# `unit_sign_disagree_w: 3000` against a 2000 W default the operator never
|
||||
# wrote, for a threshold both units can reach.
|
||||
#
|
||||
# `unit_caps()`'s own docstring already said why: there is no such thing as
|
||||
# a site rating on the wire, and this watchdog grades ONE UNIT's standing
|
||||
# command against THAT unit's `supervised` bound. So it takes the same
|
||||
# `weakest` the branch above takes, from the same list, and names the same
|
||||
# unit. At a roster of one `unit_caps()` is `[max_w]` and this is the
|
||||
# identical comparison it always was - which is what keeps the shipped
|
||||
# single-inverter refusal byte-for-byte what it was.
|
||||
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
||||
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
||||
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||
if caps_ix else "")
|
||||
if unit_sign_w is None:
|
||||
problems.append(f"unit_sign_disagree_w "
|
||||
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
|
||||
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||
problems.append(
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
|
||||
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
|
||||
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
||||
"watchdog can never fire: no inverter is ever commanded past max_w, "
|
||||
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
|
||||
"inverters that is the guard which catches a unit doing the "
|
||||
"OPPOSITE of what it was told while its sibling covers for it at "
|
||||
"the meter - the case the site-level watchdog is structurally "
|
||||
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
|
||||
f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W "
|
||||
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
|
||||
"measured default), or raise max_w.")
|
||||
|
||||
@@ -871,28 +947,39 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py
|
||||
# reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is
|
||||
# for the `maintenance_*` family.
|
||||
#
|
||||
# ⚠️ SAFETY-32 - GRADED ONCE PER UNIT, AND THE REFUSAL NAMES THE UNIT. One
|
||||
# `bms_soc_floor` for two packs compares the EMS floor against whichever
|
||||
# battery happened to be written into the flat key: a site with cut-outs of
|
||||
# 10 % and 30 % and `peak_soc_floor: 20` STARTED, and the guard would then
|
||||
# command a forced discharge that the second pack's BMS - not the operator's
|
||||
# floor - is the thing that stops. That is the disarmed-backstop state this
|
||||
# rule exists to refuse, reached through the rule itself. So the loop is over
|
||||
# units, and `_unit_label()` is fed the ROSTER index the value came off (see
|
||||
# `unit_bms_floors_indexed()`), because CFG-04 has just fixed the version of
|
||||
# this that named the wrong inverter.
|
||||
soc_floor = _number(opts, "peak_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
||||
bms_floor = _number(opts, "bms_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
"peak_soc_floor and bms_soc_floor must both be numbers (got "
|
||||
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
||||
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
||||
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
||||
"the pack's protection rather than yours. The guard would report a "
|
||||
"SoC floor on its status page while being incapable of ever being "
|
||||
"the thing that stops the discharge, and the symptom of that is a "
|
||||
"battery repeatedly sitting at the BMS limit with no record of why "
|
||||
f"- which looks exactly like the SoC drift the maintenance cycle "
|
||||
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
||||
f"(the shipped default is "
|
||||
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
||||
"bms_soc_floor if it does not match this pack.")
|
||||
for _ix, _raw, bms_floor in unit_bms_floors_indexed(opts):
|
||||
where = _unit_label(opts, _ix)
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
f"peak_soc_floor and bms_soc_floor{where} must both be numbers "
|
||||
f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
||||
f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
||||
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
||||
"the pack's protection rather than yours. The guard would report a "
|
||||
"SoC floor on its status page while being incapable of ever being "
|
||||
"the thing that stops the discharge, and the symptom of that is a "
|
||||
"battery repeatedly sitting at the BMS limit with no record of why "
|
||||
f"- which looks exactly like the SoC drift the maintenance cycle "
|
||||
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
||||
f"(the shipped default is "
|
||||
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
||||
f"bms_soc_floor{where} if it does not match this pack.")
|
||||
|
||||
# --- warnings: real, but not worth refusing over -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
|
||||
+387
-310
@@ -31,23 +31,23 @@ services:
|
||||
- mqtt:want
|
||||
|
||||
options:
|
||||
# --- sources (required) ---------------------------------------------------
|
||||
meter_entity: sensor.p1_meter_active_power
|
||||
meter_invert: false
|
||||
# ==========================================================================
|
||||
# HARDWARE - the inverter, its actuator and its own sensors
|
||||
# ==========================================================================
|
||||
setpoint_entity: ""
|
||||
soc_entity: ""
|
||||
batt_entity: ""
|
||||
batt_invert: false
|
||||
setpoint_entity: ""
|
||||
|
||||
# --- ARCH-01 / CFG-04: the inverter roster ---------------------------------
|
||||
# ⚠️ EMPTY IS NOT "UNCONFIGURED", IT IS THE SINGLE-INVERTER CONFIGURATION, and
|
||||
# it is what every install that upgrades into this version lands on. An empty
|
||||
# list means THE FIVE FLAT KEYS ABOVE DESCRIBE THE ONE INVERTER - same
|
||||
# entities, same rating, same behaviour, no operator action - which is the
|
||||
# only migration story this key was allowed to have. `build_roster()` reads
|
||||
# this and nothing else decides the shape.
|
||||
# list means THE SIX FLAT KEYS - `setpoint_entity`, `soc_entity`,
|
||||
# `batt_entity`, `batt_invert`, `max_w` and `bms_soc_floor` - DESCRIBE THE
|
||||
# ONE INVERTER: same entities, same rating, same behaviour, no operator
|
||||
# action, which is the only migration story this key was allowed to have.
|
||||
# `build_roster()` reads this and nothing else decides the shape.
|
||||
#
|
||||
# Declared, it REPLACES those five keys rather than extending them. Each entry
|
||||
# Declared, it REPLACES those six keys rather than extending them. Each entry
|
||||
# names its own actuator, its own rating and its own sensors:
|
||||
#
|
||||
# inverters:
|
||||
@@ -56,11 +56,13 @@ options:
|
||||
# soc_entity: sensor.goodwe_a_soc
|
||||
# batt_entity: sensor.goodwe_a_battery_power
|
||||
# max_w: 5000
|
||||
# bms_soc_floor: 10
|
||||
# - name: inverter-b
|
||||
# setpoint_entity: number.goodwe_b_setpoint_w
|
||||
# soc_entity: sensor.goodwe_b_soc
|
||||
# batt_entity: sensor.goodwe_b_battery_power
|
||||
# max_w: 4600
|
||||
# bms_soc_floor: 10
|
||||
#
|
||||
# ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A
|
||||
# PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES,
|
||||
@@ -74,17 +76,38 @@ options:
|
||||
# what settles that once the device range is read; see goodwe/NOTES.md §1.1b,
|
||||
# which records both readings and picks neither.
|
||||
#
|
||||
# ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE
|
||||
# DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter
|
||||
# exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read
|
||||
# off the pack that is actually installed (register 0x0560 on the reference
|
||||
# unit) before that pack is commanded. It reads 10 here because pack A reads
|
||||
# 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two
|
||||
# packs floor at the SAME percent cannot separate `min`, `max`, first or last
|
||||
# over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs
|
||||
# it for exactly that reason. Do not "simplify" those fixtures to match this
|
||||
# example.
|
||||
#
|
||||
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
|
||||
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
|
||||
# rating became per-instance precisely so that no inverter is ever graded
|
||||
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity` and `max_w` are
|
||||
# therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
|
||||
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# validate.py rule 9 enforces it again for the callers the Supervisor never
|
||||
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
|
||||
# SENSOR - `inputs_ok` already stops the site for that - and never means
|
||||
# "use the other one's".
|
||||
#
|
||||
# ⚠️ SAFETY-32: `bms_soc_floor` IS PER ENTRY AND IS REQUIRED, NOT DEFAULTED.
|
||||
# It is the percent THAT pack's own BMS cuts out at, and two batteries of
|
||||
# different models do not share one. A default would be this add-on inventing
|
||||
# a cut-out for a battery it has not been told about, which is the same defect
|
||||
# as inheriting one: the shallower pack gets protected by a floor that belongs
|
||||
# to the deeper pack, and validate.py rule 8 - which refuses a peak_soc_floor
|
||||
# the BMS would win against - would be grading the wrong number. Rule 8 grades
|
||||
# EVERY entry and names the one it refuses. The flat key below is unchanged and
|
||||
# is the single-inverter shape's own cut-out.
|
||||
#
|
||||
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
|
||||
# roster is declared is two config sites aimed at actuators, one of which
|
||||
# nothing writes; if it names an entity an entry also names, SAFETY-28's
|
||||
@@ -103,9 +126,12 @@ options:
|
||||
# validator answers `Missing option 'inverters' in root` for every existing
|
||||
# options blob, and the upgrade leaves an add-on that cannot start.
|
||||
inverters: []
|
||||
estop_fitted: false
|
||||
|
||||
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||
# `off` keeps the original single meter_entity path above, so an existing
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
# `off` keeps the original single `meter_entity` path, so an existing
|
||||
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
||||
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
|
||||
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
|
||||
@@ -116,209 +142,62 @@ options:
|
||||
# nothing at all for a repeated reading - so it is the only mode a firmware
|
||||
# watchdog may threshold. See DOCS.md.
|
||||
meter_source: "off"
|
||||
meter_entity: sensor.p1_meter_active_power
|
||||
meter_invert: false
|
||||
meter_phases: 1
|
||||
meter_max_age_s: 30
|
||||
meter_mqtt_topic: ""
|
||||
|
||||
# homewizard_local only. The meter's own address, `host` or `host:port`.
|
||||
p1_host: ""
|
||||
|
||||
# homewizard_local only. Seconds between polls. Must be well under
|
||||
# meter_max_age_s - the age is never fresher than this interval - and there is
|
||||
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
|
||||
meter_poll_s: 5
|
||||
meter_max_age_s: 30
|
||||
meter_mqtt_topic: ""
|
||||
|
||||
# The two UNSIGNED Belgian registers. The EMS derives net power from them
|
||||
# (import - export); do NOT point these at a signed template sensor.
|
||||
p1_import_entity: ""
|
||||
p1_export_entity: ""
|
||||
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
|
||||
# three-phase connection; the list length must equal meter_phases.
|
||||
p1_phase_import_entities: []
|
||||
p1_phase_export_entities: []
|
||||
|
||||
# ha_signed only. ONE signed net-power sensor: positive = import from the
|
||||
# grid, negative = export to it. Do NOT split it into two template sensors -
|
||||
# the split is done in the add-on (p1.split_signed) precisely so the sign
|
||||
# convention is tested rather than living in unreviewed YAML.
|
||||
p1_net_entity: ""
|
||||
|
||||
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
|
||||
# three-phase connection; the list length must equal meter_phases.
|
||||
p1_phase_import_entities: []
|
||||
p1_phase_export_entities: []
|
||||
|
||||
# Optional, in L1..L3 order, each one signed the same way. Same role as
|
||||
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
|
||||
# connection. The list length must equal meter_phases.
|
||||
p1_phase_net_entities: []
|
||||
|
||||
# --- control ---------------------------------------------------------------
|
||||
# ==========================================================================
|
||||
# RATINGS AND LIMITS - what this site is allowed to do
|
||||
# ==========================================================================
|
||||
max_w: 2000
|
||||
gain: 0.6
|
||||
slew_w: 1000
|
||||
deadband_w: 15
|
||||
target_grid_w: -10
|
||||
step_w: 10
|
||||
saturation_w: 500
|
||||
saturation_cycles: 3
|
||||
integrator_max_w: 0
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
cycle_floor_s: 15
|
||||
auto_start: false
|
||||
|
||||
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
|
||||
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
||||
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
||||
# a failsafe that latches until an operator starts control again.
|
||||
# The pack's OWN cut-out percent - what the BMS enforces whatever
|
||||
# `peak_soc_floor` is set to. Register 0x0560 reads 10 on the reference
|
||||
# GW5000S-BP, consistent with DoD 90. It is here so validate.py can REFUSE a
|
||||
# peak_soc_floor at or below it: such a floor can never bind, the BMS wins,
|
||||
# and the status page would report an energy backstop that does not exist.
|
||||
#
|
||||
# Both defaults are measured, not chosen. On the captured healthy day
|
||||
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
||||
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
||||
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
||||
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
||||
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
||||
# fault day still trips 13 times. There is no reason to move either number
|
||||
# without a capture that says so. They are options because the AC says
|
||||
# thresholds are defaults rather than constants - not because they are knobs
|
||||
# anyone is expected to turn.
|
||||
#
|
||||
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
||||
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
||||
sign_disagree_w: 300
|
||||
sign_disagree_s: 180
|
||||
|
||||
# --- SAFETY-30 per-inverter sign-disagreement watchdog ---------------------
|
||||
# ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS
|
||||
# ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades
|
||||
# the SITE's total command against P1 - one meter, one grid connection. This
|
||||
# pair grades EACH inverter's own share against THAT inverter's own measured
|
||||
# power (holding register 0x0518), and it catches the fault the meter cannot
|
||||
# answer for once there are two units: a unit doing the opposite of what it
|
||||
# was told, while its sibling covers for it and P1 reads exactly what a
|
||||
# healthy site reads.
|
||||
#
|
||||
# At ONE inverter these two keys do nothing at all. §6.2's identity makes the
|
||||
# site's total command that inverter's command, so the pair above is already
|
||||
# asking this question of the same actuator, and arming a second detector
|
||||
# would be two latches and two banners for one fault.
|
||||
#
|
||||
# Both defaults are measured on admin/goodwe's two-master rig, over 1890
|
||||
# closed-loop runs in which nothing is faulty, swept across the whole range
|
||||
# this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so
|
||||
# the smallest divergence this can see is exactly the gap at which the control
|
||||
# law itself already says the inverter is not following; 85 s is 8.5x the
|
||||
# worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's
|
||||
# own 30 s link veto, and under half the 180 s the site check takes. See
|
||||
# app/watchdog.py's SIGN_OPTION_DEFAULTS and
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
#
|
||||
# ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason:
|
||||
# a share is bounded to +-max_w, so a threshold at or above it can never fire.
|
||||
# validate.py refuses that config.
|
||||
unit_sign_disagree_w: 250
|
||||
unit_sign_disagree_s: 85
|
||||
|
||||
# --- SAFETY-30 SITE sign-disagreement watchdog -----------------------------
|
||||
# ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS
|
||||
# BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the
|
||||
# standing commands is graded against P1, and the trip stops the whole site -
|
||||
# what it catches is the site being asked for the wrong thing, which every
|
||||
# per-inverter check is blind to because each unit is faithfully executing its
|
||||
# share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is
|
||||
# already asking this of the same actuator.
|
||||
#
|
||||
# The threshold is the shipped 300 W, CARRIED, and the reason is that the
|
||||
# argument behind it does not depend on the roster: near zero the sign is
|
||||
# noise, the deadband is 15 W, and there is one P1 whatever is behind it.
|
||||
#
|
||||
# ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s
|
||||
# exists to reject is the loop crossing zero after a load reversal, and the
|
||||
# command has to travel the whole SITE rail at `slew_w` per cycle - a rail
|
||||
# that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in
|
||||
# which nothing is faulty, the worst continuous legitimate opposition at 300 W
|
||||
# is 110 s at one inverter and 215 s at two: carrying 180 s across would have
|
||||
# shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary
|
||||
# 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter,
|
||||
# and still inside every episode of the 2026-08-20 fault day.
|
||||
site_sign_disagree_w: 300
|
||||
site_sign_disagree_s: 325
|
||||
|
||||
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
|
||||
# Seconds the meter may keep answering with an UNCHANGED measurement while the
|
||||
# command stands at the rail before the add-on raises an alarm. Both halves
|
||||
# are required: neither one alone is safe, and app/freeze.py carries why.
|
||||
#
|
||||
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
|
||||
# the battery; a false notification costs an alert.
|
||||
#
|
||||
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
|
||||
# only transport that reads the meter's own document. On every other source
|
||||
# the status page says NOT COVERED rather than "armed".
|
||||
#
|
||||
# 300 s is measured, not chosen. Over 2399 s of the live house at the
|
||||
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
|
||||
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
|
||||
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
|
||||
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
|
||||
# still inside the 900 s quarter that bills the capacity tariff.
|
||||
meter_freeze_s: 300
|
||||
|
||||
# --- SAFETY-31: circulating power ------------------------------------------
|
||||
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
|
||||
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
|
||||
# Dockerfile does not put this file in the image, so the running add-on cannot
|
||||
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. Add a third option in three places -
|
||||
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
|
||||
#
|
||||
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
|
||||
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
|
||||
# and is therefore invisible to every other guard in this add-on. Both numbers
|
||||
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
|
||||
# in which nothing was faulty; circulation.py carries the curve they were read
|
||||
# off and the argument for each. They are NOT sensitivity preferences.
|
||||
#
|
||||
# Both are ignored at one inverter, where the quantity is identically zero.
|
||||
circulation_w: 700
|
||||
circulation_hold_s: 75
|
||||
|
||||
# --- maintenance -----------------------------------------------------------
|
||||
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
|
||||
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
|
||||
# so nothing else in the add-on gets an opinion about what a missing
|
||||
# `maintenance_*` key means. There is exactly ONE in-code copy -
|
||||
# `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does
|
||||
# not put this file in the image and the running add-on therefore cannot read
|
||||
# it; test_maintenance.py pins that copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. main.py and validate.py both read the
|
||||
# mirror and type no number of their own.
|
||||
#
|
||||
# Before this card the same defaults were written down three times and had
|
||||
# already disagreed: this block said 2000 while main.py and MaintConfig said
|
||||
# 2500, so an options dict with the key ABSENT passed validation at 2000 and
|
||||
# then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter
|
||||
# contradiction. Changing one of these numbers now moves every consumer.
|
||||
maintenance_enabled: false
|
||||
maintenance_interval_days: 28
|
||||
maintenance_start_hour: 10
|
||||
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
||||
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
||||
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
||||
# default install. validate.py refuses that config (rule 6), so these
|
||||
# defaults had to become self-consistent with the max_w above rather than
|
||||
# ship a configuration the validator rejects.
|
||||
#
|
||||
# The discharge side is clamped rather than contradicted - it drains at max_w
|
||||
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
||||
# symmetry and to keep the shipped config warning-free.
|
||||
#
|
||||
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
||||
# faster maintenance cycle. Raise max_w FIRST, then these.
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
maintenance_soc_floor: 11
|
||||
maintenance_soc_target: 99
|
||||
maintenance_hold_min: 120
|
||||
|
||||
# --- tariff / capacity tariff (all optional) ------------------------------
|
||||
peak_forecast_entity: ""
|
||||
# ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER
|
||||
# shape's number and nothing more. Declare an `inverters:` roster and each
|
||||
# entry states its own `bms_soc_floor`; this one is not read at all and is
|
||||
# not inherited by any entry. Rule 8 then grades every unit separately, which
|
||||
# is the whole of that card: one cut-out for two packs of different models
|
||||
# protects the shallower pack with the deeper pack's number.
|
||||
bms_soc_floor: 10
|
||||
peak_cap_w: 3500
|
||||
peak_forecast_entity: ""
|
||||
|
||||
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
|
||||
# The peak guard can COMMAND a discharge to hold a billed quarter under
|
||||
# peak_cap_w. Until this card it was bounded from below by nothing but the
|
||||
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
|
||||
@@ -362,31 +241,10 @@ options:
|
||||
# loop-side reserve and is waiting on the product owner. Until that is
|
||||
# answered, the BMS is the only floor under self-consumption.
|
||||
peak_soc_floor: 20
|
||||
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
|
||||
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
|
||||
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
|
||||
# it: such a floor can never bind, the BMS wins, and the status page would
|
||||
# report an energy backstop that does not exist.
|
||||
bms_soc_floor: 10
|
||||
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
||||
# all they have ever done. They are kept, because retiring them would take
|
||||
# the maintenance cycle's only price signal away on a live install before
|
||||
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
||||
# are the cards that replace it, and neither has landed.
|
||||
#
|
||||
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
|
||||
# one site, one of which silently wins, is exactly the "double up" the PM
|
||||
# named; validate.py rule 7 refuses that configuration at startup rather than
|
||||
# letting an operator believe the provider they configured is driving
|
||||
# something. See app/validate.py.
|
||||
price_now_entity: ""
|
||||
price_avg_entity: ""
|
||||
|
||||
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
|
||||
# ==========================================================================
|
||||
# TARIFF - what energy costs, and whether to trade on it
|
||||
# ==========================================================================
|
||||
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
||||
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
||||
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
||||
@@ -405,12 +263,12 @@ options:
|
||||
price_provider: "off"
|
||||
price_markup_eur_kwh: 0.03
|
||||
price_taxes_eur_kwh: 0.06
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
|
||||
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
||||
# which by itself strongly favours self-consumption over export. A scalar, not
|
||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||
export_price_eur_kwh: 0.04
|
||||
|
||||
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
|
||||
# of the installed base is on [a static profile]" - this is what makes those
|
||||
# sites first-class rather than "price_provider: off, good luck".
|
||||
@@ -440,13 +298,28 @@ options:
|
||||
# to fall back to and a dead feed leaves the site with no tariff at all.
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
||||
# which by itself strongly favours self-consumption over export. A scalar, not
|
||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||
export_price_eur_kwh: 0.04
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
||||
# all they have ever done. They are kept, because retiring them would take
|
||||
# the maintenance cycle's only price signal away on a live install before
|
||||
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
||||
# are the cards that replace it, and neither has landed.
|
||||
#
|
||||
# ⚠️ They may NOT be combined with `price_provider`. Two price sources on
|
||||
# one site, one of which silently wins, is exactly the "double up" the PM
|
||||
# named; validate.py rule 7 refuses that configuration at startup rather than
|
||||
# letting an operator believe the provider they configured is driving
|
||||
# something. See app/validate.py.
|
||||
price_now_entity: ""
|
||||
price_avg_entity: ""
|
||||
|
||||
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
|
||||
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
|
||||
# when the spread covers what the round trip costs. Off in the shipped
|
||||
# configuration: this is the first strategy in the add-on that moves energy
|
||||
@@ -467,10 +340,12 @@ options:
|
||||
# owner; the code is correct at any value and no number was picked to make a
|
||||
# trade appear.
|
||||
arbitrage_enabled: false
|
||||
|
||||
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
|
||||
# - the wear on the pack plus the conversion losses. A spread below this is
|
||||
# a cycle that loses money and pack life, so it is not taken.
|
||||
cycle_cost_threshold_eur: 0.10
|
||||
|
||||
# The reserve arbitrage may not spend, and the headroom it may not overfill.
|
||||
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
|
||||
# balancing cycle, and pointing a trading strategy at them would couple two
|
||||
@@ -478,17 +353,189 @@ options:
|
||||
arbitrage_soc_floor: 20
|
||||
arbitrage_soc_ceiling: 95
|
||||
|
||||
# --- site ------------------------------------------------------------------
|
||||
estop_fitted: false
|
||||
# ==========================================================================
|
||||
# MAINTENANCE - the monthly battery recalibration cycle
|
||||
# ==========================================================================
|
||||
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
|
||||
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
|
||||
# so nothing else in the add-on gets an opinion about what a missing
|
||||
# `maintenance_*` key means. There is exactly ONE in-code copy -
|
||||
# `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does
|
||||
# not put this file in the image and the running add-on therefore cannot read
|
||||
# it; test_maintenance.py pins that copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. main.py and validate.py both read the
|
||||
# mirror and type no number of their own.
|
||||
#
|
||||
# Before this card the same defaults were written down three times and had
|
||||
# already disagreed: this block said 2000 while main.py and MaintConfig said
|
||||
# 2500, so an options dict with the key ABSENT passed validation at 2000 and
|
||||
# then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter
|
||||
# contradiction. Changing one of these numbers now moves every consumer.
|
||||
maintenance_enabled: false
|
||||
maintenance_interval_days: 28
|
||||
maintenance_start_hour: 10
|
||||
|
||||
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
||||
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
||||
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
||||
# default install. validate.py refuses that config (rule 6), so these
|
||||
# defaults had to become self-consistent with the max_w above rather than
|
||||
# ship a configuration the validator rejects.
|
||||
#
|
||||
# The discharge side is clamped rather than contradicted - it drains at max_w
|
||||
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
||||
# symmetry and to keep the shipped config warning-free.
|
||||
#
|
||||
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
||||
# faster maintenance cycle. Raise max_w FIRST, then these.
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
maintenance_soc_floor: 11
|
||||
maintenance_soc_target: 99
|
||||
maintenance_hold_min: 120
|
||||
|
||||
# ==========================================================================
|
||||
# SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change
|
||||
# one without re-deriving it
|
||||
# ==========================================================================
|
||||
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
||||
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
||||
# a failsafe that latches until an operator starts control again.
|
||||
#
|
||||
# Both defaults are measured, not chosen. On the captured healthy day
|
||||
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
||||
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
||||
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
||||
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
||||
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
||||
# fault day still trips 13 times. There is no reason to move either number
|
||||
# without a capture that says so. They are options because the AC says
|
||||
# thresholds are defaults rather than constants - not because they are knobs
|
||||
# anyone is expected to turn.
|
||||
#
|
||||
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
||||
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
||||
sign_disagree_w: 300
|
||||
sign_disagree_s: 180
|
||||
|
||||
# ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS
|
||||
# ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades
|
||||
# the SITE's total command against P1 - one meter, one grid connection. This
|
||||
# pair grades EACH inverter's own share against THAT inverter's own measured
|
||||
# power (holding register 0x0518), and it catches the fault the meter cannot
|
||||
# answer for once there are two units: a unit doing the opposite of what it
|
||||
# was told, while its sibling covers for it and P1 reads exactly what a
|
||||
# healthy site reads.
|
||||
#
|
||||
# At ONE inverter these two keys do nothing at all. §6.2's identity makes the
|
||||
# site's total command that inverter's command, so the pair above is already
|
||||
# asking this question of the same actuator, and arming a second detector
|
||||
# would be two latches and two banners for one fault.
|
||||
#
|
||||
# Both defaults are measured on admin/goodwe's two-master rig, over 1890
|
||||
# closed-loop runs in which nothing is faulty, swept across the whole range
|
||||
# this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so
|
||||
# the smallest divergence this can see is exactly the gap at which the control
|
||||
# law itself already says the inverter is not following; 85 s is 8.5x the
|
||||
# worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's
|
||||
# own 30 s link veto, and under half the 180 s the site check takes. See
|
||||
# app/watchdog.py's SIGN_OPTION_DEFAULTS and
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
#
|
||||
# ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason:
|
||||
# a share is bounded to +-max_w, so a threshold at or above it can never fire.
|
||||
# validate.py refuses that config.
|
||||
unit_sign_disagree_w: 250
|
||||
unit_sign_disagree_s: 85
|
||||
|
||||
# ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS
|
||||
# BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the
|
||||
# standing commands is graded against P1, and the trip stops the whole site -
|
||||
# what it catches is the site being asked for the wrong thing, which every
|
||||
# per-inverter check is blind to because each unit is faithfully executing its
|
||||
# share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is
|
||||
# already asking this of the same actuator.
|
||||
#
|
||||
# The threshold is the shipped 300 W, CARRIED, and the reason is that the
|
||||
# argument behind it does not depend on the roster: near zero the sign is
|
||||
# noise, the deadband is 15 W, and there is one P1 whatever is behind it.
|
||||
#
|
||||
# ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s
|
||||
# exists to reject is the loop crossing zero after a load reversal, and the
|
||||
# command has to travel the whole SITE rail at `slew_w` per cycle - a rail
|
||||
# that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in
|
||||
# which nothing is faulty, the worst continuous legitimate opposition at 300 W
|
||||
# is 110 s at one inverter and 215 s at two: carrying 180 s across would have
|
||||
# shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary
|
||||
# 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter,
|
||||
# and still inside every episode of the 2026-08-20 fault day.
|
||||
site_sign_disagree_w: 300
|
||||
site_sign_disagree_s: 325
|
||||
|
||||
# Seconds the meter may keep answering with an UNCHANGED measurement while the
|
||||
# command stands at the rail before the add-on raises an alarm. Both halves
|
||||
# are required: neither one alone is safe, and app/freeze.py carries why.
|
||||
#
|
||||
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
|
||||
# the battery; a false notification costs an alert.
|
||||
#
|
||||
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
|
||||
# only transport that reads the meter's own document. On every other source
|
||||
# the status page says NOT COVERED rather than "armed".
|
||||
#
|
||||
# 300 s is measured, not chosen. Over 2399 s of the live house at the
|
||||
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
|
||||
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
|
||||
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
|
||||
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
|
||||
# still inside the 900 s quarter that bills the capacity tariff.
|
||||
meter_freeze_s: 300
|
||||
|
||||
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
|
||||
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
|
||||
# Dockerfile does not put this file in the image, so the running add-on cannot
|
||||
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. Add a third option in three places -
|
||||
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
|
||||
#
|
||||
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
|
||||
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
|
||||
# and is therefore invisible to every other guard in this add-on. Both numbers
|
||||
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
|
||||
# in which nothing was faulty; circulation.py carries the curve they were read
|
||||
# off and the argument for each. They are NOT sensitivity preferences.
|
||||
#
|
||||
# Both are ignored at one inverter, where the quantity is identically zero.
|
||||
circulation_w: 700
|
||||
circulation_hold_s: 75
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
auto_start: false
|
||||
gain: 0.6
|
||||
slew_w: 1000
|
||||
deadband_w: 15
|
||||
target_grid_w: -10
|
||||
step_w: 10
|
||||
saturation_w: 500
|
||||
saturation_cycles: 3
|
||||
integrator_max_w: 0
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
cycle_floor_s: 15
|
||||
log_level: info
|
||||
|
||||
schema:
|
||||
meter_entity: str
|
||||
meter_invert: bool
|
||||
# ==========================================================================
|
||||
# HARDWARE - the inverter, its actuator and its own sensors
|
||||
# ==========================================================================
|
||||
setpoint_entity: str
|
||||
soc_entity: str
|
||||
batt_entity: str
|
||||
batt_invert: bool
|
||||
setpoint_entity: str
|
||||
|
||||
# CFG-04. THE ONE LIST-OF-OBJECTS IN THIS FILE, and see the options block for
|
||||
# the measurement that says Supervisor 2026.08.0 accepts it: nested types are
|
||||
@@ -512,44 +559,121 @@ schema:
|
||||
- name: str
|
||||
setpoint_entity: str
|
||||
max_w: int(100,5000)
|
||||
bms_soc_floor: int(0,50)
|
||||
soc_entity: str?
|
||||
batt_entity: str?
|
||||
batt_invert: bool?
|
||||
|
||||
estop_fitted: bool
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
||||
meter_entity: str
|
||||
meter_invert: bool
|
||||
|
||||
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
||||
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
||||
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
||||
# quietly wrong number.
|
||||
meter_phases: int(1,3)
|
||||
meter_max_age_s: int(5,300)
|
||||
meter_poll_s: int(1,60)
|
||||
meter_mqtt_topic: str?
|
||||
p1_host: str?
|
||||
meter_poll_s: int(1,60)
|
||||
meter_max_age_s: int(5,300)
|
||||
meter_mqtt_topic: str?
|
||||
p1_import_entity: str?
|
||||
p1_export_entity: str?
|
||||
p1_net_entity: str?
|
||||
p1_phase_import_entities:
|
||||
- str
|
||||
|
||||
p1_phase_export_entities:
|
||||
- str
|
||||
p1_net_entity: str?
|
||||
|
||||
p1_phase_net_entities:
|
||||
- str
|
||||
|
||||
# ==========================================================================
|
||||
# RATINGS AND LIMITS - what this site is allowed to do
|
||||
# ==========================================================================
|
||||
max_w: int(100,5000)
|
||||
gain: float(0.05,1.0)
|
||||
slew_w: int(50,5000)
|
||||
deadband_w: int(0,500)
|
||||
target_grid_w: float(-200,200)
|
||||
step_w: int(1,100)
|
||||
saturation_w: int(100,2000)
|
||||
saturation_cycles: int(1,10)
|
||||
integrator_max_w: int(0,15000)
|
||||
heartbeat_s: int(2,25)
|
||||
stale_input_s: int(5,120)
|
||||
cycle_floor_s: int(5,60)
|
||||
auto_start: bool
|
||||
bms_soc_floor: int(0,50)
|
||||
peak_cap_w: int(500,15000)
|
||||
peak_forecast_entity: str?
|
||||
|
||||
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
|
||||
# a supported way to have no energy bound at all. The ranges OVERLAP on
|
||||
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
|
||||
# rule 8 is what refuses the pair, with a message naming both numbers.
|
||||
peak_soc_floor: int(5,60)
|
||||
|
||||
# ==========================================================================
|
||||
# TARIFF - what energy costs, and whether to trade on it
|
||||
# ==========================================================================
|
||||
# ⚠️ The provider list is written out rather than left as `str`, so the
|
||||
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
|
||||
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
|
||||
# checks reach validate_startup_config() with a dict the Supervisor never saw.
|
||||
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
|
||||
# else, which is AC2.
|
||||
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
|
||||
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
|
||||
# gone below zero (you pay to export at midday in summer), and a schema that
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
# A JSON string rather than a nested schema, deliberately: it is ONE option
|
||||
# key (§14 has one), it survives config.yaml's flat options block, and
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
price_now_entity: str?
|
||||
price_avg_entity: str?
|
||||
|
||||
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
|
||||
# and a negative one is a threshold every spread clears - arbitrage with the
|
||||
# brake removed. app/arbitrage.py refuses it in code too, because
|
||||
# validate_startup_config() is also sim/inject.py's entry point and can be
|
||||
# handed any dict at all.
|
||||
arbitrage_enabled: bool
|
||||
cycle_cost_threshold_eur: float(0,1)
|
||||
arbitrage_soc_floor: int(5,95)
|
||||
arbitrage_soc_ceiling: int(10,100)
|
||||
|
||||
# ==========================================================================
|
||||
# MAINTENANCE - the monthly battery recalibration cycle
|
||||
# ==========================================================================
|
||||
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
|
||||
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
|
||||
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
|
||||
# the Supervisor is what enforces a range and the Supervisor reads only this
|
||||
# file, so a second copy of `int(500,5000)` in Python would be a place to
|
||||
# disagree and nothing else. What is NOT expressible here is a relation
|
||||
# BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the
|
||||
# schema grammar has no way to say so, and that is why validate.py's rule 6 is
|
||||
# written in code instead. Add a ninth maintenance option in three places -
|
||||
# options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth.
|
||||
maintenance_enabled: bool
|
||||
maintenance_interval_days: int(1,90)
|
||||
maintenance_start_hour: int(0,23)
|
||||
maintenance_discharge_w: int(500,5000)
|
||||
maintenance_charge_w: int(500,5000)
|
||||
maintenance_soc_floor: int(5,30)
|
||||
maintenance_soc_target: int(50,100)
|
||||
maintenance_hold_min: int(5,480)
|
||||
|
||||
# ==========================================================================
|
||||
# SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change
|
||||
# one without re-deriving it
|
||||
# ==========================================================================
|
||||
# No "off" value, deliberately: this is a P0 safety check and the schema is
|
||||
# not the place to make disarming it a supported configuration.
|
||||
sign_disagree_w: int(50,5000)
|
||||
@@ -565,6 +689,7 @@ schema:
|
||||
# this range puts the guard outside the regime it can be answered for, and the
|
||||
# floor does not have to be a knee the way `circulation_w`'s does.
|
||||
unit_sign_disagree_w: int(50,5000)
|
||||
|
||||
# The floor of 30 is `inverter.LINK_STALE_S`, the firmware's own veto window:
|
||||
# below it, a unit not executing its share is a LINK fault the add-on already
|
||||
# answers by dropping the unit from the split, and firing inside that window
|
||||
@@ -605,6 +730,7 @@ schema:
|
||||
# fault. A configurable value under 400 W would be a false-alarm generator
|
||||
# that this project's own doctrine (SAFETY-15, SAFETY-19) forbids shipping.
|
||||
circulation_w: int(400,5000)
|
||||
|
||||
# The floor of 30 is `inverter.LINK_STALE_S`: below the firmware's own veto
|
||||
# window a unit still executing a stale share is a LINK fault the add-on
|
||||
# already handles by dropping it from the split, and firing inside that window
|
||||
@@ -613,68 +739,19 @@ schema:
|
||||
# loop must be caught and acted on well inside one.
|
||||
circulation_hold_s: int(30,900)
|
||||
|
||||
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
|
||||
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
|
||||
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
|
||||
# the Supervisor is what enforces a range and the Supervisor reads only this
|
||||
# file, so a second copy of `int(500,5000)` in Python would be a place to
|
||||
# disagree and nothing else. What is NOT expressible here is a relation
|
||||
# BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the
|
||||
# schema grammar has no way to say so, and that is why validate.py's rule 6 is
|
||||
# written in code instead. Add a ninth maintenance option in three places -
|
||||
# options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth.
|
||||
maintenance_enabled: bool
|
||||
maintenance_interval_days: int(1,90)
|
||||
maintenance_start_hour: int(0,23)
|
||||
maintenance_discharge_w: int(500,5000)
|
||||
maintenance_charge_w: int(500,5000)
|
||||
maintenance_soc_floor: int(5,30)
|
||||
maintenance_soc_target: int(50,100)
|
||||
maintenance_hold_min: int(5,480)
|
||||
|
||||
peak_forecast_entity: str?
|
||||
peak_cap_w: int(500,15000)
|
||||
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
|
||||
# a supported way to have no energy bound at all. The ranges OVERLAP on
|
||||
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
|
||||
# rule 8 is what refuses the pair, with a message naming both numbers.
|
||||
peak_soc_floor: int(5,60)
|
||||
bms_soc_floor: int(0,50)
|
||||
price_now_entity: str?
|
||||
price_avg_entity: str?
|
||||
|
||||
# ⚠️ The provider list is written out rather than left as `str`, so the
|
||||
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
|
||||
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
|
||||
# checks reach validate_startup_config() with a dict the Supervisor never saw.
|
||||
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
|
||||
# else, which is AC2.
|
||||
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
# A JSON string rather than a nested schema, deliberately: it is ONE option
|
||||
# key (§14 has one), it survives config.yaml's flat options block, and
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
|
||||
# gone below zero (you pay to export at midday in summer), and a schema that
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
|
||||
# and a negative one is a threshold every spread clears - arbitrage with the
|
||||
# brake removed. app/arbitrage.py refuses it in code too, because
|
||||
# validate_startup_config() is also sim/inject.py's entry point and can be
|
||||
# handed any dict at all.
|
||||
arbitrage_enabled: bool
|
||||
cycle_cost_threshold_eur: float(0,1)
|
||||
arbitrage_soc_floor: int(5,95)
|
||||
arbitrage_soc_ceiling: int(10,100)
|
||||
|
||||
estop_fitted: bool
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
auto_start: bool
|
||||
gain: float(0.05,1.0)
|
||||
slew_w: int(50,5000)
|
||||
deadband_w: int(0,500)
|
||||
target_grid_w: float(-200,200)
|
||||
step_w: int(1,100)
|
||||
saturation_w: int(100,2000)
|
||||
saturation_cycles: int(1,10)
|
||||
integrator_max_w: int(0,15000)
|
||||
heartbeat_s: int(2,25)
|
||||
stale_input_s: int(5,120)
|
||||
cycle_floor_s: int(5,60)
|
||||
log_level: list(trace|debug|info|warning|error)
|
||||
|
||||
@@ -203,6 +203,16 @@ A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
|
||||
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
|
||||
# placeholder for it either - the placeholder era ended on 2026-09-04.
|
||||
B_RATING_HYPOTHESIS = 3000.0
|
||||
# SAFETY-32. Each pack's OWN BMS cut-out percent, now a REQUIRED roster key.
|
||||
# ⚠️ 10 IS MEASURED (register 0x0560 on the reference GW5000S-BP and the shipped
|
||||
# `bms_soc_floor` default); 15 IS SIMULATED - there is no second pack. They
|
||||
# DIFFER on purpose: a fixture whose two per-unit numbers are equal cannot show
|
||||
# a check that read the wrong unit's, which is MUTATION.md habit 1 and the defect
|
||||
# that has now bitten four consecutive cards. Both sit below the shipped
|
||||
# `peak_soc_floor` of 20, so rule 8 accepts this roster.
|
||||
A_BMS_FLOOR = 10
|
||||
B_BMS_FLOOR_SIM = 15
|
||||
|
||||
A_RATING = 5000.0
|
||||
|
||||
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
|
||||
@@ -1418,13 +1428,16 @@ _TWO_OPTS.pop("batt_entity")
|
||||
_TWO_OPTS["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
max_w=A_RATING),
|
||||
max_w=A_RATING, bms_soc_floor=A_BMS_FLOOR),
|
||||
# ⚠️ 3000 W IS A HYPOTHESIS AND IS MARKED AS ONE WHEREVER IT APPEARS. It is
|
||||
# here because it DIFFERS from A's 5000 W, which is the only property any
|
||||
# check below uses. SPIKE-04 measures the real device.
|
||||
# ⚠️ AND SO IS ITS 15 % CUT-OUT (SAFETY-32). There is no second pack to read
|
||||
# register 0x0560 off; the value is SIMULATED, and it differs from A's 10 %
|
||||
# for MUTATION.md habit 1's reason and no other.
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS),
|
||||
max_w=B_RATING_HYPOTHESIS, bms_soc_floor=B_BMS_FLOOR_SIM),
|
||||
]
|
||||
_c9, _hass9 = built(_TWO_OPTS)
|
||||
print(f" the roster built from `inverters:` -> {[repr(i) for i in _c9.inverters]}")
|
||||
@@ -1500,14 +1513,19 @@ _decl9 = dict(ln.strip().lstrip("- ").split(": ", 1)
|
||||
for ln in _entry9.splitlines()[1:] if ": " in ln)
|
||||
print(f" the shipped per-entry schema: {_decl9}")
|
||||
check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
||||
"`setpoint_entity` and `max_w` are REQUIRED - no trailing `?` - so the "
|
||||
"Supervisor refuses a half-written entry before the add-on sees it, and "
|
||||
"an entry can never fall back on a sibling's rating because there is no "
|
||||
"entry without one",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "soc_entity",
|
||||
"batt_entity", "batt_invert"}
|
||||
"`setpoint_entity`, `max_w` and `bms_soc_floor` are REQUIRED - no "
|
||||
"trailing `?` - so the Supervisor refuses a half-written entry before the "
|
||||
"add-on sees it, and an entry can never fall back on a sibling's rating "
|
||||
"or on a sibling's BMS cut-out because there is no entry without one. "
|
||||
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
|
||||
"`int(0,50)?`, and the difference is the whole of that card: an optional "
|
||||
"per-pack cut-out is a DEFAULTED one, and a default is one number for "
|
||||
"two batteries - the shallower pack protected by the deeper pack's BMS",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||
"soc_entity", "batt_entity", "batt_invert"}
|
||||
and not any(_decl9[k].endswith("?")
|
||||
for k in ("name", "setpoint_entity", "max_w"))
|
||||
for k in ("name", "setpoint_entity", "max_w",
|
||||
"bms_soc_floor"))
|
||||
and all(_decl9[k].endswith("?")
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert")))
|
||||
|
||||
@@ -1589,9 +1607,11 @@ check("...and two inverters on two DIFFERENT entities are accepted, because "
|
||||
"that is the configuration this card exists for and a validator that "
|
||||
"fires on the legitimate case gets switched off",
|
||||
validate_startup_config(_opts(setpoint_entity="", inverters=[
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING),
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING,
|
||||
bms_soc_floor=A_BMS_FLOOR),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
max_w=B_RATING_HYPOTHESIS)])) == [])
|
||||
max_w=B_RATING_HYPOTHESIS,
|
||||
bms_soc_floor=B_BMS_FLOOR_SIM)])) == [])
|
||||
|
||||
# --- the rest of rule 9 -------------------------------------------------------
|
||||
_p_flat = validate_startup_config(_opts(inverters=[
|
||||
|
||||
@@ -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")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
||||
"""UX-01 - the Configuration tab's labels, and the check that stops them rotting.
|
||||
|
||||
RUN: python3 -B test_translations.py (no third-party imports; the add-on
|
||||
CI image is Alpine + aiohttp + paho)
|
||||
|
||||
WHAT THIS FILE IS FOR. Before UX-01 there was no `translations/` directory at
|
||||
all, so Home Assistant rendered the raw option KEYS - `unit_sign_disagree_w`,
|
||||
`saturation_cycles`, `integrator_max_w` - for all seventy options, in
|
||||
`schema:` order, with no description. The two files this grades are what turn
|
||||
that into sentences, and the highest-value half of them is not the label: it is
|
||||
the sentence that says WHETHER TO TOUCH THE VALUE, because the tab presents a
|
||||
constant derived from 1890 closed-loop test runs and the operator's own inverter
|
||||
rating as the same kind of field.
|
||||
|
||||
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE
|
||||
CITATIONS - that every option has a name and a description in both languages,
|
||||
that no translation names an option that does not exist, and that each derived
|
||||
constant's English description cites the record it was derived from. It does NOT
|
||||
grade whether a sentence is true, whether the Dutch is good Dutch, or whether
|
||||
the result looks right on screen. The last of those is measured, not asserted,
|
||||
by `tools/supervisor_shape_probe.py` section F, which loads these files with
|
||||
Supervisor 2026.08.0's OWN loader out of the release image, runs its OWN
|
||||
`UiOptions`, replays the frontend's label resolution as the shipped bundle
|
||||
writes it, and prints every rendered row. That transcript is the evidence; this
|
||||
file is the gate that keeps it true.
|
||||
|
||||
⚠️ AND THE PARSER IS THE OTHER HALF OF THAT PAIR. There is no PyYAML in the CI
|
||||
image and a check that skips on ImportError is not a check, so
|
||||
`tools/translations_lint.py` parses these files by hand. A hand parser that
|
||||
quietly disagrees with the real loader is exactly the defect this repository
|
||||
keeps finding, so the probe imports THAT SAME MODULE and asserts, key by key and
|
||||
string by string, that it returns what `supervisor.store.data.
|
||||
_read_app_translations()` returns. Part 5 below re-states the arrangement so a
|
||||
reader of this file is told where the licence for the hand parser comes from.
|
||||
|
||||
MUTATION.md is the method for the ledger in part 6; `tools/mutation_run.sh` is
|
||||
the harness, and every row there was RUN rather than reasoned about.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import translations_lint as TL # noqa: E402
|
||||
|
||||
FAILED = 0
|
||||
def check(what, ok):
|
||||
global FAILED
|
||||
print((" ok " if ok else " FAIL ") + what)
|
||||
if not ok:
|
||||
FAILED += 1
|
||||
|
||||
|
||||
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
ORDER, NESTED = TL.config_surface(CFG_TEXT)
|
||||
LANGS = ("en", "nl")
|
||||
TR = {lg: TL.parse_translation_file(HERE / "translations" / f"{lg}.yaml")
|
||||
for lg in LANGS}
|
||||
|
||||
print("UX-01 - add-on option translations\n")
|
||||
print("1. the surface being described")
|
||||
print(f" config.yaml declares {len(ORDER)} top-level options under `schema:` "
|
||||
f"and {sum(len(v) for v in NESTED.values())} per-entry key(s) under "
|
||||
f"{sorted(NESTED)}")
|
||||
# ⚠️ A FLOOR, NOT AN EQUALITY. This is the "did the reader read anything at all"
|
||||
# line: `config_surface()` returning an empty list would make every coverage
|
||||
# check below vacuously true, which is the third failure mode in MUTATION.md -
|
||||
# the assertion runs, the sentence is true, and the data cannot reach the
|
||||
# failure it names.
|
||||
check(f"the option surface was actually read - {len(ORDER)} options, not zero",
|
||||
len(ORDER) >= 60 and "inverters" in ORDER and "max_w" in ORDER)
|
||||
check("...and the repeatable `inverters:` section's per-entry keys were read "
|
||||
"too, which is the half a top-level-only reader would miss",
|
||||
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
|
||||
and len(NESTED["inverters"]) == 7)
|
||||
|
||||
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
|
||||
"languages")
|
||||
for lg in LANGS:
|
||||
problems = TL.audit(CFG_TEXT, TR[lg], lg)
|
||||
print(f" {lg}: {len(TR[lg]['configuration'])} options translated, "
|
||||
f"{len(problems)} problem(s)")
|
||||
for p in problems[:10]:
|
||||
print(" - " + p)
|
||||
check(f"`{lg}` covers config.yaml exactly: no option without a translation, "
|
||||
f"no translation without an option, every one with a name AND a "
|
||||
f"description. Problems: {problems[:3]}", not problems)
|
||||
|
||||
# ⚠️ THE `nl` HALF IS NOT OPTIONAL EVEN THOUGH THE FALLBACK IS. The frontend
|
||||
# resolves `translations[lang]?.name || translations["en"]?.name || key`, PER
|
||||
# KEY - measured off the shipped bundle, see the probe - so a MISSING nl key
|
||||
# degrades to English and a WRONG one does not degrade at all. The card asks for
|
||||
# both languages complete, and complete is a thing a check can hold.
|
||||
check("...and the two files describe the SAME option set, so nothing on the "
|
||||
"owner's Dutch screen quietly falls back to English",
|
||||
set(TR["en"]["configuration"]) == set(TR["nl"]["configuration"]))
|
||||
check("...including inside the repeatable section",
|
||||
set(TR["en"]["configuration"]["inverters"]["fields"])
|
||||
== set(TR["nl"]["configuration"]["inverters"]["fields"]))
|
||||
check("...and no Dutch string was left as a copy of the English one, which is "
|
||||
"what an untranslated key looks like when it is present rather than "
|
||||
"absent",
|
||||
not [k for k in ORDER
|
||||
if TR["en"]["configuration"][k]["description"]
|
||||
== TR["nl"]["configuration"][k]["description"]])
|
||||
|
||||
print("\n3. ⭐ the description says WHETHER TO TOUCH IT")
|
||||
# ⚠️ THE HIGHEST-VALUE ITEM IN THE CARD, AND THE ONE A COVERAGE COUNT CANNOT
|
||||
# REACH. Sixty-odd descriptions that all say "the threshold, in watts" would
|
||||
# pass part 2 completely. What makes this surface safe is that a DERIVED
|
||||
# constant names the campaign it came from and says the value is not a knob,
|
||||
# while a number that really is the operator's says so too.
|
||||
#
|
||||
# ⚠️ AND THIS IS A TEXT CHECK, WHICH IS WEAK ON PURPOSE AND IS SAID TO BE. It
|
||||
# cannot know whether a citation is TRUE; it can only know that the citation is
|
||||
# there and names the right record, so that deleting the provenance - or
|
||||
# renaming the card it points at - goes red instead of shipping. The table is
|
||||
# written out rather than derived because there is nothing in config.yaml that
|
||||
# distinguishes a derived constant from an operator's number; that distinction
|
||||
# is the whole of what this card had to add.
|
||||
DERIVED = {
|
||||
"sign_disagree_w": ("SAFETY-02", "MEASURED"),
|
||||
"sign_disagree_s": ("SAFETY-02", "MEASURED"),
|
||||
"unit_sign_disagree_w": ("SAFETY-30", "1890"),
|
||||
"unit_sign_disagree_s": ("SAFETY-30", "1890"),
|
||||
"site_sign_disagree_w": ("SAFETY-30", "CARRIED"),
|
||||
"site_sign_disagree_s": ("SAFETY-30", "1890"),
|
||||
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
|
||||
"circulation_w": ("SAFETY-31", "924"),
|
||||
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
|
||||
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
|
||||
"bms_soc_floor": ("0x0560", "MEASURED"),
|
||||
"integrator_max_w": ("MEASURED", "Do not raise it"),
|
||||
"gain": ("MEASURED ON", "do not raise it"),
|
||||
}
|
||||
_uncited = []
|
||||
for key, needles in sorted(DERIVED.items()):
|
||||
desc = TR["en"]["configuration"][key]["description"]
|
||||
for needle in needles:
|
||||
if needle not in desc:
|
||||
_uncited.append(f"{key} does not cite {needle!r}")
|
||||
print(f" {len(DERIVED)} derived constants must cite their campaign; "
|
||||
f"{len(_uncited)} do not")
|
||||
check(f"every derived constant's English description names WHERE THE NUMBER "
|
||||
f"CAME FROM, so an operator reading the tab can tell it apart from their "
|
||||
f"own inverter rating. Uncited: {_uncited}", not _uncited)
|
||||
# The other direction, and it matters as much: a number that IS the operator's
|
||||
# must not be dressed up as derived, or the labels stop meaning anything.
|
||||
_theirs = ["max_w", "peak_cap_w"]
|
||||
check("...and the numbers that really are the OPERATOR'S say so instead of "
|
||||
"carrying a citation - `max_w` and `peak_cap_w` are the two the card "
|
||||
"names, and both read 'THIS IS YOUR NUMBER'",
|
||||
all("THIS IS YOUR NUMBER" in TR["en"]["configuration"][k]["description"]
|
||||
for k in _theirs))
|
||||
check("...and the one threshold that is NOT derived from a measurement admits "
|
||||
"it rather than borrowing the others' authority - "
|
||||
"`cycle_cost_threshold_eur` is an open product question (OPT-08)",
|
||||
"NOT DERIVED FROM A MEASUREMENT"
|
||||
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
|
||||
and "OPT-08"
|
||||
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"])
|
||||
|
||||
print("\n4. task ordering, and the labels that carry it")
|
||||
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced
|
||||
# flag, no units on the input. Grouping is approximated by ORDER plus label text
|
||||
# and by nothing else, so both halves are graded: the label prefix, and that the
|
||||
# prefixes appear in the file as CONTIGUOUS RUNS in the task order. A group
|
||||
# whose members are scattered renders as a jumble however good each label is.
|
||||
GROUPS = ["Hardware", "Meter", "Limits", "Tariff", "Maintenance", "Safety",
|
||||
"Advanced"]
|
||||
NL_GROUPS = {"Hardware": "Hardware", "Meter": "Meter", "Limits": "Grenzen",
|
||||
"Tariff": "Tarief", "Maintenance": "Onderhoud",
|
||||
"Safety": "Veiligheid", "Advanced": "Geavanceerd"}
|
||||
_prefixes = []
|
||||
for key in ORDER:
|
||||
name = TR["en"]["configuration"][key]["name"]
|
||||
_prefixes.append(name.split(" \u00b7 ")[0] if " \u00b7 " in name else f"<none:{key}>")
|
||||
_runs = [p for i, p in enumerate(_prefixes) if i == 0 or p != _prefixes[i - 1]]
|
||||
print(f" the rendered order groups into: {_runs}")
|
||||
check(f"every label carries a group prefix, so the flat list reads as groups. "
|
||||
f"Missing: {[p for p in _prefixes if p.startswith('<none:')]}",
|
||||
not [p for p in _prefixes if p.startswith("<none:")])
|
||||
check(f"...and each group is ONE CONTIGUOUS RUN in the rendered order, in the "
|
||||
f"task order the card asks for - hardware, meter, ratings and limits, "
|
||||
f"tariff, maintenance, safety, advanced. Got: {_runs}", _runs == GROUPS)
|
||||
_nl_bad = [k for k in ORDER
|
||||
if NL_GROUPS.get(TR["en"]["configuration"][k]["name"].split(" \u00b7 ")[0])
|
||||
!= TR["nl"]["configuration"][k]["name"].split(" \u00b7 ")[0]]
|
||||
check(f"...and the Dutch labels carry the SAME grouping under Dutch names, so "
|
||||
f"the two languages do not read as two different layouts. Off: {_nl_bad}",
|
||||
not _nl_bad)
|
||||
# ⚠️ THE ORDER IS THE `schema:` BLOCK'S, NOT THE `options:` BLOCK'S, AND THAT IS
|
||||
# A MEASUREMENT. `App.schema_ui` is `UiOptions(coresys)(data["schema"])`, so the
|
||||
# tab renders the schema half in its own order. Asserted here because the whole
|
||||
# of part 4 is about an ordering that would otherwise be graded on the wrong
|
||||
# half of the file - and because the two halves of config.yaml were already in
|
||||
# different orders before this card.
|
||||
# ⚠️ ANCHORED ON THE INDENTATION AND SCOPED TO THE `options:` BLOCK. The file
|
||||
# starts with the add-on manifest, whose `services:` list carries ` - mqtt:want`
|
||||
# at two spaces with a colon in it - a looser reader picks that up as an option
|
||||
# called `- mqtt` and the comparison below then fails for a reason that has
|
||||
# nothing to do with what it is watching. Measured, on the first run.
|
||||
import re as _re # noqa: E402
|
||||
_opt_half = CFG_TEXT[CFG_TEXT.index("\noptions:\n"):CFG_TEXT.index("\nschema:")]
|
||||
_opt_order = _re.findall(r"^ ([a-z_0-9]+):", _opt_half, _re.M)
|
||||
check("config.yaml's two halves now declare the same options in the same "
|
||||
"order, so the file reads the way the tab renders even though only the "
|
||||
"schema half decides it", _opt_order == ORDER)
|
||||
|
||||
print("\n5. the parser is strict, so a file it half-read cannot pass")
|
||||
# ⚠️ THE FAILURE THIS PART EXISTS FOR. A permissive parser answers "no problems"
|
||||
# for a file it silently skipped half of - the same green-and-not-watching
|
||||
# defect one layer down. So every shape outside the subset UX-01 writes must
|
||||
# RAISE, and that is graded with real malformed input rather than asserted.
|
||||
BAD = [
|
||||
("a tab-indented line", "configuration:\n\tmeter_entity:\n"),
|
||||
("three-space indent", "configuration:\n meter_entity:\n"),
|
||||
("a bare unquoted scalar", 'configuration:\n a:\n name: hello\n'),
|
||||
("a single-quoted scalar", "configuration:\n a:\n name: 'hello'\n"),
|
||||
("a list item", 'configuration:\n a:\n - "x"\n'),
|
||||
("a duplicate key", 'configuration:\n a:\n name: "x"\n name: "y"\n'),
|
||||
("an over-indented child", 'configuration:\n a:\n'),
|
||||
("a line that is not a key at all", "configuration:\n just some prose\n"),
|
||||
("an unsupported escape", 'configuration:\n a:\n name: "x\\ty"\n'),
|
||||
]
|
||||
_accepted = []
|
||||
for what, text in BAD:
|
||||
try:
|
||||
TL.parse_translation_text(text, what)
|
||||
_accepted.append(what)
|
||||
except TL.TranslationSyntaxError:
|
||||
pass
|
||||
check(f"every shape outside the accepted subset RAISES rather than being "
|
||||
f"skipped. Silently accepted: {_accepted}", not _accepted)
|
||||
check("...and the accepted subset really does round-trip the shipped files - "
|
||||
"70 options and 7 per-entry keys came back out of each, which is what "
|
||||
"says the strictness above is not just refusing everything",
|
||||
all(len(TR[lg]["configuration"]) == len(ORDER)
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
|
||||
for lg in LANGS))
|
||||
print(" ⚠️ the licence for this hand parser is NOT this file. "
|
||||
"tools/supervisor_shape_probe.py section F loads the same two files with "
|
||||
"`supervisor.store.data._read_app_translations()` out of the 2026.08.0 "
|
||||
"release image and asserts, key by key and string by string, that this "
|
||||
"module returns the same thing. Re-run it when either changes.")
|
||||
|
||||
print("\n6. MUTATION: the check is run against broken inputs, both directions")
|
||||
# ⚠️ THIS IS THE PART THE CARD ASKS FOR IN SO MANY WORDS, AND IT IS RUN RATHER
|
||||
# THAN DESCRIBED. A coverage check is worth exactly what it catches, so each
|
||||
# mutation below is APPLIED to a copy of the real parsed translations and the
|
||||
# audit is required to NAME the key it broke - not merely to be non-empty. A
|
||||
# check that goes red for the wrong reason is a check that will go green for the
|
||||
# wrong reason later.
|
||||
import copy # noqa: E402
|
||||
|
||||
|
||||
def _mutate(what, fn, must_name):
|
||||
tr = copy.deepcopy(TR["en"])
|
||||
fn(tr)
|
||||
problems = TL.audit(CFG_TEXT, tr, "en")
|
||||
named = [p for p in problems if must_name in p]
|
||||
print(f" {what}\n -> {len(problems)} problem(s); "
|
||||
f"{(named[0][:120] + '...') if named else 'NONE NAMED ' + must_name}")
|
||||
check(f"MUTATION: {what} -> the audit REFUSES and names `{must_name}`",
|
||||
bool(named))
|
||||
|
||||
|
||||
_mutate("delete one option's whole translation (`saturation_cycles`)",
|
||||
lambda tr: tr["configuration"].pop("saturation_cycles"),
|
||||
"saturation_cycles")
|
||||
_mutate("delete one option's description but keep its name (`meter_phases`)",
|
||||
lambda tr: tr["configuration"]["meter_phases"].pop("description"),
|
||||
"meter_phases")
|
||||
_mutate("blank one option's description (`gain`) - present but empty is not "
|
||||
"covered either",
|
||||
lambda tr: tr["configuration"]["gain"].update(description=" "),
|
||||
"gain")
|
||||
_mutate("translate an option that does not exist (`saturation_cyles`, one "
|
||||
"letter out - the rename this check exists for)",
|
||||
lambda tr: tr["configuration"].update(
|
||||
saturation_cyles={"name": "x", "description": "y"}),
|
||||
"saturation_cyles")
|
||||
_mutate("delete one PER-ENTRY key of the repeatable section (`bms_soc_floor`)",
|
||||
lambda tr: tr["configuration"]["inverters"]["fields"].pop("bms_soc_floor"),
|
||||
"bms_soc_floor")
|
||||
_mutate("invent a per-entry key the schema does not declare (`max_kw`)",
|
||||
lambda tr: tr["configuration"]["inverters"]["fields"].update(
|
||||
max_kw={"name": "x", "description": "y"}),
|
||||
"max_kw")
|
||||
_mutate("drop the whole `fields:` block from the repeatable section",
|
||||
lambda tr: tr["configuration"]["inverters"].pop("fields"),
|
||||
"inverters")
|
||||
_mutate("hang a `fields:` block off an option that is not a repeatable section",
|
||||
lambda tr: tr["configuration"]["max_w"].update(
|
||||
fields={"x": {"name": "y"}}),
|
||||
"max_w")
|
||||
# ⚠️ AND THE TWO THAT COST THE WHOLE FILE RATHER THAN ONE LABEL. Supervisor
|
||||
# validates each translation file through SCHEMA_APP_TRANSLATIONS and, on
|
||||
# refusal, LOGS A WARNING AND DROPS ALL OF IT - so a shape it rejects does not
|
||||
# lose one description, it silently returns the entire tab to raw keys, and the
|
||||
# only trace is one line in a log nobody is reading. Neither of these is
|
||||
# reachable through the `configuration:` coverage above, which is exactly why
|
||||
# they are here.
|
||||
_mutate("a top-level block Supervisor does not read (`netwrok:`) - the file is "
|
||||
"DROPPED WHOLE, not partly",
|
||||
lambda tr: tr.update(netwrok={"x": "y"}), "netwrok")
|
||||
_mutate("a `network:` block whose value is a mapping rather than a string - "
|
||||
"same consequence, every label in the file gone",
|
||||
lambda tr: tr.update(network={"8099/tcp": {"x": "y"}}), "network")
|
||||
|
||||
# ⚠️ AND THE MUTATION THAT MATTERS MOST: A NEW OPTION ARRIVES. This is the rot
|
||||
# the acceptance criterion is written about - somebody adds a key to config.yaml
|
||||
# in six months and the tab renders it raw. The mutation is applied to the
|
||||
# CONFIG side rather than the translation side, because that is the direction it
|
||||
# happens in.
|
||||
_grown = CFG_TEXT.replace("\n log_level: list(", "\n new_option_s: int(1,9)\n log_level: list(")
|
||||
check("the config-side mutation really did land - a new option in the schema "
|
||||
"half that no translation mentions", _grown != CFG_TEXT
|
||||
and "new_option_s" in TL.config_surface(_grown)[0])
|
||||
_grow_problems = TL.audit(_grown, TR["en"], "en")
|
||||
print(f" a new option added to config.yaml -> {_grow_problems[:1]}")
|
||||
check("MUTATION: adding an option to config.yaml and forgetting the "
|
||||
"translation goes RED, naming the key - which is the whole of the "
|
||||
"acceptance criterion about this rotting",
|
||||
any("new_option_s" in p for p in _grow_problems))
|
||||
|
||||
# ⚠️ THE NEGATIVE CONTROL, WITHOUT WHICH THE EIGHT ROWS ABOVE PROVE NOTHING. An
|
||||
# audit that returned a problem for every input would have "passed" all of them.
|
||||
check("...and the UNMUTATED input is still clean, so the rows above are the "
|
||||
"mutations speaking and not a check that always complains",
|
||||
not TL.audit(CFG_TEXT, TR["en"], "en")
|
||||
and not TL.audit(CFG_TEXT, TR["nl"], "nl"))
|
||||
|
||||
print("\n7. no behaviour and no value moved")
|
||||
# ⚠️ UX-01 IS A PRESENTATION CARD. It reorders config.yaml and adds two files;
|
||||
# it must not move a single default. The whole options half is compared against
|
||||
# the schema half key by key here, and every other suite in this directory pins
|
||||
# the actual values by reading this same file - test_maintenance.py's eight,
|
||||
# test_watchdog.py's six, test_price.py's seven, test_freeze.py's, and
|
||||
# test_peakguard.py's. This line is the cheap one that says the reorder did not
|
||||
# lose or duplicate a key on the way.
|
||||
check("no option was lost, gained or duplicated by the reordering: the two "
|
||||
"halves of config.yaml hold the same 70 keys, once each",
|
||||
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
|
||||
check("...and `inverters: []` is still shipped in the options half, which is "
|
||||
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
|
||||
"(measured; see DECISIONS/CFG-04-inverter-roster.md B)",
|
||||
"\n inverters: []\n" in _opt_half)
|
||||
check("...and the schema half still declares the NESTED per-entry `max_w:` "
|
||||
"BEFORE the flat one, so test_inverter.py part 12's first-match trap "
|
||||
"stays baited rather than sorted out by this card's reordering",
|
||||
CFG_TEXT[CFG_TEXT.index("\nschema:"):].index(" max_w: int(")
|
||||
< CFG_TEXT[CFG_TEXT.index("\nschema:"):].index("\n max_w: int("))
|
||||
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"{FAILED} FAILED")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -204,12 +204,17 @@ check("...and the refusal names the actuator and every writer aimed at it, so "
|
||||
_PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"}
|
||||
_PER_INVERTER_OK["inverters"] = [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint",
|
||||
"max_w": 2000},
|
||||
"max_w": 2000, "bms_soc_floor": 10},
|
||||
# ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated,
|
||||
# not a rating measured off a second device - SPIKE-04 measures that when
|
||||
# there is one to measure. Nothing in this file depends on the number.
|
||||
# ⚠️ SAFETY-32 made `bms_soc_floor` a REQUIRED per-entry key, and the two
|
||||
# values here DIFFER (10 / 15) rather than being copied. 15 % is SIMULATED -
|
||||
# there is no second pack to read a cut-out off - and the reason it is not
|
||||
# simply 10 is MUTATION.md habit 1: a fixture whose two per-unit numbers are
|
||||
# equal cannot show a check reading the wrong unit's.
|
||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b",
|
||||
"max_w": 2000}]
|
||||
"max_w": 2000, "bms_soc_floor": 15}]
|
||||
check("two inverters with their own entities are ACCEPTED - one writer each, "
|
||||
"no shared actuator, and that is CFG-04's whole purpose",
|
||||
validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == [])
|
||||
@@ -919,6 +924,248 @@ for _name, _got in sorted(_prov.items()):
|
||||
f"against, and the 4324 W the DC reading would make it {_got}",
|
||||
all(_got.values()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position")
|
||||
# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was
|
||||
# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in
|
||||
# the three lines above:
|
||||
#
|
||||
# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN
|
||||
# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN
|
||||
# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN
|
||||
# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN
|
||||
#
|
||||
# NONE of them is an equivalent mutant: each one deletes a safety refusal.
|
||||
# `max_w: 5000` on inverter-a beside `2000` on inverter-b with
|
||||
# `sign_disagree_w: 3000` is refused by the shipped validator with "the
|
||||
# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one
|
||||
# of the four - the site starts, with a watchdog that reports `armed` on the
|
||||
# status page and cannot trip on pack B. That is rule 5's own failure mode
|
||||
# reached THROUGH rule 5.
|
||||
#
|
||||
# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now
|
||||
# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that
|
||||
# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N
|
||||
# members cannot separate two reductions that agree on N members, and adding a
|
||||
# member moves the hole instead of closing it.
|
||||
#
|
||||
# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of
|
||||
# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]`
|
||||
# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a
|
||||
# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so
|
||||
# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an
|
||||
# absence from a list nobody re-derived.
|
||||
#
|
||||
# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production
|
||||
# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at
|
||||
# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built
|
||||
# on the real pair would be degenerate on exactly the axis these checks measure.
|
||||
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing
|
||||
# in this section is measured off a second unit; the rosters are simulated and
|
||||
# are labelled so.
|
||||
_BIG, _SMALL = 5000.0, 2000.0
|
||||
_RATE = 3000.0 # strictly between them, which is the point
|
||||
|
||||
|
||||
def _roster_n(caps, **over):
|
||||
"""A roster of len(caps) units, unit-0..unit-N, each with its own cap."""
|
||||
o = base(**over)
|
||||
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
|
||||
o.pop(k, None)
|
||||
o["inverters"] = [
|
||||
dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}",
|
||||
soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}",
|
||||
max_w=c, bms_soc_floor=10)
|
||||
for i, c in enumerate(caps)]
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
# The three rules that reduce over `unit_caps_indexed()`, each as
|
||||
# (option name, the token its refusal is recognised by).
|
||||
_CAP_RULES = (
|
||||
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
|
||||
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
|
||||
("maintenance_charge_w", "maintenance charge in a cheap window"),
|
||||
)
|
||||
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
|
||||
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
|
||||
|
||||
# THE SET: width x position of the one small cap.
|
||||
_SET = []
|
||||
for _n in (2, 3, 4, 5):
|
||||
for _pos in range(_n):
|
||||
_caps = [_BIG] * _n
|
||||
_caps[_pos] = _SMALL
|
||||
_SET.append((_n, _pos, tuple(_caps)))
|
||||
|
||||
# habit 1: the PRECONDITION as its own check. If a later edit makes the caps
|
||||
# equal, this goes red rather than the claims below going quietly vacuous.
|
||||
check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that "
|
||||
"cap is at the stated position, and the rate under test sits STRICTLY "
|
||||
"between it and the others - two equal caps could not tell `min` from "
|
||||
"`first` from `last` and the whole set would be measuring nothing",
|
||||
len(_SET) == 14
|
||||
and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG
|
||||
for _n2, p, c in _SET)
|
||||
and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4})
|
||||
|
||||
|
||||
def _verdict(opts, token):
|
||||
"""(refused?, the unit the message NAMES, the cap it QUOTES)."""
|
||||
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
|
||||
if not ps:
|
||||
return (False, None, None)
|
||||
m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0])
|
||||
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
|
||||
|
||||
|
||||
# --- the shipped answer, on every member of the set -------------------------
|
||||
_rows = []
|
||||
for _opt, _token in _CAP_RULES:
|
||||
for _n, _pos, _caps in _SET:
|
||||
_o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE}))
|
||||
_rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token)))
|
||||
print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs "
|
||||
f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/"
|
||||
f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):")
|
||||
for _opt in sorted({r[0] for r in _rows}):
|
||||
_got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt]
|
||||
print(f" {_opt:<22} " + " ".join(
|
||||
f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}"
|
||||
for n, p, (ok, u, _c) in _got))
|
||||
|
||||
check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, "
|
||||
"and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with "
|
||||
"the small cap at every position in turn. A reduction that answered "
|
||||
"`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` "
|
||||
"misses at least one member of this set, which is what four hand-run "
|
||||
"mutations against a 19/19 green suite proved the single `[5000, 2000]` "
|
||||
"fixture could not do",
|
||||
all(ok and unit == f"unit-{p}" and cap == _SMALL
|
||||
for _o2, _n2, p, _c2, (ok, unit, cap) in _rows))
|
||||
|
||||
check("...and it is a COMPARISON, not a blanket refusal of rosters: the same "
|
||||
"three rates against a roster whose caps can ALL take them start "
|
||||
"cleanly, at every width. Without this the line above is satisfied by a "
|
||||
"validator that refuses everything (habit 2 in the negative direction)",
|
||||
all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})),
|
||||
tok) == (False, None, None)
|
||||
for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5)))
|
||||
|
||||
# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ----------------
|
||||
# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the
|
||||
# validator saw, and its (unit, cap) answer is compared against what the shipped
|
||||
# validator actually said on that roster. A candidate that agrees everywhere is
|
||||
# a reduction this fixture set CANNOT distinguish from the shipped one, and it
|
||||
# is printed as a SURVIVOR rather than left out of a table nobody re-derived.
|
||||
def _pick(caps, fn):
|
||||
ix = list(enumerate(caps))
|
||||
try:
|
||||
i, c = fn(ix)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return (f"unit-{i}", c)
|
||||
|
||||
|
||||
_FAMILY = {
|
||||
"min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]),
|
||||
"max": lambda ix: max(ix, key=lambda p: p[1]),
|
||||
"first": lambda ix: ix[0],
|
||||
"last": lambda ix: ix[-1],
|
||||
"min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]),
|
||||
"min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]),
|
||||
"min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]),
|
||||
"min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]),
|
||||
"min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]),
|
||||
"min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]),
|
||||
"min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]),
|
||||
"mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)),
|
||||
}
|
||||
_survivors = []
|
||||
print(" the family table - each candidate against what the SHIPPED "
|
||||
"validator answered on the same roster:")
|
||||
for _name, _fn in _FAMILY.items():
|
||||
_misses = []
|
||||
for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows:
|
||||
_pred = _pick(list(_caps), _fn)
|
||||
_pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1]
|
||||
if _opt == "maintenance_charge_w":
|
||||
_pred_refuses = (_pred is not None and _pred[1] > 0
|
||||
and _RATE > _pred[1])
|
||||
_said = ((True, _pred[0], _pred[1]) if _pred_refuses
|
||||
else (False, None, None))
|
||||
if _said != (_ok, _unit, _cap):
|
||||
_misses.append(f"{_opt[:4]}/n{_n}p{_pos}")
|
||||
if not _misses and _name != "min (SHIPPED)":
|
||||
_survivors.append(_name)
|
||||
print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}")
|
||||
check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. "
|
||||
"Eleven candidates - max, first, last, the mean, and every positional "
|
||||
"slice this card has actually been defeated by - are each computed "
|
||||
"against what the real validator answered on the same roster, and every "
|
||||
"one of them differs somewhere in the set. This is the check that would "
|
||||
"have taken PR #48 red four rounds ago: a member no fixture kills is "
|
||||
"PRINTED AS A NAMED SURVIVOR here instead of being absent from a list "
|
||||
f"nobody re-derived. survivors: {_survivors}",
|
||||
_survivors == [])
|
||||
|
||||
# --- the ROSTER OF ONE, where every one of them is the same thing -----------
|
||||
_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()}
|
||||
print(f" at a ROSTER OF ONE the twelve candidates answer: "
|
||||
f"{sorted({str(v) for v in _at_one.values()})}")
|
||||
check("...and at a ROSTER OF ONE every candidate that still has a member to "
|
||||
"reduce answers THE SAME THING - which is exactly why no "
|
||||
"single-inverter fixture in this repository could ever have caught any "
|
||||
"of this, and why the shipped single-inverter refusals are what they "
|
||||
"always were. The only two that do NOT coincide are `caps[1:]` and "
|
||||
"`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are "
|
||||
"crashes rather than silent wrong answers, so they are not survivors "
|
||||
"there either",
|
||||
{v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)}
|
||||
and {n for n, v in _at_one.items() if v is None}
|
||||
== {"min(caps[1:])", "min(caps[:-1])"}
|
||||
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
|
||||
"SAFETY-02 sign-disagreement")[0])
|
||||
|
||||
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
|
||||
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
|
||||
# PIN - and said that way on purpose, because it is the shape of what the pin
|
||||
# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was
|
||||
# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines
|
||||
# under a branch that had already stopped doing that. Both directions were
|
||||
# measured before the fix and both are asserted here.
|
||||
_leftover = _roster_n([_SMALL, _SMALL],
|
||||
**dict(_QUIET, unit_sign_disagree_w=_RATE))
|
||||
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
|
||||
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
|
||||
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
|
||||
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
|
||||
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
|
||||
"WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over "
|
||||
"from a single-inverter install and `unit_sign_disagree_w: 3000` used to "
|
||||
"produce ZERO problems - the site started, the status page said `armed`, "
|
||||
"and the watchdog could not trip on EITHER unit. It is refused now, "
|
||||
"against the weakest unit's own cap, and the message names that unit",
|
||||
_lv == (True, "unit-0", _SMALL))
|
||||
check("...and the over-refusal in the OTHER direction is gone too: a roster of "
|
||||
"two 5000 W units with NO flat `max_w` was refused at 3000 W against a "
|
||||
"2000 W DEFAULT the operator never wrote, for a threshold both units can "
|
||||
"reach. It starts",
|
||||
_verdict(_roster_n([_BIG, _BIG],
|
||||
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
|
||||
"SAFETY-30 per-inverter sign-disagreement")
|
||||
== (False, None, None))
|
||||
check("...and the flat single-inverter config it always refused is refused "
|
||||
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
|
||||
"the same comparison, the same boundary and the same sentence",
|
||||
why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000),
|
||||
-5000, 5000), "unit_sign_disagree_w")
|
||||
and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
||||
-5000, 5000) == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -1818,6 +1818,181 @@ check("...and no `sign_site` claim can be filed at a roster of one, so the "
|
||||
"arbitration is byte-identical to what shipped",
|
||||
"sign_site" not in _c1s.reason)
|
||||
|
||||
|
||||
print()
|
||||
print("8. SAFETY-32 ROUND 4's ENUMERATION: the two `any(...)` reductions over")
|
||||
print(" the roster whose only evidence is a latched NON-FIRST unit")
|
||||
# ⚠️ THIS SECTION IS NOT SAFETY-32's SUBJECT, AND IT IS HERE BECAUSE OF IT.
|
||||
# SAFETY-32 was rejected three times for the same shape one step over: a
|
||||
# reduction over roster length whose fixture could not separate it from a slice
|
||||
# of the roster. Round 4 therefore ENUMERATED every such reduction in
|
||||
# `Controller` - min, max, sum, any, all, sorted, [0], [-1] over
|
||||
# `self.inverters` / `self.packs` - and mutated each to `[:1]` to find out which
|
||||
# were GRADED. Two survived a green 19-file suite, both `any(i.sign_wd.tripped
|
||||
# for i in self.inverters)`, both SAFETY-30's, and this block is what closes
|
||||
# them. No shipped behaviour changes; the reductions were already right.
|
||||
#
|
||||
# main.py `cycle()`, the stale-input hold - +4 bytes, 19/19 GREEN
|
||||
# main.py `publish()`, the status vocabulary - +4 bytes, 19/19 GREEN
|
||||
#
|
||||
# ⚠️ WHY THE SUITE COULD NOT SEE THEM, and it is the same sentence every time:
|
||||
# the only fixture that reached either was a roster of ONE, where
|
||||
# `self.inverters[:1]` IS `self.inverters`. Part 3 above builds a two-inverter
|
||||
# roster in which ONLY inverter B is latched - the exact data both reductions
|
||||
# needed - and neither of them was read on it. A fixture of N units cannot
|
||||
# separate reductions that agree on N units, one card over.
|
||||
#
|
||||
# ⚠️ AND BOTH ORDERS ARE RUN, for 12g's reason: a single "only B is latched"
|
||||
# fixture kills `[:1]` and leaves `[-1:]` - grade the roster by its LAST unit -
|
||||
# answering identically. The pair is what leaves no single-position slice.
|
||||
|
||||
|
||||
async def _latched_then_input_gap(reversed_idx):
|
||||
"""Latch ONE unit's sign watchdog, then take a SIBLING's reading away.
|
||||
|
||||
⚠️ A REAL LATCH FROM A REAL EPISODE, not `sign_wd.tripped = True`: the same
|
||||
`_SignHass` plant and the same `_drive()` part 3 uses, so the detector
|
||||
reaches its verdict through the shipped path. Then one pack's SoC goes
|
||||
missing for LESS than `stale_input_s`, which is the state the hold exists
|
||||
for - a single missed poll must not become a fault.
|
||||
|
||||
THE FAULT THIS GRADES: a LATCHED unit must take the whole roster past that
|
||||
hold, because the unit's `failsafe` SET is the thing parking it. Return
|
||||
early and the unit's last share stays standing for the heartbeat to
|
||||
re-emit - main.py's own comment: "the trip would stop acting the moment a
|
||||
poll was missed". A reduction that looked at one roster POSITION would do
|
||||
exactly that whenever the latched unit was not the position it looked at.
|
||||
"""
|
||||
hass = _SignHass(load_w=2000.0, reversed_units={reversed_idx})
|
||||
c = _sign_site(hass)
|
||||
await _drive(c)
|
||||
sibling = c.inverters[1 - reversed_idx]
|
||||
sibling.soc = None
|
||||
c.inputs_bad_since = datetime.now(timezone.utc)
|
||||
await c.cycle()
|
||||
return c
|
||||
|
||||
|
||||
_gap_b = asyncio.run(_latched_then_input_gap(1))
|
||||
_gap_a = asyncio.run(_latched_then_input_gap(0))
|
||||
for _tag, _c, _i in (("B latched", _gap_b, 1), ("A latched", _gap_a, 0)):
|
||||
_pl = [p for p in (_c.allocation.plans if _c.allocation else ())
|
||||
if p.unit == _c.inverters[_i].name]
|
||||
print(f" {_tag}, sibling's SoC missing 0 s: inputs_ok="
|
||||
f"{_c.inputs_ok}, reason={_c.reason!r}, latched unit target="
|
||||
f"{_c.inverters[_i].target}, why={_pl[0].why if _pl else '(no plan)'}")
|
||||
check("PRECONDITION (habit 1): on BOTH runs the site really is in the state "
|
||||
"the hold is for - one pack's reading is missing, `inputs_ok` is False, "
|
||||
"the gap is younger than `stale_input_s`, and the SITE latch is NOT "
|
||||
"tripped, so the only thing that can carry the roster past the hold is "
|
||||
"the per-unit `any()`. And exactly ONE unit is latched on each run, at "
|
||||
"the OTHER position",
|
||||
_gap_b.inputs_ok is False and _gap_a.inputs_ok is False
|
||||
and _gap_b.site_sign_wd.tripped is False
|
||||
and _gap_a.site_sign_wd.tripped is False
|
||||
and [i.sign_wd.tripped for i in _gap_b.inverters] == [False, True]
|
||||
and [i.sign_wd.tripped for i in _gap_a.inverters] == [True, False])
|
||||
check("⚠️ A LATCHED UNIT TAKES THE ROSTER PAST THE STALE-INPUT HOLD FROM EITHER "
|
||||
"POSITION. Neither cycle parks on `inputs missing`; both go on to file "
|
||||
"claims, and the latched unit is commanded 0 W by its OWN `failsafe` "
|
||||
"claim, named in the allocation record. Grade this with "
|
||||
"`self.inverters[:1]` and the B-latched run returns early - the +4-byte "
|
||||
"mutation that was GREEN across all 19 files - and with "
|
||||
"`self.inverters[-1:]` the A-latched run does. The remedy stops acting "
|
||||
"the moment a poll is missed, which is main.py's own sentence for it",
|
||||
not (_gap_b.reason or "").startswith("inputs missing")
|
||||
and not (_gap_a.reason or "").startswith("inputs missing")
|
||||
and _gap_b.inverters[1].target == 0.0
|
||||
and _gap_a.inverters[0].target == 0.0
|
||||
and all(any(p.unit == c.inverters[i].name and "failsafe" in p.why
|
||||
for p in (c.allocation.plans if c.allocation else ()))
|
||||
for c, i in ((_gap_b, 1), (_gap_a, 0))))
|
||||
# ...and the counterfactual, which is what makes the two lines above a
|
||||
# DIFFERENCE rather than a description: with NOTHING latched, the very same
|
||||
# input gap DOES park the site on the hold. Without this the checks above would
|
||||
# be satisfied by a `cycle()` that never had a hold at all.
|
||||
async def _gap_no_latch():
|
||||
c = _sign_site(_SignHass(load_w=2000.0))
|
||||
await _drive(c, n=2)
|
||||
c.inverters[0].soc = None
|
||||
c.inputs_bad_since = datetime.now(timezone.utc)
|
||||
await c.cycle()
|
||||
return c
|
||||
|
||||
|
||||
_gap_none = asyncio.run(_gap_no_latch())
|
||||
print(f" nothing latched, same gap: reason={_gap_none.reason!r}")
|
||||
check("...and the counterfactual (habit 7): with NO unit latched the SAME gap "
|
||||
"DOES hold the site - `inputs missing 0s` - so the two runs above show "
|
||||
"the latch carrying the roster past a hold that is really there, rather "
|
||||
"than a hold that never fires",
|
||||
(_gap_none.reason or "").startswith("inputs missing")
|
||||
and not any(i.sign_wd.tripped for i in _gap_none.inverters))
|
||||
|
||||
# --- and the SECOND survivor: the status vocabulary publish() hands the broker
|
||||
# ⚠️ THE EXISTING CHECK IS AT A ROSTER OF ONE, which is why this one exists.
|
||||
# "MQTT's controller-status sensor reports 'failsafe'" above drives a
|
||||
# single-inverter Controller, where `self.inverters[:1]` is the whole roster.
|
||||
# A site at 0 W reporting `running` is the failure main.py's comment names, and
|
||||
# at two inverters it is reachable whenever the latched unit is not the one a
|
||||
# narrowed reduction happens to read.
|
||||
_pub = {}
|
||||
|
||||
|
||||
def _published(ctl):
|
||||
_pub.clear()
|
||||
ctl.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: _pub.update(v),
|
||||
"close": lambda _s: None})()
|
||||
ctl.publish()
|
||||
return dict(_pub)
|
||||
|
||||
|
||||
_pb, _pa = _published(_gap_b), _published(_gap_a)
|
||||
_pn = _published(_gap_none)
|
||||
print(f" published status: B latched -> {_pb.get('status')!r}, "
|
||||
f"A latched -> {_pa.get('status')!r}, none latched -> "
|
||||
f"{_pn.get('status')!r}")
|
||||
check("⚠️ AND `publish()` SAYS `failsafe` WHICHEVER UNIT IS LATCHED. The status "
|
||||
"sensor is the vocabulary every dashboard already reads, and a site with "
|
||||
"one inverter parked by its own failsafe claim reporting `running` is "
|
||||
"the exact failure main.py's comment names. `self.inverters[:1]` "
|
||||
"publishes `running` on the B-latched site - +4 bytes, GREEN across all "
|
||||
"19 files - and `self.inverters[-1:]` does it on the A-latched one",
|
||||
_pb.get("status") == "failsafe" and _pa.get("status") == "failsafe")
|
||||
check("...and the counterfactual: the same two-inverter site with NOTHING "
|
||||
"latched publishes `running`, so the two above are a difference the "
|
||||
"reduction produced and not a constant",
|
||||
_pn.get("status") == "running" and _gap_none.auto is True)
|
||||
|
||||
# --- and the THIRD one the same enumeration found: the OPERATOR'S BANNER -----
|
||||
# ⚠️ `status_payload()`'s failsafe branch is a fourth `any(... for i in
|
||||
# self.inverters)`, and `self.inverters[:1]` there was +4 bytes and GREEN across
|
||||
# all 19 files too - for the third time, because the only fixture that reached
|
||||
# it was a roster of ONE. The banner is the ONE sentence at the top of the page;
|
||||
# a site holding an inverter at 0 W under a "Stopped" or "NOT READY" headline
|
||||
# sends the operator to the wrong question.
|
||||
_bn_b = _gap_b.status_payload()
|
||||
_bn_a = _gap_a.status_payload()
|
||||
_bn_n = _gap_none.status_payload()
|
||||
print(f" banner: B latched -> {_bn_b['banner']!r}")
|
||||
print(f" A latched -> {_bn_a['banner']!r}")
|
||||
print(f" none -> {_bn_n['banner']!r}")
|
||||
check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED, at "
|
||||
"level `bad`, with the N>=2 sentence that names the unit's OWN measured "
|
||||
"power rather than the meter. `self.inverters[:1]` drops the headline on "
|
||||
"the B-latched site and `self.inverters[-1:]` on the A-latched one, and "
|
||||
"the page then leads with whatever the next branch says",
|
||||
_bn_b["level"] == _bn_a["level"] == "bad"
|
||||
and _bn_b["banner"] == _bn_a["banner"]
|
||||
and _bn_b["banner"].startswith("FAILSAFE")
|
||||
and "its own measured power" in _bn_b["banner"])
|
||||
check("...and the counterfactual again: the same site with nothing latched "
|
||||
"carries neither that level nor that sentence, so the banner really is "
|
||||
"being produced by the reduction under test",
|
||||
not _bn_n["banner"].startswith("FAILSAFE")
|
||||
and _bn_n["banner"] != _bn_b["banner"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -40,8 +40,24 @@ DECISIONS/CFG-04-inverter-roster.md:
|
||||
reconstruction happens to lack FIRST, which is an artefact of the
|
||||
reconstruction and NOT evidence about `inverters`. B is that evidence.
|
||||
C. A two-inverter blob validates, per-entry types enforced.
|
||||
D. An entry missing `name`, `setpoint_entity` or `max_w` is refused BY NAME,
|
||||
and a JSON string where the list belongs is refused outright.
|
||||
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
|
||||
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
|
||||
own name asserted in the refusal - and a JSON string where the list belongs
|
||||
is refused outright.
|
||||
|
||||
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
|
||||
per-entry key. That change broke §C and, worse, left §D printing PASS on a
|
||||
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
|
||||
now carries the key and §D is an isolated counterfactual asserting the key name.
|
||||
|
||||
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
|
||||
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
|
||||
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
|
||||
together by hand from two runs is not a measurement, so this one was measured:
|
||||
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
|
||||
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
|
||||
differs from A's 10 so that the per-entry number is exercised at all, while
|
||||
production is expected to have BOTH packs at 10 %.
|
||||
"""
|
||||
import sys, json, copy
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
@@ -167,13 +183,25 @@ print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
||||
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
|
||||
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
|
||||
# pair instead of the real one.
|
||||
#
|
||||
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
|
||||
# blob is also what proves an operator can still POST a two-inverter config
|
||||
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
|
||||
# not exercise a per-entry number at all, for the same reason the two ratings
|
||||
# must stay different.
|
||||
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
|
||||
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
|
||||
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
|
||||
# - there is no second pack on this site, and in production BOTH packs are
|
||||
# expected to floor at 10 %, which is why the differing pair lives here and in
|
||||
# the fixtures rather than in config.yaml's example.
|
||||
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
||||
"max_w": 5000},
|
||||
"max_w": 5000, "bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
|
||||
"max_w": 4600}])
|
||||
"max_w": 4600, "bms_soc_floor": 15}])
|
||||
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
||||
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
||||
@@ -184,17 +212,38 @@ except OSError:
|
||||
pass
|
||||
|
||||
print("\n== D. what the schema itself refuses, per entry ==")
|
||||
# The rating here is filler - each entry is refused for a MISSING key, so no
|
||||
# verdict below depends on the number. It carries B's datasheet 4600 W anyway,
|
||||
# so that nothing in this file reads as 3000 W being inverter B's rating.
|
||||
for title, entry in (
|
||||
("no name", {"setpoint_entity": "number.b", "max_w": 4600}),
|
||||
("no setpoint_entity", {"name": "inverter-b", "max_w": 4600}),
|
||||
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
|
||||
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
|
||||
ok, out = validate(dict(TWO, inverters=[entry]))
|
||||
show(f"an entry with {title} is refused", not ok, out)
|
||||
ok, out = validate(dict(TWO, inverters="[]"))
|
||||
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
|
||||
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
|
||||
# section. These entries used to omit several keys at once and assert a bare
|
||||
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
|
||||
# on printing PASS while the refusal it printed said `Missing option
|
||||
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
|
||||
# anything, so the baseline below is COMPLETE and each case deletes exactly one
|
||||
# key - or, for the range case, moves exactly one value out of range.
|
||||
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
|
||||
"bms_soc_floor": 10}
|
||||
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
|
||||
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
|
||||
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
|
||||
# is the provenance problem §B2 is kept as a warning about.
|
||||
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
|
||||
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
|
||||
show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere "
|
||||
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||
for title, entry, expect in (
|
||||
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
|
||||
("no setpoint_entity",
|
||||
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"),
|
||||
("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
|
||||
("no bms_soc_floor (SAFETY-32)",
|
||||
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
|
||||
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
|
||||
("bms_soc_floor over the range (SAFETY-32)",
|
||||
dict(D_FULL, bms_soc_floor=99), "at most 50")):
|
||||
ok, out = validate(dict(D_BASE, inverters=[entry]))
|
||||
show(f"an entry with {title} is refused, and the refusal NAMES it",
|
||||
(not ok) and expect in str(out), out)
|
||||
ok, out = validate(dict(D_BASE, inverters="[]"))
|
||||
show("a JSON STRING where the list belongs is refused", not ok, out)
|
||||
|
||||
|
||||
@@ -223,6 +272,180 @@ except Exception as e: # noqa: BLE001
|
||||
show("Supervisor's UI renderer survives the shape", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
print("\n== F. UX-01: the TRANSLATED Configuration tab, rendered ==")
|
||||
# ⚠️ WHAT THIS SECTION MEASURES, AND WHY IT IS NOT AN ASSERTION ABOUT A FILE.
|
||||
# UX-01 adds translations/en.yaml and translations/nl.yaml. Whether they reach
|
||||
# an operator depends on two pieces of somebody else's software, not on us:
|
||||
#
|
||||
# 1. SUPERVISOR decides whether a file is loaded at all.
|
||||
# `supervisor/store/data.py:_read_app_translations()` globs this directory,
|
||||
# keys each file by its STEM, and runs it through `SCHEMA_APP_TRANSLATIONS`.
|
||||
# ⚠️ A FILE THAT FAILS THAT SCHEMA IS DROPPED WITH A LOG WARNING AND
|
||||
# NOTHING ELSE: `except (ConfigurationFileError, vol.Invalid): _LOGGER.
|
||||
# warning(...); continue`. The add-on then renders raw keys and the only
|
||||
# trace is one line in the Supervisor log. So the loader is RUN here, out
|
||||
# of the release image, rather than the schema being re-implemented.
|
||||
# 2. THE FRONTEND decides what it does with what Supervisor loaded. The
|
||||
# resolution below is not invented: it is transcribed from the shipped
|
||||
# bundle in THIS image, `api/panel/frontend_latest/5680.*.js`
|
||||
# (frontend 20250925.1), which reads
|
||||
#
|
||||
# _getTranslationEntry(lang,s,o){let e=this.addon.translations[lang]?.
|
||||
# configuration; if(e){if(o?.path)for(const t of o.path)
|
||||
# if(e=e[t]?.fields,!e)return; return e[s.name]}}
|
||||
# computeLabel =(s,_,o)=>this._getTranslationEntry(this.hass.language,s,o)?.name
|
||||
# ||this._getTranslationEntry("en",s,o)?.name||s.name
|
||||
# computeHelper=(s,o) =>this._getTranslationEntry(this.hass.language,s,o)?.description
|
||||
# ||this._getTranslationEntry("en",s,o)?.description||""
|
||||
# _filteredSchema=(opts,schema)=>schema.filter(e=>e.name in opts||e.required)
|
||||
#
|
||||
# i.e. the fallback is PER KEY, en is the second chance, and the raw key
|
||||
# name is the third.
|
||||
from supervisor.apps.validate import SCHEMA_APP_TRANSLATIONS # noqa: E402
|
||||
from supervisor.store.data import _read_app_translations # noqa: E402
|
||||
|
||||
TRANSLATIONS = _read_app_translations(Path("/w"))
|
||||
show("Supervisor's OWN translation loader accepts both files - a file that "
|
||||
"fails SCHEMA_APP_TRANSLATIONS is DROPPED with a log warning and the tab "
|
||||
"silently goes back to raw keys, so 'it is on disk' is not the question",
|
||||
set(TRANSLATIONS) == {"en", "nl"}, sorted(TRANSLATIONS))
|
||||
|
||||
ui = UiOptions(None)(schema)
|
||||
print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
|
||||
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
|
||||
"SCHEMA BLOCK'S and not the options block's)")
|
||||
|
||||
|
||||
def _entry(lang, name, path=()):
|
||||
node = TRANSLATIONS.get(lang, {}).get("configuration")
|
||||
if not node:
|
||||
return None
|
||||
for step in path:
|
||||
node = (node.get(step) or {}).get("fields")
|
||||
if not node:
|
||||
return None
|
||||
return node.get(name)
|
||||
|
||||
|
||||
def _label(lang, name, path=()):
|
||||
for lg in (lang, "en"):
|
||||
e = _entry(lg, name, path)
|
||||
if e and e.get("name"):
|
||||
return e["name"], lg
|
||||
return name, "RAW KEY"
|
||||
|
||||
|
||||
def _helper(lang, name, path=()):
|
||||
for lg in (lang, "en"):
|
||||
e = _entry(lg, name, path)
|
||||
if e and e.get("description"):
|
||||
return e["description"], lg
|
||||
return "", "NONE"
|
||||
|
||||
|
||||
# ⚠️ EVERY FIELD, PRINTED. This is the transcript the card asks for: what the
|
||||
# owner's Supervisor puts on the screen, not a claim that it would look nice.
|
||||
for lang in ("en", "nl"):
|
||||
print(f"\n --- as rendered at hass.language == {lang!r} "
|
||||
f"(the owner's HA runs Europe/Brussels) ---")
|
||||
for node in ui:
|
||||
lbl, src = _label(lang, node["name"])
|
||||
hlp, hsrc = _helper(lang, node["name"])
|
||||
flag = "" if src == lang else f" [name from {src}]"
|
||||
print(f" {node['name']:28s} -> {lbl}{flag}")
|
||||
print(f" {'':28s} {hlp[:150]}{'...' if len(hlp) > 150 else ''}")
|
||||
for sub in node.get("schema", ()):
|
||||
slbl, ssrc = _label(lang, sub["name"], (node["name"],))
|
||||
print(f" · {sub['name']:24s} -> {slbl}"
|
||||
+ ("" if ssrc == lang else f" [{ssrc}]"))
|
||||
|
||||
_raw = [n["name"] for n in ui if _label("en", n["name"])[1] == "RAW KEY"]
|
||||
show("NOT ONE top-level field falls through to its raw key name in `en` - "
|
||||
f"which is what the tab rendered for all {len(ui)} of them before this "
|
||||
f"card, because there was no translations/ directory at all. Raw: {_raw}",
|
||||
not _raw)
|
||||
_nohelp = [n["name"] for n in ui if not _helper("en", n["name"])[0]]
|
||||
show("...and not one is left without a description, which is the half that "
|
||||
f"says WHETHER TO TOUCH IT. Missing: {_nohelp}", not _nohelp)
|
||||
_nl_missing = [n["name"] for n in ui if _label("nl", n["name"])[1] != "nl"]
|
||||
show("...and every one of them is translated in `nl` too, so nothing on the "
|
||||
f"owner's own screen falls back to English. Falling back: {_nl_missing}",
|
||||
not _nl_missing)
|
||||
|
||||
# ⚠️ THE HONEST HALF, AND IT IS A NEGATIVE RESULT. `inverters` is a
|
||||
# list-of-objects, so UiOptions gives it type "schema" WITH multiple: True, and
|
||||
# `_convertSchemaElement` sends exactly that case to a SELECTOR rather than to a
|
||||
# nested ha-form:
|
||||
#
|
||||
# if("schema"===t.type&&!t.multiple) return {...type:"expandable"...} <- not us
|
||||
# if("schema"===t.type){const o={};for(const a of t.schema)
|
||||
# o[a.name]={required:a.required,selector:...}; return {object:{multiple:t.multiple,fields:o}}}
|
||||
#
|
||||
# and ha-selector-object labels those fields with
|
||||
#
|
||||
# computeLabel=e=>{const t=this.selector.object?.translation_key;
|
||||
# if(this.localizeValue&&t){...}
|
||||
# return this.selector.object?.fields?.[e.name]?.label||e.name}
|
||||
#
|
||||
# `_convertSchemaElementToSelector` sets neither `label` nor `translation_key`,
|
||||
# so ON THIS FRONTEND the per-entry keys inside the repeatable section render as
|
||||
# `setpoint_entity`, `max_w`, `bms_soc_floor` - RAW - however well they are
|
||||
# translated here. The `fields:` block is still shipped and still validated,
|
||||
# because it is the shape Supervisor's own schema defines for exactly this and
|
||||
# it is what a frontend that wires `label` through would consume; but this card
|
||||
# must not claim it reaches the operator today, and the count below is asserted
|
||||
# so that the day it changes, this transcript says so.
|
||||
_multi = [n for n in ui if n.get("multiple") and n.get("type") == "schema"]
|
||||
show("`inverters` is rendered as a REPEATABLE SECTION (type schema, multiple), "
|
||||
"which is CFG-04's measurement re-confirmed",
|
||||
[n["name"] for n in _multi] == ["inverters"], json.dumps(_multi)[:200])
|
||||
show("⚠️ MEASURED AND NEGATIVE: this frontend gives that section's per-entry "
|
||||
"keys to ha-selector-object, which labels them `fields[name].label || "
|
||||
"name` - and the converter sets no `label` and no `translation_key`. So "
|
||||
"the per-entry translations below are SHIPPED AND VALIDATED but do NOT "
|
||||
"reach the screen on 20250925.1. The top-level `inverters` label and "
|
||||
"description DO.",
|
||||
bool(_multi) and all("label" not in s and "translation_key" not in s
|
||||
for n in _multi for s in n.get("schema", ())),
|
||||
"per-entry keys as rendered today: "
|
||||
+ ", ".join(s["name"] for n in _multi for s in n.get("schema", ())))
|
||||
|
||||
# ⚠️ AND THE HAND PARSER CI USES IS GRADED AGAINST THIS LOADER, KEY BY KEY.
|
||||
# test_translations.py runs in the add-on's CI image, which has no PyYAML, so it
|
||||
# parses these files with tools/translations_lint.py. A hand parser that
|
||||
# disagrees with the real loader is a check whose data cannot reach the failure
|
||||
# it names, so the two are compared here - where both are available - rather
|
||||
# than assumed to agree.
|
||||
sys.path.insert(0, "/probe")
|
||||
import translations_lint as _tl # noqa: E402
|
||||
_mismatch = []
|
||||
for lang in ("en", "nl"):
|
||||
hand = _tl.parse_translation_file(f"/w/translations/{lang}.yaml")
|
||||
real = {"configuration": TRANSLATIONS[lang].get("configuration", {})}
|
||||
# SCHEMA_APP_TRANSLATIONS fills `description: None` for an absent one; the
|
||||
# hand parser simply has no key. Normalise that ONE difference and nothing
|
||||
# else, so a real disagreement cannot hide behind the normalisation.
|
||||
def _norm(d):
|
||||
return {k: {kk: vv for kk, vv in v.items() if vv is not None
|
||||
and kk != "fields"}
|
||||
| ({"fields": _norm(v["fields"])} if v.get("fields") else {})
|
||||
for k, v in d.items()}
|
||||
if _norm(hand.get("configuration", {})) != _norm(real["configuration"]):
|
||||
a, b = _norm(hand.get("configuration", {})), _norm(real["configuration"])
|
||||
_mismatch += [f"{lang}:{k}" for k in sorted(set(a) | set(b))
|
||||
if a.get(k) != b.get(k)]
|
||||
show("the hand parser CI uses (tools/translations_lint.py) returns EXACTLY "
|
||||
"what Supervisor's own loader returns, for both files, key by key and "
|
||||
f"string by string. Disagreements: {_mismatch}", not _mismatch)
|
||||
_cfg_text = Path("/w/config.yaml").read_text(encoding="utf-8")
|
||||
_probs = []
|
||||
for lang in ("en", "nl"):
|
||||
_probs += _tl.audit(
|
||||
_cfg_text,
|
||||
{"configuration": TRANSLATIONS[lang].get("configuration", {})}, lang)
|
||||
show(f"...and the coverage audit is clean against the real loader's view too. "
|
||||
f"Problems: {_probs}", not _probs)
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""UX-01: read config.yaml's option surface and the translations, and compare.
|
||||
|
||||
⚠️ WHY THIS IS A MODULE AND NOT A FUNCTION INSIDE THE CHECK. Two very different
|
||||
programs have to agree about what is in `translations/*.yaml`:
|
||||
|
||||
* `test_translations.py`, which runs in the add-on's own CI image - Alpine,
|
||||
python3 + py3-aiohttp + py3-paho-mqtt and NOTHING ELSE. There is no PyYAML
|
||||
there and a check that skips on ImportError is not a check, so the parser
|
||||
below is written by hand;
|
||||
* `tools/supervisor_shape_probe.py`, which runs inside the real Home Assistant
|
||||
Supervisor image and loads the same files with the loader Home Assistant
|
||||
ACTUALLY uses (`supervisor.utils.yaml.read_json_or_yaml_file`, validated by
|
||||
`SCHEMA_APP_TRANSLATIONS`).
|
||||
|
||||
A hand parser that quietly disagrees with the real loader is precisely the
|
||||
defect this repository keeps finding - a check whose data cannot reach the
|
||||
failure it names. So the probe imports THIS module and asserts, key by key and
|
||||
string by string, that `parse_translation_file()` and the real loader return the
|
||||
same thing. That equality is the licence for CI to use the hand parser at all.
|
||||
|
||||
The parser is DELIBERATELY STRICT. It accepts exactly the shape UX-01 generates
|
||||
- two-space indentation, one `key:` mapping opener or one `key: "double quoted
|
||||
scalar"` per line, `#` comments, blank lines - and RAISES on anything else. A
|
||||
permissive parser would answer "no problems" for a file it had silently skipped
|
||||
half of, which is the same failure wearing a passing grade.
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
|
||||
__all__ = ["parse_translation_file", "parse_translation_text", "config_surface",
|
||||
"audit"]
|
||||
|
||||
|
||||
class TranslationSyntaxError(ValueError):
|
||||
"""The file is not in the subset this parser accepts. Never silently skip."""
|
||||
|
||||
|
||||
_LINE = re.compile(r"^(?P<indent> *)(?P<name>[A-Za-z0-9_.:/-]+):(?P<rest>.*)$")
|
||||
|
||||
|
||||
def _unescape(raw, lineno):
|
||||
"""The body of a YAML double-quoted scalar -> its value."""
|
||||
out, i = [], 0
|
||||
while i < len(raw):
|
||||
ch = raw[i]
|
||||
if ch == "\\":
|
||||
if i + 1 >= len(raw):
|
||||
raise TranslationSyntaxError(f"line {lineno}: trailing backslash")
|
||||
nxt = raw[i + 1]
|
||||
if nxt not in ('"', "\\"):
|
||||
# Deliberately narrow: UX-01's generator emits only these two.
|
||||
# Anything else is a shape this parser has not been measured on.
|
||||
raise TranslationSyntaxError(
|
||||
f"line {lineno}: unsupported escape \\{nxt}")
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
raise TranslationSyntaxError(f"line {lineno}: unescaped quote")
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def parse_translation_text(text, where="<text>"):
|
||||
"""A nested dict from the strict two-space YAML subset UX-01 writes.
|
||||
|
||||
Raises TranslationSyntaxError on ANY line it does not fully understand.
|
||||
"""
|
||||
root = {}
|
||||
stack = [(-2, root)] # (indent, mapping)
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
if line.rstrip() != line:
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: trailing whitespace")
|
||||
m = _LINE.match(line)
|
||||
if not m:
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: not `key:` -> {line!r}")
|
||||
indent, name, rest = len(m.group("indent")), m.group("name"), m.group("rest")
|
||||
if indent % 2:
|
||||
raise TranslationSyntaxError(
|
||||
f"{where} line {lineno}: indent {indent} is not a multiple of 2")
|
||||
while stack and indent <= stack[-1][0]:
|
||||
stack.pop()
|
||||
if not stack:
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: dedented past the root")
|
||||
parent_indent, parent = stack[-1]
|
||||
if indent != parent_indent + 2:
|
||||
raise TranslationSyntaxError(
|
||||
f"{where} line {lineno}: indent {indent} does not open under "
|
||||
f"indent {parent_indent}")
|
||||
if name in parent:
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: duplicate key {name!r}")
|
||||
if rest == "":
|
||||
child = {}
|
||||
parent[name] = child
|
||||
stack.append((indent, child))
|
||||
continue
|
||||
if not rest.startswith(" "):
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: no space after the colon")
|
||||
value = rest[1:]
|
||||
if len(value) < 2 or value[0] != '"' or value[-1] != '"':
|
||||
raise TranslationSyntaxError(
|
||||
f"{where} line {lineno}: scalars must be double quoted -> {value!r}")
|
||||
parent[name] = _unescape(value[1:-1], lineno)
|
||||
return root
|
||||
|
||||
|
||||
def parse_translation_file(path):
|
||||
return parse_translation_text(io.open(path, encoding="utf-8").read(), str(path))
|
||||
|
||||
|
||||
# --- config.yaml's option surface --------------------------------------------
|
||||
# ⚠️ THE SCHEMA HALF, NOT THE OPTIONS HALF, AND THAT IS A MEASUREMENT RATHER
|
||||
# THAN A PREFERENCE. Supervisor's `App.schema_ui` is `UiOptions(...)(data[
|
||||
# ATTR_SCHEMA])`, so the ORDER the Configuration tab renders in is the order of
|
||||
# the `schema:` block and nothing else. tools/supervisor_shape_probe.py section
|
||||
# F prints that order out of the real image.
|
||||
#
|
||||
# ⚠️ ANCHORED ON THE INDENTATION, never on a stripped line. A top-level schema
|
||||
# key is at EXACTLY two spaces and everything nested is at four or more; the
|
||||
# `max_w` inside `inverters:` and the flat `max_w` are the same text otherwise,
|
||||
# and config.yaml declares the nested one FIRST on purpose so that a first-match
|
||||
# stripped reader is caught. See test_inverter.py part 12.
|
||||
_TOP = re.compile(r"^ ([a-z_0-9]+):(.*)$")
|
||||
_NESTED = re.compile(r"^ {4,}(?:- )?([a-z_0-9]+): *(\S.*?) *$")
|
||||
|
||||
|
||||
def config_surface(config_text):
|
||||
"""(top-level schema keys in render order, {key: [nested per-entry keys]}).
|
||||
|
||||
Nested keys are the ones a list-of-objects option declares per entry - today
|
||||
only `inverters:`. A list of scalars (`- str`) declares none, and comes back
|
||||
absent from the second dict rather than as an empty list.
|
||||
"""
|
||||
marker = config_text.index("\nschema:")
|
||||
half = config_text[marker:]
|
||||
order, nested, current = [], {}, None
|
||||
for line in half.splitlines():
|
||||
m = _TOP.match(line)
|
||||
if m:
|
||||
order.append(m.group(1))
|
||||
current = m.group(1) if not m.group(2).strip() else None
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
n = _NESTED.match(line)
|
||||
if n:
|
||||
nested.setdefault(current, []).append(n.group(1))
|
||||
else:
|
||||
current = None
|
||||
if len(order) != len(set(order)):
|
||||
dupes = sorted({k for k in order if order.count(k) > 1})
|
||||
raise ValueError(f"config.yaml declares {dupes} twice at the top level of schema:")
|
||||
return order, nested
|
||||
|
||||
|
||||
# --- the audit ---------------------------------------------------------------
|
||||
def audit(config_text, translation, lang, require_descriptions=True):
|
||||
"""Every disagreement between config.yaml and one translation file.
|
||||
|
||||
Returns a list of strings. EMPTY MEANS AGREEMENT; each string names the key
|
||||
and says which direction the disagreement runs, because "the translations do
|
||||
not match" is not something anybody can act on.
|
||||
"""
|
||||
order, nested = config_surface(config_text)
|
||||
problems = []
|
||||
top = translation.get("configuration")
|
||||
if not isinstance(top, dict):
|
||||
return [f"{lang}: no `configuration:` block at all"]
|
||||
# ⚠️ THE WHOLE FILE IS ALL-OR-NOTHING, AND THAT IS WHY THESE TWO LINES ARE
|
||||
# HERE RATHER THAN ONLY THE `configuration:` COVERAGE BELOW.
|
||||
# `_read_app_translations()` runs the file through SCHEMA_APP_TRANSLATIONS
|
||||
# and, on `vol.Invalid`, LOGS A WARNING AND DROPS THE WHOLE FILE - every
|
||||
# label in it, not just the offending key. So a shape Supervisor refuses is
|
||||
# not a small defect that costs one description; it silently returns the tab
|
||||
# to raw keys. The two ways to write one that this parser can still read are
|
||||
# a top-level block that is not `configuration` or `network`, and a
|
||||
# `network:` block whose values are not strings ({str: str} is the schema).
|
||||
for extra in sorted(set(translation) - {"configuration", "network"}):
|
||||
problems.append(f"{lang}: `{extra}:` is not a block Supervisor reads "
|
||||
"(only `configuration` and `network` are) - "
|
||||
"SCHEMA_APP_TRANSLATIONS refuses the file and the "
|
||||
"Supervisor DROPS ALL OF IT with a log warning")
|
||||
for port, text in sorted((translation.get("network") or {}).items()):
|
||||
if not isinstance(text, str):
|
||||
problems.append(f"{lang}: `network.{port}` is not a string - "
|
||||
"`network:` is {str: str} and a mapping there makes "
|
||||
"Supervisor drop the WHOLE file, every label in it")
|
||||
|
||||
for key in order:
|
||||
entry = top.get(key)
|
||||
if entry is None:
|
||||
problems.append(f"{lang}: option `{key}` has no translation - Home "
|
||||
"Assistant would render the raw key")
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
problems.append(f"{lang}: `{key}` is not a mapping")
|
||||
continue
|
||||
if not str(entry.get("name", "")).strip():
|
||||
problems.append(f"{lang}: option `{key}` has no `name`")
|
||||
if require_descriptions and not str(entry.get("description", "")).strip():
|
||||
problems.append(f"{lang}: option `{key}` has no `description` - the "
|
||||
"operator gets a label and no way to know whether to "
|
||||
"touch it")
|
||||
want = nested.get(key, [])
|
||||
got = entry.get("fields")
|
||||
if want and not isinstance(got, dict):
|
||||
problems.append(f"{lang}: `{key}` is a repeatable section with "
|
||||
f"per-entry keys {sorted(want)} and no `fields:` block")
|
||||
continue
|
||||
if got is not None and not want:
|
||||
problems.append(f"{lang}: `{key}` carries a `fields:` block but "
|
||||
"config.yaml declares no per-entry keys for it")
|
||||
continue
|
||||
for sub in want:
|
||||
se = (got or {}).get(sub)
|
||||
if se is None:
|
||||
problems.append(f"{lang}: `{key}` per-entry key `{sub}` has no "
|
||||
"translation")
|
||||
continue
|
||||
if not str(se.get("name", "")).strip():
|
||||
problems.append(f"{lang}: `{key}.{sub}` has no `name`")
|
||||
if require_descriptions and not str(se.get("description", "")).strip():
|
||||
problems.append(f"{lang}: `{key}.{sub}` has no `description`")
|
||||
for orphan in sorted(set(got or {}) - set(want)):
|
||||
problems.append(f"{lang}: `{key}.{orphan}` is translated but "
|
||||
"config.yaml declares no such per-entry key")
|
||||
|
||||
for orphan in sorted(set(top) - set(order)):
|
||||
problems.append(f"{lang}: `{orphan}` is translated but config.yaml "
|
||||
"declares no such option - a rename or a typo")
|
||||
return problems
|
||||
@@ -0,0 +1,275 @@
|
||||
# Home Assistant add-on translations - ENGLISH (UX-01)
|
||||
#
|
||||
# ⚠️ WHAT THIS FILE IS FOR, AND WHAT HOME ASSISTANT ACTUALLY DOES WITH IT.
|
||||
# Without a `translations/` directory the Configuration tab renders the raw
|
||||
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 70
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY are
|
||||
# rendered, always, in `schema:` order, with no description. This file is what
|
||||
# turns that into sentences.
|
||||
#
|
||||
# HOW IT IS READ, measured on the Supervisor image this site runs
|
||||
# (ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0):
|
||||
#
|
||||
# * `supervisor/store/data.py:_read_app_translations()` reads every
|
||||
# `.yaml`/`.yml`/`.json` in this directory, keyed by FILE STEM (so this file
|
||||
# is the `en` translation), and validates each through
|
||||
# `SCHEMA_APP_TRANSLATIONS`: `configuration: {str: {name (required),
|
||||
# description, fields}}` plus `network: {str: str}`. ⚠️ A FILE THAT FAILS
|
||||
# THAT SCHEMA IS DROPPED WITH A LOG WARNING AND NOTHING ELSE - the add-on
|
||||
# goes on rendering raw keys and nobody is told twice. tools/
|
||||
# supervisor_shape_probe.py section F is what says it loaded.
|
||||
# * The frontend resolves each label as
|
||||
# `translations[<user's language>]?...name || translations["en"]?...name ||
|
||||
# schema.name`, and the helper text the same way with `description`. ⚠️ THE
|
||||
# FALLBACK IS PER KEY, so a key missing from nl.yaml degrades to this file,
|
||||
# while a key present but wrong does not degrade at all.
|
||||
#
|
||||
# ⚠️ ORDER IS NOT SET HERE. `App.schema_ui` is `UiOptions(...)(data["schema"])`,
|
||||
# so the Configuration tab renders in the order of config.yaml's `schema:`
|
||||
# block. The order below only mirrors it so the two files read alike.
|
||||
#
|
||||
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST. There is no section-header primitive, no
|
||||
# per-option "advanced" flag, no units on the input and no sliders, so grouping
|
||||
# is approximated by ORDER plus the `Group · ` prefix in each label. Do not
|
||||
# promise the operator visual sections; there are none.
|
||||
#
|
||||
# ⚠️ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM. The Configuration tab
|
||||
# presents a number read off 1890 closed-loop test runs and the operator's own
|
||||
# inverter rating as the same kind of field. The descriptions are the only place
|
||||
# that difference can be stated, so a derived value names its campaign and says
|
||||
# "do not change without re-deriving", and a value that is genuinely the
|
||||
# operator's says so too.
|
||||
configuration:
|
||||
setpoint_entity:
|
||||
name: "Hardware · Inverter setpoint entity"
|
||||
description: "The ESPHome number entity this add-on writes the power setpoint to, normally number.*_goodwe_setpoint_w. This is the only thing the add-on commands. Leave it empty if you fill in the inverter list below: a site with a roster must not also name one loose actuator, and the add-on refuses to start when both are set."
|
||||
soc_entity:
|
||||
name: "Hardware · Battery charge level entity (%)"
|
||||
description: "The sensor that reports the pack's state of charge in percent. Use the ESP32's own reading, not a figure computed somewhere else. The maintenance cycle and every charge-level floor read this."
|
||||
batt_entity:
|
||||
name: "Hardware · Battery power entity (W)"
|
||||
description: "The sensor that reports this inverter's own battery power, positive while discharging. On this installation that is holding register 0x0518, read by the ESP32. The sign watchdog and the circulating-power alarm both grade the inverter against this reading."
|
||||
batt_invert:
|
||||
name: "Hardware · Battery power sign is reversed"
|
||||
description: "Only switch this on if your battery power sensor reports discharging as a negative number. The add-on expects positive to mean discharging."
|
||||
inverters:
|
||||
name: "Hardware · Inverter list (leave empty if you have one inverter)"
|
||||
description: "Leave this empty unless the site really has more than one inverter. EMPTY DOES NOT MEAN 'not configured yet': it means the four entity settings above, together with the inverter rating and the battery cut-out under Limits, describe the one inverter you have. That is what every existing installation reads, and nobody has to touch anything. Filling this in REPLACES those settings instead of extending them, and nothing is inherited: each entry has to name its own actuator, its own rating and its own battery cut-out, so that no inverter is ever judged against another unit's numbers. The flat setpoint entity above must then be left empty."
|
||||
fields:
|
||||
name:
|
||||
name: "Name"
|
||||
description: "A short identifier for this inverter, for example inverter-a. It gives this unit its own namespace for its safety latch, heartbeat and write bookkeeping in the add-on's state file, so two units may not share one."
|
||||
setpoint_entity:
|
||||
name: "Setpoint entity"
|
||||
description: "This inverter's own ESPHome number.*_goodwe_setpoint_w. Two entries may not name the same one - two writers on one actuator is refused at startup."
|
||||
max_w:
|
||||
name: "Inverter rating (W)"
|
||||
description: "The supervised ceiling on what may be written to THIS inverter; the site's ceiling is the sum over the list. Required of every entry: an entry without one would have to borrow a rating, and judging one inverter against another unit's numbers is exactly the fault this per-entry list exists to remove."
|
||||
bms_soc_floor:
|
||||
name: "Battery cut-out level (%)"
|
||||
description: "The percent at which THIS pack's own battery management system cuts out. Read it off the pack that is actually installed (register 0x0560) before that pack is ever commanded; do not copy another unit's figure. Required of every entry, deliberately: an optional one would be a defaulted one, and one default for two batteries of different models protects the shallower pack with the deeper pack's number."
|
||||
soc_entity:
|
||||
name: "Battery charge level entity (%) (optional)"
|
||||
description: "This pack's own state of charge. Leave it empty if this unit reports none - that is what it means, and it never falls back to another unit's sensor."
|
||||
batt_entity:
|
||||
name: "Battery power entity (W) (optional)"
|
||||
description: "This unit's own battery power, positive while discharging. Leave it empty if this unit reports none; nothing is inherited from another entry."
|
||||
batt_invert:
|
||||
name: "Battery power sign is reversed (optional)"
|
||||
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485 emergency stop is fitted"
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
meter_source:
|
||||
name: "Meter · Where the meter reading comes from"
|
||||
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
|
||||
meter_entity:
|
||||
name: "Meter · Grid power entity (W)"
|
||||
description: "The net grid power sensor, used when the meter source is 'off'. POSITIVE MUST MEAN DRAWING FROM THE GRID. Get this the wrong way round and the loop drives the battery in the wrong direction; use the sign switch below rather than a template sensor."
|
||||
meter_invert:
|
||||
name: "Meter · Grid power sign is reversed"
|
||||
description: "Switch this on if your grid sensor reports drawing from the grid as a negative number."
|
||||
meter_phases:
|
||||
name: "Meter · Number of phases (1 or 3)"
|
||||
description: "Must match your connection. A telegram whose phase count disagrees is rejected as it arrives and logged, so a wrong value shows up straight away as 'no telegrams accepted' rather than as a quietly wrong number. The range accepts 2, but that is not a real Belgian connection."
|
||||
p1_host:
|
||||
name: "Meter · HomeWizard P1 address"
|
||||
description: "For meter source homewizard_local only: the meter's own address on your network, as host or host:port, for example 192.168.2.250."
|
||||
meter_poll_s:
|
||||
name: "Meter · Seconds between meter polls"
|
||||
description: "For meter source homewizard_local only. 5 s is the meter's own update rate, so there is nothing to gain below it. It must stay well under the staleness limit below - the reading is never fresher than this interval - and the add-on refuses to start if it is at or above that limit."
|
||||
meter_max_age_s:
|
||||
name: "Meter · A reading counts as stale after (s)"
|
||||
description: "Beyond this the grid figure counts as missing. On its own it does not command 0 W: the 'inputs missing' timer under Advanced starts only after this one has expired, and the two stack. 30 s is six polls of headroom at the default poll interval."
|
||||
meter_mqtt_topic:
|
||||
name: "Meter · MQTT topic"
|
||||
description: "For meter source mqtt_p1 only: the topic the P1 readings are published on."
|
||||
p1_import_entity:
|
||||
name: "Meter · Import register entity (W)"
|
||||
description: "The UNSIGNED consumption register. Do not point this at a signed template sensor: the add-on works out net power itself as import minus export, so the sign convention lives in tested code rather than in unreviewed YAML."
|
||||
p1_export_entity:
|
||||
name: "Meter · Export register entity (W)"
|
||||
description: "The UNSIGNED injection register, the counterpart of the import register above. Same rule: no signed template sensor."
|
||||
p1_net_entity:
|
||||
name: "Meter · Signed net power entity (W)"
|
||||
description: "For meter source ha_signed only. ONE signed sensor: positive is drawn from the grid, negative is fed back into it. Do not split it into two template sensors - the add-on does that split itself, so that it is tested."
|
||||
p1_phase_import_entities:
|
||||
name: "Meter · Per-phase import entities (L1, L2, L3)"
|
||||
description: "Optional, in L1..L3 order. Needed for the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases above."
|
||||
p1_phase_export_entities:
|
||||
name: "Meter · Per-phase export entities (L1, L2, L3)"
|
||||
description: "Optional, in L1..L3 order, alongside the per-phase import entities above. The number of entries must equal the number of phases."
|
||||
p1_phase_net_entities:
|
||||
name: "Meter · Per-phase signed net entities (L1, L2, L3)"
|
||||
description: "For meter source ha_signed only. Optional, in L1..L3 order, each signed the same way as the net entity above. Same job as the per-phase import and export entities: the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases."
|
||||
max_w:
|
||||
name: "Limits · Inverter rating (W)"
|
||||
description: "THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may ever be commanded, in watts. Set it to what your inverter is actually rated for, start low and raise it once the site has been commissioned. It is not read at all when the inverter list is filled in - each entry then carries its own, and the site ceiling is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not."
|
||||
bms_soc_floor:
|
||||
name: "Limits · Battery cut-out level (%)"
|
||||
description: "MEASURED, not chosen: the percent at which the pack's OWN battery management system stops discharging, whatever this add-on is set to. Register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure. It is here so the add-on can refuse a capacity-guard floor at or below it: such a floor could never bind, the BMS would win first, and the status page would report an energy backstop that does not exist. Not read when the inverter list is filled in - each entry then states its own pack's cut-out."
|
||||
peak_cap_w:
|
||||
name: "Limits · Capacity-tariff target (W)"
|
||||
description: "THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tries to stay under. Take it from your grid contract - in Belgium it is the capacity-tariff peak your distribution charge is billed on."
|
||||
peak_forecast_entity:
|
||||
name: "Limits · Quarter-hour demand forecast entity (W)"
|
||||
description: "Optional. A forecast of the current billing quarter's average demand. Leave it empty and there is no capacity cap at all."
|
||||
peak_soc_floor:
|
||||
name: "Limits · Peak guard will not discharge below (%)"
|
||||
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
|
||||
price_provider:
|
||||
name: "Tariff · Dynamic price source"
|
||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ Switching it on today fetches, checks and publishes prices and changes no energy decision - nothing consumes the price series yet. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tariff · Supplier markup (EUR/kWh)"
|
||||
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under engie_dynamic, totalenergies_dynamic, eneco_eddy and manual_csv it is ignored, and logged as ignored, never applied twice."
|
||||
price_taxes_eur_kwh:
|
||||
name: "Tariff · Levies and VAT (EUR/kWh)"
|
||||
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
|
||||
export_price_eur_kwh:
|
||||
name: "Tariff · Injection price (EUR/kWh)"
|
||||
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
|
||||
static_profile:
|
||||
name: "Tariff · Fixed or day/night tariff (one line of JSON)"
|
||||
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
|
||||
price_csv_path:
|
||||
name: "Tariff · Price CSV file"
|
||||
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
|
||||
price_feed_url:
|
||||
name: "Tariff · Price feed URL override"
|
||||
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
|
||||
price_horizon_h:
|
||||
name: "Tariff · How far ahead to fetch prices (hours)"
|
||||
description: "How many hours of prices to ask the source for."
|
||||
price_timeout_s:
|
||||
name: "Tariff · Price request timeout (s)"
|
||||
description: "How long a single request to the price source may take before it is given up on."
|
||||
price_now_entity:
|
||||
name: "Tariff · Current price entity (older price path)"
|
||||
description: "The older way of getting a price, kept because retiring it would take the maintenance cycle's only price signal away from a live installation. The add-on reads this and the average below and derives exactly one thing from them - whether now is cheaper than average - which only the maintenance cycle's cheap-window charging uses. It may not be combined with a dynamic price source."
|
||||
price_avg_entity:
|
||||
name: "Tariff · Average price entity (older price path)"
|
||||
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
|
||||
arbitrage_enabled:
|
||||
name: "Tariff · Trade on the price spread"
|
||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
|
||||
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
|
||||
arbitrage_soc_floor:
|
||||
name: "Tariff · Trading may not discharge below (%)"
|
||||
description: "The reserve price trading may not spend. Deliberately not the same key as the maintenance floor: pointing a trading strategy at the monthly balancing cycle's numbers would couple two unrelated policies through one setting."
|
||||
arbitrage_soc_ceiling:
|
||||
name: "Tariff · Trading may not charge above (%)"
|
||||
description: "The headroom price trading may not fill. The counterpart of the trading floor above, and kept separate from the maintenance target for the same reason."
|
||||
maintenance_enabled:
|
||||
name: "Maintenance · Run the monthly battery cycle"
|
||||
description: "A deliberate deep discharge followed by a full recharge and a hold, so the battery management system can recalibrate its charge estimate and balance the cells. Off by default."
|
||||
maintenance_interval_days:
|
||||
name: "Maintenance · Minimum days between cycles"
|
||||
description: "The shortest gap between two maintenance cycles."
|
||||
maintenance_start_hour:
|
||||
name: "Maintenance · Hour of day a due cycle starts"
|
||||
description: "Local hour, 0 to 23, at which a cycle that has become due begins."
|
||||
maintenance_discharge_w:
|
||||
name: "Maintenance · Discharge rate (W)"
|
||||
description: "How fast the cycle drains the pack; the surplus is exported. Above the inverter rating it is CLAMPED to the rating rather than refused - the cycle still completes, just more slowly, and the status page says so. It ships at 2000 to match the shipped rating; raise the rating first, then this."
|
||||
maintenance_charge_w:
|
||||
name: "Maintenance · Recharge rate (W)"
|
||||
description: "⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses to start otherwise. Above it, the cheap-window charge shaping asks for more than the supervised limit allows, the arbiter resolves that contradiction to 0 W, and maintenance would silently never charge in a cheap window on a default installation. It is also capped again by whatever headroom the peak guard leaves."
|
||||
maintenance_soc_floor:
|
||||
name: "Maintenance · Discharge down to (%)"
|
||||
description: "The depth the monthly recalibration deliberately goes to, once a month, on purpose. ⚠️ THIS IS NOT THE PEAK GUARD'S FLOOR: that one is the depth a money-saving discharge may never reach on any day. The right values genuinely differ - 11 here against 20 there - which is why they are two separate settings and must not be merged."
|
||||
maintenance_soc_target:
|
||||
name: "Maintenance · Recharge up to (%)"
|
||||
description: "The level the recharge phase aims for before the hold begins."
|
||||
maintenance_hold_min:
|
||||
name: "Maintenance · Hold at full for (minutes)"
|
||||
description: "How long to sit at the target so the battery management system can finish balancing the cells."
|
||||
sign_disagree_w:
|
||||
name: "Safety · Sign watchdog threshold (W) — measured, do not change"
|
||||
description: "MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and the grid reading must exceed this before their opposite signs count as a disagreement at all. On the captured healthy day (2026-08-23) sign agreement over the gated samples is 93.4 %; on the captured fault day (2026-08-20) the clusters run 0.0-10.5 % with opposed runs up to 1752 s. There is no reason to move this without a new capture that says so. It must stay below the inverter rating or the watchdog can never fire, and the add-on refuses that configuration."
|
||||
sign_disagree_s:
|
||||
name: "Safety · Sign watchdog hold (s) — measured, do not change"
|
||||
description: "MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter must stay opposed, continuously, before the add-on commands 0 W and latches until an operator starts control again. 180 s sits in the middle of a very wide measured gap: the longest legitimate opposed run on the healthy day is 30 s, while the fault day still trips 13 times at 300 s. Do not change it without a new capture."
|
||||
unit_sign_disagree_w:
|
||||
name: "Safety · Per-inverter sign threshold (W) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothing at all. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS on the two-master rig (SAFETY-30), in which nothing was faulty, swept across the whole range this add-on accepts for loop gain and slew rate. 250 W is exactly half the saturation threshold, so the smallest divergence this can see is precisely the gap at which the control law itself already says the inverter is not following. Do not change it without re-deriving it. It must stay below the inverter rating or it can never fire."
|
||||
unit_sign_disagree_s:
|
||||
name: "Safety · Per-inverter sign hold (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs produce (10.0 s), 2.8 times the firmware's own 30 s link veto, and under half the 180 s the site-wide check takes. Do not change it without re-deriving it."
|
||||
site_sign_disagree_w:
|
||||
name: "Safety · Site-wide sign threshold (W) — measured, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter threshold, CARRIED ACROSS DELIBERATELY (SAFETY-30): the argument behind it does not depend on how many inverters there are - near zero the sign is noise, the deadband is 15 W, and there is one meter whatever is behind it. Do not change it without a capture that says so."
|
||||
site_sign_disagree_s:
|
||||
name: "Safety · Site-wide sign hold (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30), and deliberately NOT carried across from the single-inverter 180 s. On those runs the worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, because the command has to travel the whole site rail at a slew rate that is not the site's - so carrying 180 s across would have shipped a false trip reachable at the lowest gain and slew with an ordinary 2.2 kW load reversal. 325 s is 1.51 times that, still inside the 900 s billing quarter, and still inside every episode of the captured fault day. Do not change it without re-deriving it."
|
||||
meter_freeze_s:
|
||||
name: "Safety · Frozen-meter alarm after (s) — measured, do not change"
|
||||
description: "MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answering with an UNCHANGED reading, while the command stands at the rail, before the add-on raises an alarm. Over 2399 s of this house at its settled operating point - 1194 polls, 735 of them repeating the previous reading - the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that and still inside the 900 s billing quarter. ALARM ONLY: it does not command 0 W and does not latch. ⚠️ Only the homewizard_local meter source can see this at all; on every other source the status page says NOT COVERED rather than armed. Do not change it without re-deriving it."
|
||||
circulation_w:
|
||||
name: "Safety · Circulating-power threshold (W) — measured, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identically zero. Two inverters pushing against each other net out to zero at the meter and are invisible to every other guard in this add-on. MEASURED ON 924 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-31): the worst legitimate opposition collapses from 505 s at a 100 W threshold to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below - under it, ordinary operation holds the sensor over the threshold for minutes and nothing shorter than the billing quarter could tell that from a fault. 700 W is 1.75 times the knee, and the extra margin is there because the second inverter's ramp and lag are still copied from the first one rather than measured. Do not change it without re-deriving it."
|
||||
circulation_hold_s:
|
||||
name: "Safety · Circulating-power hold (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
|
||||
auto_start:
|
||||
name: "Advanced · Start controlling automatically on boot"
|
||||
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
|
||||
gain:
|
||||
name: "Advanced · Loop gain"
|
||||
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
|
||||
slew_w:
|
||||
name: "Advanced · Largest change per cycle (W)"
|
||||
description: "How far the setpoint may move in one control cycle. It bounds how fast the site can turn around, and several safety windows were measured across the whole range this accepts."
|
||||
deadband_w:
|
||||
name: "Advanced · Deadband (W)"
|
||||
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
|
||||
target_grid_w:
|
||||
name: "Advanced · Resting point at the meter (W)"
|
||||
description: "What the meter should sit at when nothing is happening; negative means a slight export. -10 W is deliberate: resting at 0 W leaks about half the deadband in billed import all day - roughly 0.2 to 0.35 kWh at a 15 W deadband - and biasing the rest point below zero moves that leak into the export register, which is not billed. It costs about this many watts of given-away export, so keep it small."
|
||||
step_w:
|
||||
name: "Advanced · Setpoint quantisation (W)"
|
||||
description: "The setpoint is rounded to a multiple of this before it is written, so the inverter is not handed a new number for every watt of noise."
|
||||
saturation_w:
|
||||
name: "Advanced · Saturation threshold (W)"
|
||||
description: "The gap between what was commanded and what the inverter delivers that counts as 'this inverter is at a limit'. ⚠️ The per-inverter sign threshold under Safety is derived as exactly half of this, so moving this moves that measurement's argument with it."
|
||||
saturation_cycles:
|
||||
name: "Advanced · Saturated cycles before freezing (count)"
|
||||
description: "How many consecutive saturated cycles before the loop stops winding up. ⚠️ Do not set this to 1: a single noisy cycle would then freeze the loop."
|
||||
integrator_max_w:
|
||||
name: "Advanced · Integrator bound (W, 0 means the same as the rating)"
|
||||
description: "Caps how much stale error can be waiting to unwind when the sign flips. MEASURED on the closed-loop simulator with a 4000 W load dropped to zero: at a bound equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating it is 1800 W. ⚠️ Do not raise it above the inverter rating - the output clamp already bounds what reaches the wire, so extra headroom buys nothing but wrong-direction watts after every saturation event. Lowering it below the rating is the useful direction, and the only reason this is a separate setting."
|
||||
heartbeat_s:
|
||||
name: "Advanced · Setpoint refresh interval (s)"
|
||||
description: "How often the setpoint is rewritten even when it has not changed. It must stay well under the firmware's own watchdog, which vetoes a link that has been quiet for 30 s."
|
||||
stale_input_s:
|
||||
name: "Advanced · Command 0 W after inputs missing for (s)"
|
||||
description: "How long the inputs may be missing before the add-on commands 0 W. With a P1 meter this clock starts only AFTER the meter staleness limit has already expired, so the two stack: the total is longer than this number on its own."
|
||||
cycle_floor_s:
|
||||
name: "Advanced · Longest gap between control cycles (s)"
|
||||
description: "A deadline on the gap between cycles, reset by every cycle. On a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than about 10 s, and keep it under the meter staleness limit - past that there is no reading left to evaluate anyway."
|
||||
log_level:
|
||||
name: "Advanced · Log level"
|
||||
description: "How much detail the add-on writes to its log. 'info' is the shipped setting; 'debug' and 'trace' are for diagnosing a problem and are noisy."
|
||||
@@ -0,0 +1,255 @@
|
||||
# Home Assistant add-on-vertalingen - NEDERLANDS (UX-01)
|
||||
#
|
||||
# ⚠️ DIT IS GEEN WOORD-VOOR-WOORDVERTALING VAN en.yaml en hoort dat ook niet te
|
||||
# zijn. De eigenaar draait Home Assistant op Europe/Brussels; dit is de tekst
|
||||
# die een Vlaamse huiseigenaar leest, met de woorden die op zijn factuur en op
|
||||
# zijn digitale meter staan: afname en injectie, kwartierpiek en
|
||||
# capaciteitstarief, laadniveau, uitschakelgrens.
|
||||
#
|
||||
# ⚠️ DE TERUGVAL IS PER SLEUTEL. De frontend probeert eerst de taal van de
|
||||
# gebruiker, dan `en`, dan de kale sleutelnaam. Een sleutel die HIER ONTBREEKT
|
||||
# valt dus netjes terug op het Engels; een sleutel die hier STAAT maar fout is,
|
||||
# valt nergens op terug. Een half vertaalde sleutel weglaten is beter dan hem
|
||||
# verkeerd invullen.
|
||||
#
|
||||
# ⚠️ DE VOLGORDE WORDT HIER NIET BEPAALD. Het tabblad Configuratie rendert in de
|
||||
# volgorde van het `schema:`-blok van config.yaml; de volgorde hieronder is
|
||||
# alleen een spiegel daarvan, zodat beide bestanden gelijk lezen.
|
||||
#
|
||||
# ⚠️ AFGELEIDE CONSTANTEN VERMELDEN WAAR ZE VANDAAN KOMEN. Het tabblad toont een
|
||||
# getal dat uit 1890 gesloten-lustests komt en het omvormervermogen van de
|
||||
# eigenaar als hetzelfde soort veld. De beschrijving is de enige plek waar dat
|
||||
# verschil gezegd kan worden.
|
||||
configuration:
|
||||
setpoint_entity:
|
||||
name: "Hardware · Setpoint-entiteit van de omvormer"
|
||||
description: "De ESPHome number-entiteit waar deze add-on het vermogen naartoe schrijft, meestal number.*_goodwe_setpoint_w. Dit is het enige wat de add-on aanstuurt. Laat leeg als u hieronder de lijst met omvormers invult: een installatie met een lijst mag niet óók één losse aansturing hebben, en de add-on start dan niet op."
|
||||
soc_entity:
|
||||
name: "Hardware · Entiteit met het laadniveau van de batterij (%)"
|
||||
description: "De sensor die de laadtoestand van de batterij in procent doorgeeft. Gebruik de meting van de ESP32 zelf, geen waarde die elders berekend wordt. De onderhoudscyclus en alle ondergrenzen voor het laadniveau lezen deze sensor."
|
||||
batt_entity:
|
||||
name: "Hardware · Entiteit met het batterijvermogen (W)"
|
||||
description: "De sensor die het batterijvermogen van deze omvormer doorgeeft, positief bij ontladen. Op deze installatie is dat holding register 0x0518, uitgelezen door de ESP32. Zowel de tekenbewaking als het alarm voor rondpompend vermogen beoordelen de omvormer aan de hand van deze meting."
|
||||
batt_invert:
|
||||
name: "Hardware · Teken van het batterijvermogen omkeren"
|
||||
description: "Zet dit alleen aan als uw sensor ontladen als een negatief getal doorgeeft. De add-on gaat ervan uit dat positief ontladen betekent."
|
||||
inverters:
|
||||
name: "Hardware · Lijst met omvormers (leeg laten bij één omvormer)"
|
||||
description: "Laat dit leeg tenzij er echt meer dan één omvormer staat. LEEG BETEKENT NIET 'nog niet ingesteld': het betekent dat de vier entiteiten hierboven, samen met het omvormervermogen en de uitschakelgrens van de batterij onder Grenzen, de ene omvormer beschrijven die u hebt. Dat is wat elke bestaande installatie leest, zonder dat iemand iets hoeft aan te passen. Vult u de lijst wel in, dan VERVANGT die de instellingen hierboven in plaats van ze aan te vullen, en wordt er niets overgenomen: elk item moet zijn eigen aansturing, zijn eigen vermogen en zijn eigen uitschakelgrens vermelden, zodat geen enkele omvormer ooit beoordeeld wordt op de cijfers van een andere. De losse setpoint-entiteit hierboven moet dan leeg blijven."
|
||||
fields:
|
||||
name:
|
||||
name: "Naam"
|
||||
description: "Een korte aanduiding voor deze omvormer, bijvoorbeeld inverter-a. Ze geeft deze omvormer een eigen naamruimte voor haar veiligheidsvergrendeling, hartslag en schrijfadministratie in het statusbestand van de add-on, dus twee omvormers mogen er niet één delen."
|
||||
setpoint_entity:
|
||||
name: "Setpoint-entiteit"
|
||||
description: "De eigen ESPHome-entiteit number.*_goodwe_setpoint_w van deze omvormer. Twee items mogen niet dezelfde entiteit noemen - twee schrijvers op één aansturing wordt bij het opstarten geweigerd."
|
||||
max_w:
|
||||
name: "Vermogen van deze omvormer (W)"
|
||||
description: "De bewaakte bovengrens van wat er naar DEZE omvormer geschreven mag worden; het plafond van de installatie is de som over de lijst. Verplicht bij elk item: een item zonder deze waarde zou een vermogen moeten lenen, en een omvormer beoordelen op de cijfers van een andere is precies de fout die deze lijst per omvormer moet wegnemen."
|
||||
bms_soc_floor:
|
||||
name: "Uitschakelniveau van de batterij (%)"
|
||||
description: "Het percentage waarbij het batterijmanagementsysteem van DEZE batterij uitschakelt. Lees dit af van de batterij die er werkelijk staat (register 0x0560) voordat die batterij ooit aangestuurd wordt; neem geen waarde van een andere omvormer over. Bewust verplicht bij elk item: optioneel maken zou neerkomen op een standaardwaarde, en één standaardwaarde voor twee verschillende batterijen beschermt de kleinste batterij met het getal van de grootste."
|
||||
soc_entity:
|
||||
name: "Entiteit met het laadniveau (%) (optioneel)"
|
||||
description: "Het laadniveau van deze batterij zelf. Laat leeg als deze omvormer dat niet doorgeeft - dat is wat het betekent, en er wordt nooit teruggevallen op de sensor van een andere omvormer."
|
||||
batt_entity:
|
||||
name: "Entiteit met het batterijvermogen (W) (optioneel)"
|
||||
description: "Het batterijvermogen van deze omvormer zelf, positief bij ontladen. Laat leeg als deze omvormer dat niet doorgeeft; er wordt niets van een ander item overgenomen."
|
||||
batt_invert:
|
||||
name: "Teken van het batterijvermogen omkeren (optioneel)"
|
||||
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485-noodstop is geplaatst"
|
||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
||||
meter_source:
|
||||
name: "Meter · Waar de meterstand vandaan komt"
|
||||
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."
|
||||
meter_entity:
|
||||
name: "Meter · Entiteit met het netvermogen (W)"
|
||||
description: "De sensor met het netto netvermogen, gebruikt wanneer de meterbron 'off' is. POSITIEF MOET AFNAME VAN HET NET BETEKENEN. Staat dit omgekeerd, dan stuurt de regeling de batterij de verkeerde kant op; gebruik daarvoor de tekenschakelaar hieronder en geen template-sensor."
|
||||
meter_invert:
|
||||
name: "Meter · Teken van het netvermogen omkeren"
|
||||
description: "Zet dit aan als uw netsensor afname van het net als een negatief getal doorgeeft."
|
||||
meter_phases:
|
||||
name: "Meter · Aantal fasen (1 of 3)"
|
||||
description: "Moet overeenkomen met uw aansluiting. Een telegram met een ander aantal fasen wordt bij binnenkomst geweigerd en gelogd, zodat een verkeerde waarde meteen zichtbaar wordt als 'geen telegrammen aanvaard' in plaats van als een stil verkeerd getal. Het bereik aanvaardt 2, maar dat is geen echte Belgische aansluiting."
|
||||
p1_host:
|
||||
name: "Meter · Adres van de HomeWizard P1"
|
||||
description: "Alleen voor meterbron homewizard_local: het adres van de meter zelf op uw netwerk, als host of host:poort, bijvoorbeeld 192.168.2.250."
|
||||
meter_poll_s:
|
||||
name: "Meter · Seconden tussen twee uitlezingen"
|
||||
description: "Alleen voor meterbron homewizard_local. 5 s is het tempo waarin de meter zelf ververst, dus lager instellen levert niets op. Deze waarde moet ruim onder de verouderingsgrens hieronder blijven - de meting is nooit verser dan dit interval - en de add-on start niet op als ze eraan gelijk is of erboven ligt."
|
||||
meter_max_age_s:
|
||||
name: "Meter · Een meting is verouderd na (s)"
|
||||
description: "Daarna geldt de netwaarde als ontbrekend. Op zichzelf stuurt dit nog geen 0 W aan: de teller 'invoer ontbreekt' onder Geavanceerd begint pas te lopen nadat deze verstreken is, en de twee stapelen. 30 s is zes uitlezingen speling bij het standaard uitleesinterval."
|
||||
meter_mqtt_topic:
|
||||
name: "Meter · MQTT-topic"
|
||||
description: "Alleen voor meterbron mqtt_p1: het topic waarop de P1-metingen gepubliceerd worden."
|
||||
p1_import_entity:
|
||||
name: "Meter · Entiteit van het afnameregister (W)"
|
||||
description: "Het afnameregister ZONDER teken. Wijs dit niet aan een template-sensor mét teken toe: de add-on berekent het nettovermogen zelf als afname min injectie, zodat de tekenafspraak in geteste code zit en niet in ongecontroleerde YAML."
|
||||
p1_export_entity:
|
||||
name: "Meter · Entiteit van het injectieregister (W)"
|
||||
description: "Het injectieregister ZONDER teken, de tegenhanger van het afnameregister hierboven. Zelfde regel: geen template-sensor mét teken."
|
||||
p1_net_entity:
|
||||
name: "Meter · Entiteit met het nettovermogen mét teken (W)"
|
||||
description: "Alleen voor meterbron ha_signed. ÉÉN sensor mét teken: positief is afname van het net, negatief is injectie. Splits die niet in twee template-sensoren op - de add-on doet die splitsing zelf, juist zodat ze getest is."
|
||||
p1_phase_import_entities:
|
||||
name: "Meter · Afname-entiteiten per fase (L1, L2, L3)"
|
||||
description: "Optioneel, in de volgorde L1..L3. Nodig voor de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen hierboven."
|
||||
p1_phase_export_entities:
|
||||
name: "Meter · Injectie-entiteiten per fase (L1, L2, L3)"
|
||||
description: "Optioneel, in de volgorde L1..L3, naast de afname-entiteiten per fase hierboven. Het aantal items moet gelijk zijn aan het aantal fasen."
|
||||
p1_phase_net_entities:
|
||||
name: "Meter · Netto-entiteiten per fase mét teken (L1, L2, L3)"
|
||||
description: "Alleen voor meterbron ha_signed. Optioneel, in de volgorde L1..L3, elk met hetzelfde teken als de netto-entiteit hierboven. Zelfde rol als de afname- en injectie-entiteiten per fase: de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen."
|
||||
max_w:
|
||||
name: "Grenzen · Vermogen van de omvormer (W)"
|
||||
description: "DIT IS UW GETAL, geen afgeleide waarde: de harde bovengrens van wat er ooit gestuurd mag worden, in watt. Zet het op wat uw omvormer werkelijk aankan, begin laag en verhoog het pas na de indienstname. Het wordt helemaal niet gelezen zodra de lijst met omvormers is ingevuld - elk item draagt dan zijn eigen waarde en het plafond van de installatie is hun som. Verschillende veiligheidsdrempels moeten eronder blijven; de add-on start niet op als er één niet aan voldoet."
|
||||
bms_soc_floor:
|
||||
name: "Grenzen · Uitschakelniveau van de batterij (%)"
|
||||
description: "GEMETEN, niet gekozen: het percentage waarbij het batterijmanagementsysteem van de batterij ZELF stopt met ontladen, wat u hier ook instelt. Register 0x0560 leest 10 op de referentie-GW5000S-BP. Lees deze waarde af van de batterij die er werkelijk staat in plaats van dit getal over te nemen. Ze staat hier zodat de add-on een ondergrens voor de piekbewaking op of onder deze waarde kan weigeren: zo'n ondergrens kan nooit werken, het BMS wint altijd eerst, en de statuspagina zou een reserve tonen die niet bestaat. Wordt niet gelezen zodra de lijst met omvormers is ingevuld - elk item vermeldt dan de uitschakelgrens van zijn eigen batterij."
|
||||
peak_cap_w:
|
||||
name: "Grenzen · Streefwaarde capaciteitstarief (W)"
|
||||
description: "DIT IS UW GETAL: het gemiddelde kwartiervermogen waar de piekbewaking onder probeert te blijven. Neem het over uit uw netcontract - in België is dit de kwartierpiek waarop het capaciteitstarief van uw distributiefactuur berekend wordt."
|
||||
peak_forecast_entity:
|
||||
name: "Grenzen · Entiteit met de kwartierprognose (W)"
|
||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende factuurkwartier. Laat u dit leeg, dan is er helemaal geen piekbegrenzing."
|
||||
peak_soc_floor:
|
||||
name: "Grenzen · Piekbewaking ontlaadt niet onder (%)"
|
||||
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
|
||||
price_provider:
|
||||
name: "Tarief · Bron van dynamische prijzen"
|
||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ Dit vandaag aanzetten haalt prijzen op, controleert en publiceert ze, en verandert geen enkele energiebeslissing - er is nog niets dat de prijsreeks gebruikt. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tarief · Marge van de leverancier (EUR/kWh)"
|
||||
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij engie_dynamic, totalenergies_dynamic, eneco_eddy en manual_csv wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
|
||||
price_taxes_eur_kwh:
|
||||
name: "Tarief · Heffingen en btw (EUR/kWh)"
|
||||
description: "Belastingen en heffingen per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat."
|
||||
export_price_eur_kwh:
|
||||
name: "Tarief · Injectievergoeding (EUR/kWh)"
|
||||
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."
|
||||
static_profile:
|
||||
name: "Tarief · Vast of dag/nacht-tarief (één regel JSON)"
|
||||
description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief."
|
||||
price_csv_path:
|
||||
name: "Tarief · CSV-bestand met prijzen"
|
||||
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
|
||||
price_feed_url:
|
||||
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
||||
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen."
|
||||
price_horizon_h:
|
||||
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
|
||||
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
|
||||
price_timeout_s:
|
||||
name: "Tarief · Time-out voor een prijsaanvraag (s)"
|
||||
description: "Hoe lang één aanvraag bij de prijsbron mag duren voordat ze wordt opgegeven."
|
||||
price_now_entity:
|
||||
name: "Tarief · Entiteit met de huidige prijs (oudere prijsweg)"
|
||||
description: "De oudere manier om aan een prijs te komen, behouden omdat ze weghalen de onderhoudscyclus op een draaiende installatie zijn enige prijssignaal zou afnemen. De add-on leest deze entiteit en het gemiddelde hieronder en leidt er precies één ding uit af - of het nu goedkoper is dan gemiddeld - wat alleen gebruikt wordt om tijdens de onderhoudscyclus in een goedkoop venster te laden. Mag niet gecombineerd worden met een dynamische prijsbron."
|
||||
price_avg_entity:
|
||||
name: "Tarief · Entiteit met de gemiddelde prijs (oudere prijsweg)"
|
||||
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
|
||||
arbitrage_enabled:
|
||||
name: "Tarief · Handelen op het prijsverschil"
|
||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
|
||||
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
|
||||
arbitrage_soc_floor:
|
||||
name: "Tarief · Handel ontlaadt niet onder (%)"
|
||||
description: "De reserve die de prijshandel niet mag aanspreken. Bewust een andere instelling dan de ondergrens van het onderhoud: een handelsstrategie op de cijfers van de maandelijkse balanceercyclus richten, zou twee ongerelateerde keuzes aan één getal koppelen."
|
||||
arbitrage_soc_ceiling:
|
||||
name: "Tarief · Handel laadt niet boven (%)"
|
||||
description: "De ruimte die de prijshandel niet mag volmaken. De tegenhanger van de ondergrens hierboven, en om dezelfde reden losgehouden van het onderhoudsdoel."
|
||||
maintenance_enabled:
|
||||
name: "Onderhoud · Maandelijkse batterijcyclus uitvoeren"
|
||||
description: "Een bewuste diepe ontlading, gevolgd door volledig opladen en een rustperiode op vol, zodat het batterijmanagementsysteem zijn schatting van het laadniveau kan herijken en de cellen kan balanceren. Staat standaard uit."
|
||||
maintenance_interval_days:
|
||||
name: "Onderhoud · Minimum aantal dagen tussen cycli"
|
||||
description: "De kortste tijd tussen twee onderhoudscycli."
|
||||
maintenance_start_hour:
|
||||
name: "Onderhoud · Uur waarop een geplande cyclus start"
|
||||
description: "Lokaal uur, 0 tot 23, waarop een cyclus die aan de beurt is begint."
|
||||
maintenance_discharge_w:
|
||||
name: "Onderhoud · Ontlaadvermogen (W)"
|
||||
description: "Hoe snel de cyclus de batterij leegtrekt; het overschot gaat naar het net. Boven het vermogen van de omvormer wordt dit BEGRENSD tot dat vermogen en niet geweigerd - de cyclus loopt gewoon door, alleen trager, en de statuspagina meldt dat. Standaard 2000, afgestemd op het standaardvermogen; verhoog eerst het omvormervermogen en dan pas dit."
|
||||
maintenance_charge_w:
|
||||
name: "Onderhoud · Laadvermogen (W)"
|
||||
description: "⚠️ MOET GELIJK AAN OF LAGER ZIJN dan het vermogen van de omvormer; anders start de add-on niet op. Erboven vraagt het laden in een goedkoop venster meer dan de bewaakte grens toelaat, lost de arbiter die tegenstrijdigheid op naar 0 W, en zou het onderhoud op een standaardinstallatie stilzwijgend nooit laden in een goedkoop venster. Daarnaast wordt dit nog eens begrensd door de ruimte die de piekbewaking overlaat."
|
||||
maintenance_soc_floor:
|
||||
name: "Onderhoud · Ontladen tot (%)"
|
||||
description: "De diepte waar de maandelijkse herijking bewust naartoe gaat, één keer per maand, met opzet. ⚠️ DIT IS NIET DE ONDERGRENS VAN DE PIEKBEWAKING: die geeft aan hoe diep een besparende ontlading op geen enkele dag mag komen. De juiste waarden verschillen echt - 11 hier tegenover 20 daar - en daarom zijn het twee aparte instellingen die niet samengevoegd mogen worden."
|
||||
maintenance_soc_target:
|
||||
name: "Onderhoud · Opladen tot (%)"
|
||||
description: "Het niveau waar de laadfase naartoe werkt voordat de rustperiode begint."
|
||||
maintenance_hold_min:
|
||||
name: "Onderhoud · Op vol houden gedurende (minuten)"
|
||||
description: "Hoe lang er op het streefniveau gebleven wordt zodat het batterijmanagementsysteem het balanceren van de cellen kan afmaken."
|
||||
sign_disagree_w:
|
||||
name: "Veiligheid · Drempel tekenbewaking (W) — gemeten, niet wijzigen"
|
||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Zowel het uitgestuurde setpoint als de netmeting moet boven deze waarde liggen voordat hun tegengestelde tekens überhaupt als onenigheid tellen. Op de vastgelegde gezonde dag (23-08-2026) komt het teken in 93,4 % van de beoordeelde metingen overeen; op de vastgelegde foutdag (20-08-2026) ligt dat tussen 0,0 en 10,5 %, met tegengestelde periodes tot 1752 s. Er is geen reden om hieraan te sleutelen zonder een nieuwe meetcampagne die dat onderbouwt. De waarde moet onder het omvormervermogen blijven, anders kan de bewaking nooit aanslaan, en de add-on weigert die instelling."
|
||||
sign_disagree_s:
|
||||
name: "Veiligheid · Wachttijd tekenbewaking (s) — gemeten, niet wijzigen"
|
||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Hoe lang het setpoint en de meter onafgebroken tegengesteld moeten blijven voordat de add-on 0 W stuurt en vergrendelt tot iemand de regeling opnieuw start. 180 s ligt midden in een zeer brede gemeten marge: de langste terechte tegengestelde periode op de gezonde dag duurt 30 s, terwijl de foutdag bij 300 s nog altijd 13 keer aanslaat. Verander dit niet zonder nieuwe metingen."
|
||||
unit_sign_disagree_w:
|
||||
name: "Veiligheid · Tekendrempel per omvormer (W) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer doet deze instelling niets. AFGELEID UIT 1890 GESLOTEN-LUSTESTS op de testopstelling met twee omvormers (SAFETY-30), waarbij niets defect was, over het volledige bereik dat deze add-on toelaat voor versterking en volgsnelheid. 250 W is precies de helft van de verzadigingsdrempel, zodat het kleinste verschil dat hier zichtbaar is, exact de afwijking is waarbij de regeling zelf al zegt dat de omvormer niet volgt. Verander dit niet zonder de afleiding opnieuw te maken. De waarde moet onder het omvormervermogen blijven, anders kan ze nooit aanslaan."
|
||||
unit_sign_disagree_s:
|
||||
name: "Veiligheid · Wachttijd per omvormer (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30): 85 s is 8,5 keer de langste terechte tegengestelde periode die die tests opleveren (10,0 s), 2,8 keer het eigen veto van 30 s in de firmware, en minder dan de helft van de 180 s die de installatiebrede controle neemt. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
site_sign_disagree_w:
|
||||
name: "Veiligheid · Tekendrempel voor de hele installatie (W) — gemeten, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. Dezelfde 300 W als de drempel voor één omvormer, BEWUST OVERGENOMEN (SAFETY-30): de onderbouwing hangt niet af van het aantal omvormers - rond nul is het teken ruis, de dode zone is 15 W, en er is één meter, wat er ook achter hangt. Verander dit niet zonder metingen die dat onderbouwen."
|
||||
site_sign_disagree_s:
|
||||
name: "Veiligheid · Wachttijd voor de hele installatie (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30), en bewust NIET overgenomen van de 180 s voor één omvormer. In die tests duurt de langste terechte tegengestelde periode bij 300 W 110 s met één omvormer en 215 s met twee, omdat het commando de volledige installatiegrens moet doorlopen aan een volgsnelheid die niet die van de installatie is. 180 s overnemen zou dus een vals alarm hebben opgeleverd bij de laagste versterking en volgsnelheid en een gewone belastingomkering van 2,2 kW. 325 s is 1,51 keer die waarde, blijft binnen het factuurkwartier van 900 s en binnen elke episode van de vastgelegde foutdag. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
meter_freeze_s:
|
||||
name: "Veiligheid · Alarm vastgelopen meter na (s) — gemeten, niet wijzigen"
|
||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-18). Hoe lang de meter mag blijven antwoorden met een ONVERANDERDE waarde, terwijl het commando op de grens staat, voordat de add-on alarm slaat. Over 2399 s van deze woning op haar ingeregelde werkpunt - 1194 uitlezingen, waarvan 735 een herhaling van de vorige waarde - duurt de langste periode zonder een nieuwe waarde 15,4 s; 300 s is 19,5 keer zoveel en blijft binnen het factuurkwartier van 900 s. ALLEEN EEN ALARM: er wordt geen 0 W gestuurd en er wordt niets vergrendeld. ⚠️ Alleen de meterbron homewizard_local kan dit überhaupt zien; bij elke andere bron meldt de statuspagina NIET GEDEKT in plaats van actief. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
circulation_w:
|
||||
name: "Veiligheid · Drempel rondpompend vermogen (W) — gemeten, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer is deze grootheid altijd nul. Twee omvormers die tegen elkaar in duwen, heffen elkaar aan de meter op en zijn onzichtbaar voor elke andere bewaking in deze add-on. GEMETEN OVER 924 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-31): de langste terechte tegenwerking valt terug van 505 s bij een drempel van 100 W naar 5 s bij 400 W, en 400 W is de scherpe knik waaronder deze add-on u niet laat gaan - daaronder houdt gewone werking de sensor minutenlang boven de drempel en kan niets korter dan het factuurkwartier dat van een storing onderscheiden. 700 W is 1,75 keer de knik; die extra marge is er omdat het aanloopgedrag van de tweede omvormer nog gekopieerd is van de eerste en niet gemeten. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
circulation_hold_s:
|
||||
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
auto_start:
|
||||
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
|
||||
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
|
||||
gain:
|
||||
name: "Geavanceerd · Versterking van de regellus"
|
||||
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
|
||||
slew_w:
|
||||
name: "Geavanceerd · Grootste verandering per cyclus (W)"
|
||||
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. Dit begrenst hoe snel de installatie kan omkeren; verschillende veiligheidsvensters zijn over het volledige toegelaten bereik gemeten."
|
||||
deadband_w:
|
||||
name: "Geavanceerd · Dode zone (W)"
|
||||
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."
|
||||
target_grid_w:
|
||||
name: "Geavanceerd · Rustpunt aan de meter (W)"
|
||||
description: "Waar de meter moet staan wanneer er niets gebeurt; negatief betekent een lichte injectie. -10 W is bewust gekozen: rusten op 0 W lekt de hele dag door ongeveer de helft van de dode zone aan gefactureerde afname - grofweg 0,2 tot 0,35 kWh bij een dode zone van 15 W - en het rustpunt onder nul leggen verplaatst dat lek naar het injectieregister, dat niet gefactureerd wordt. Het kost ongeveer evenveel watt weggegeven injectie, dus houd het klein."
|
||||
step_w:
|
||||
name: "Geavanceerd · Afronding van het setpoint (W)"
|
||||
description: "Het setpoint wordt op een veelvoud hiervan afgerond voordat het geschreven wordt, zodat de omvormer niet voor elke watt ruis een nieuw getal krijgt."
|
||||
saturation_w:
|
||||
name: "Geavanceerd · Verzadigingsdrempel (W)"
|
||||
description: "Het verschil tussen wat gevraagd werd en wat de omvormer levert dat geldt als 'deze omvormer zit aan een grens'. ⚠️ De tekendrempel per omvormer onder Veiligheid is afgeleid als precies de helft hiervan, dus deze waarde verschuiven, verschuift ook de onderbouwing daarvan."
|
||||
saturation_cycles:
|
||||
name: "Geavanceerd · Verzadigde cycli voor bevriezen (aantal)"
|
||||
description: "Hoeveel opeenvolgende verzadigde cycli er nodig zijn voordat de regeling stopt met verder oplopen. ⚠️ Zet dit niet op 1: één ruiscyclus zou de regeling dan al bevriezen."
|
||||
integrator_max_w:
|
||||
name: "Geavanceerd · Begrenzing van de integrator (W, 0 betekent gelijk aan het vermogen)"
|
||||
description: "Begrenst hoeveel oude afwijking er kan klaarstaan om af te bouwen wanneer het teken omslaat. GEMETEN op de gesloten-lussimulator met een belasting van 4000 W die naar nul valt: bij een grens gelijk aan het omvormervermogen staat het commando twee cycli later op 1000 W, bij anderhalf keer dat vermogen op 1800 W. ⚠️ Zet dit niet hoger dan het omvormervermogen - de uitgangsbegrenzing bepaalt toch al wat er naar de omvormer gaat, dus extra ruimte levert alleen watts in de verkeerde richting op na elke verzadiging. Lager zetten dan het omvormervermogen is de nuttige richting, en de enige reden dat dit een aparte instelling is."
|
||||
heartbeat_s:
|
||||
name: "Geavanceerd · Verversingsinterval van het setpoint (s)"
|
||||
description: "Hoe vaak het setpoint opnieuw geschreven wordt, ook als het niet veranderd is. Dit moet ruim onder de eigen bewaking van de firmware blijven, die een verbinding die 30 s stil is geweest, blokkeert."
|
||||
stale_input_s:
|
||||
name: "Geavanceerd · 0 W sturen nadat invoer ontbreekt gedurende (s)"
|
||||
description: "Hoe lang de invoer mag ontbreken voordat de add-on 0 W stuurt. Bij een P1-meter begint deze teller pas te lopen NADAT de verouderingsgrens van de meter al verstreken is, dus de twee stapelen: het totaal is langer dan dit getal op zich."
|
||||
cycle_floor_s:
|
||||
name: "Geavanceerd · Langste tijd tussen twee regelcycli (s)"
|
||||
description: "Een uiterste termijn voor de tijd tussen twee cycli, die door elke cyclus opnieuw begint. Bij een gezonde meter verstrijkt hij nooit en verandert hij niets. Verhoog hem alleen als uw meter trager is dan ongeveer 10 s, en houd hem onder de verouderingsgrens van de meter - daarna is er toch geen meting meer om te beoordelen."
|
||||
log_level:
|
||||
name: "Geavanceerd · Logniveau"
|
||||
description: "Hoeveel detail de add-on naar zijn logboek schrijft. 'info' is de standaardinstelling; 'debug' en 'trace' dienen om een probleem te onderzoeken en leveren veel tekst op."
|
||||
Reference in New Issue
Block a user