Compare commits

..
21 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 f76639c7b2 TEST-21: two of the argmax's four axes are on an edge, and the doc did not say so
checks / unit (pull_request) Successful in 6m22s
§3.3 already set the standard - "a worst case sitting on an edge of the grid
would be a number about the grid" - and then did not check its own argmax
against it. Review did. Reproduced here independently before writing it down:

    stagger  0 -> 1 -> 2 -> 3 -> 4      0.0  15.0  25.0  30.0  35.0 s
    weights (A) at stagger 4            0.5 -> 20.0   0.7/0.9/0.99 -> 0.0
                                        0.10 -> 35.0 s

Strictly increasing in stagger, maximal at the largest modelled stagger, and
maximal at the most asymmetric split. Interior only in `gain` and `slew_w`.

⚠️ SO THE HEADLINE IS 0.0 s AT stagger=0. The 35.0 s is ENTIRELY a product of
the modelled serialised-write stagger, which is declared MODELLED in the harness
and is a TERM of the figure rather than a detail of it. A site whose two writes
land in the same cycle does not produce this episode at all.

It does not move `delivery_hold_s`: 185 s must cover the worst the modelled site
can produce, and four cycles of stagger is inside what a real two-inverter write
path can do. What changes is that a reader asking "is 35.0 s about the plant or
about the grid?" now gets the answer instead of the invitation.

⚠️ THE PR BODY HAD CLAIMED THE ARGMAX WAS "interior to the grid, not on an edge
of an axis I had to choose". That was wrong, and it is the claim the doc's own
standard exists to test. The grid is the record's own, fixed by §3.3 before this
rebuild ran, which is the mitigating half and is stated too.

Also: `nl.yaml` wrote "35.0 s" beside "5,29 keer" and "75,0 s" in one sentence.
Dutch takes the comma.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
adminandClaude Opus 5 57775d4e06 TEST-21: delivery_hold_s's floor re-derived, and 30.0 s did not reproduce
No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.

`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.

The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.

    cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
        python3 -B delivery_window.py --selftest --campaign --blag

⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.

WHAT REPRODUCED - and it is the part the compatibility promise rests on:
  * the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
    measured, not argued.
  * the flapping family is 0.0 s everywhere in all 840 runs - the family that
    defeated SAFETY-30's site window still cannot touch this check.
  * 45.0 s at twice A's lag, to the cycle.

WHAT DID NOT:
  * the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
    two (20.0 -> 30.0 s).
  * the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
    at 100 W as a property of the reduction. This rebuild's curve is monotone.
    The mechanism is real and is still the reason not to read either table as a
    sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
    written down as weaker.
  * the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
    there, but that gap is the honest measure of how much of the original table
    was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
    RANGE and no individual value; the rebuild prints its choices above its own
    table so the caveat travels with the number.
  * the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.

185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.

⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.

⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.

Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
admin bb469163a6 Merge pull request 'UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02' (#66) from UX-04 into release/1.0 2026-09-07 19:03:35 +02:00
glenn schrooyenandClaude Opus 5 477911afa1 UX-04: the 59 was computed and then graded against "> 15"
checks / unit (pull_request) Successful in 6m22s
Review approved and found four things worth fixing. Three are cosmetic; the
fourth is the same defect this card is about, one level up.

⚠️ THE FLOOR WAS PRINTED AS 59 AND ASSERTED AS `> 15`. So a manifest change
dropping the required count to 20 would have passed while the sentence beside it
still said 59 - a number displayed but not graded, which is exactly what let
UX-01's AC 3 survive review in the first place, and exactly the shape
`test_translations.py` was already fixed for.

Now DERIVED and graded in two separate assertions, because they are two
different claims:

  1. `_EXPECT_REQ = len(schema) - len(_OPTIONAL)`, where optional is Supervisor's
     own rule - a `?` suffix, plus `inverters` because `UiOptions` marks a list
     optional - asserted EQUAL to Supervisor's required count. The printed
     number is computed, so it cannot go stale.
  2. ⚠️ AND THE WITHDRAWAL'S PREMISE, SEPARATELY. The equality alone is not
     enough and I checked why: both sides read the same `?` convention, so
     marking a key optional moves BOTH and it stays green - measured, by making
     `slew_w` optional and watching it pass. That grades Supervisor agreeing
     with us, NOT the thing AC 3 was withdrawn for. The second assertion grades
     the premise: the floor is still far above the ~15 AC 3 asked for, and if it
     ever is not, the withdrawal needs re-arguing rather than re-asserting.

Driven, both directions, against the real 2026.08.0 image:

    clean                         PASS  "floor is DERIVED: 71 schema keys minus
                                         12 optional ones is 59, and
                                         Supervisor's own required flag agrees"
    premise threshold 15 -> 40    FAIL  "the floor of 59 is still far above
                                         the ~40 AC 3 asked for"

Also: a dangling reference to `DECISIONS/UX-04-hiding-and-the-deepmerge.md`,
which was never the filename shipped, and `DOCS.md` still saying section F
"prints" what it now grades.

Not changed, and review agreed both are disclosed rather than defects: the
pair-keyed ledger cannot see a SECOND site for a pair it already holds (the
site count can grow while the pair set cannot), and the probe's assertions run
by hand rather than in CI because they need the Supervisor image - the same
status as before this card, not a regression.

Suite: 27 files, all pass. Probe: all cases passed on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, --network none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:56:55 +02:00
Claude Opus 5 21f22b393a UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02
checks / unit (pull_request) Successful in 6m20s
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is
LIVE, so all three describe the running install. No behaviour, no schema and no
default moves here: config.yaml, translations/ and every module under app/ are
byte-identical to 6e7b233.

⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and
the arithmetic is the reason rather than the effort. options: and schema: both
declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key
only when it is ?-optional AND absent from options:, and nothing is absent. But
the deeper fact is that no manifest could have met it: Supervisor's own
UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter -
`schema.filter(e => e.name in opts || e.required)`, transcribed out of the
bundle in the release image - shows a required field unconditionally. So 59 is
the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything
reachable.

⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH.
A key absent from options: has no default for the Supervisor to deep-merge into
an existing box's stored blob. That is exactly the delivery_hold_s mechanism
this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to
its options: default of 185 instead of becoming exit 78. Hiding a key is
precisely the edit that removes that. It would also promote the 27 unpinned
in-code literal fallbacks the new check ledgers into the ACTUAL source of the
defaults on a live install - the CFG-03 defect the same card's AC 4 exists to
police, made load-bearing by its AC 3.

⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW.
supervisor_shape_probe.py section F has printed "the Configuration tab renders
71 fields" since the day it was written and exited 0 through UX-01's whole
review while displaying the number that contradicted the criterion it was
evidence for. Three assertions replace the bare print, every number computed
from the manifest and from Supervisor's own `required` flag rather than typed,
including an ISOLATED COUNTERFACTUAL that empties options: and nothing else.
MEASURED RED: delete one key (price_feed_url) from options: and two of them
fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1.

--- AC 4: the AST check, built --------------------------------------------

AC 4's first clause is VACUOUS over an empty set (see above) and its second -
"the table changed goes red" - already works six times over. The third, the one
with the content, was never written: the only literal scan in the repository is
test_validate.py's regex over eight maintenance_* keys, which is a grep and
predates the card.

test_defaults_pin.py walks ast.Call nodes over app/*.py. It:

  - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than
    naming them. Every existing pin names its own table by hand, so a SEVENTH
    table added tomorrow is pinned by nobody and nothing says so;
  - grades all 30 mirrored keys against config.yaml, and requires that no
    module carries a literal fallback for any of them;
  - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by
    pair and not by count - a count goes green again the moment one site is
    deleted and another added. Line numbers are deliberately out of the key: a
    ledger that reddens on an unrelated reformat is one people re-bless without
    reading. It can only shrink;
  - grades every remaining literal AGAINST config.yaml, which is the half that
    catches CFG-04's actual failure - two copies of one number, one moves.

⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for:
main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10.
It cannot reach an operator - the deep-merge guarantees the key is present - and
the only paths that hand over a partial dict are sim/inject.py and this repo's
fixtures. Recorded as the single permitted divergence with its reason rather
than corrected; changing it alters the control loop's target and belongs to a
card that grades the loop. A second divergence goes red.

⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic
module containing a deliberate violation of each rule and REQUIRES it to be
caught, in-process, through the same function the graded parts call - so a
scanner that goes blind stops working there too - and the site total is graded
against a floor. And a literal was planted in the REAL tree:

    main.py:290  float(opts.get("maintenance_charge_w", 2000))
    -> FAIL no literal fallback survives for any of the 30 keys a mirror
       already answers for                                          exit 1

    main.py:290  opts.get("p1_host", "x"), opts.get("slew_w", 999)
    -> FAIL no NEW module/key pair has started carrying a literal fallback
            [('main.py', 'p1_host')]
       FAIL every remaining literal still AGREES with config.yaml
            main.py  slew_w: code 999 vs config.yaml 0               exit 1

Both plants removed.

--- AC 5: CFG-04 is closed, and not by this card's predecessor -------------

fef6ef2 touched config.yaml, translations/, test_translations.py and the probe -
no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation
survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same
triple mutation on a throwaway copy of 6e7b233: 26 files, RED at
test_selfconfig.py, exit 1 -

  1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches
  the rail collapsing to the 2000 default under an explicit 400 W slew

git log -L 310,325:test_selfconfig.py attributes that check to bcee235,
"UX-02: pin both halves of the max_w fix, because neither was graded". The AC is
met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done.

--- the 8 per-entry translations: they stay --------------------------------

Measured negative and not a defect in them: UiOptions gives inverters type
schema WITH multiple, the frontend sends that case to ha-selector-object, which
labels fields `fields[name].label || name`, and the converter sets neither label
nor translation_key. The Supervisor tab CANNOT be made to render them. They stay
because UX-02's ingress panel is the surface that can, because they cost
nothing, and because deleting them removes the only thing that would catch a
per-entry key added without a translation - which is how SAFETY-32's REQUIRED
bms_soc_floor would have joined the roster unlabelled. A written decision, not a
change; translations/ is untouched.

tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7.
That is TOOL-04, already filed, and it is not re-filed or fixed here.

--- evidence ---------------------------------------------------------------

  positive control  test_defaults_pin.py's mirror floor moved 6 -> 7 inside the
                    harness container: "### RED: test_defaults_pin.py", exit 1
  suite             27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the
                    checkout mounted read-only)
  shape probe       all cases passed, exit 0, on
                    ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0

Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or
sim/. Both probe runs were throwaway containers over a read-only mount.

DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four
decisions, the frontend transcription, and the two counterfactual transcripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:29:48 +02:00
admin 6e7b23388c Merge pull request 'PKG-13: the no-op half of the pre-deploy check has never run as documented' (#65) from PKG-13 into release/1.0 2026-09-07 17:34:18 +02:00
glenn schrooyenandClaude Opus 5 1ba35f9c99 PKG-13: one safe.directory flag, not two, and the tighter of the pair
checks / unit (pull_request) Successful in 6m19s
Review found `-c safe.directory={where}` and `-c safe.directory=*` set on the
same invocation, the specific value redundant once the wildcard is present.

Dropped the WILDCARD rather than the specific path, which is the opposite of
the suggestion and the tighter end of it: `_git()` always runs `-C where`, so
the only repository it ever reads is the one it was pointed at, and naming that
one path grants exactly the trust the tool needs. `*` granted trust to every
repository on the machine for the lifetime of each subprocess - harmless for
read-only `log`/`show` with no tty, as review measured, but broader than the
job.

Verified after the change: the documented container invocation still runs BOTH
halves and reports `no runtime change since the last bump` / `CLEARED for
deploy`, so the fix this branch exists for is intact.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 17:27:36 +02:00
glenn schrooyenandClaude Opus 5 0b653c35a6 PKG-13: the no-op half of the pre-deploy check has never run as documented
checks / unit (pull_request) Successful in 6m19s
⚠️ THE INVOCATION ON PKG-09's CARD AND IN THE TOOL'S OWN DOCSTRING RUNS ONLY
HALF THE CHECK. The container runs as uid 0 against a checkout owned by the
operator, and git refuses that:

    fatal: detected dubious ownership in repository at '/w/goodwe-addon'

`_git` returned non-zero, `last_version_change()` returned None, and the
silent-no-op check - THE REASON PKG-09 EXISTS - reported SKIPPED. Measured, on
the documented command against release/1.0:

    ⚠️  SKIPPED: could not ask git what changed since the last bump;
                 the no-op check is INCONCLUSIVE, not clean
    NOT CLEARED

and with `--user $(id -u):$(id -g)`, unchanged otherwise:

    no runtime change since the last bump - nothing to deploy
    CLEARED for deploy.

⚠️ THE TWO HALVES WERE MUTUALLY EXCLUSIVE ACROSS THE TWO DOCUMENTED
ENVIRONMENTS. In the container the validator ran and git skipped; on a bare host
git ran and the validator skipped on aiohttp. NEITHER DOCUMENTED COMMAND RAN
BOTH. The pre-0.9.0 clearance was sound only because it happened to be driven as
two separate invocations.

⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults the
git result only when `shipped == deployed`; every real run so far compared 0.7.0
against 0.9.0 and printed its verdict without ever needing git.

FIXED IN `_git()` WITH `safe.directory`, NOT BY DOCUMENTING `--user`. The tool
should be correct however it is invoked rather than requiring every future
caller to remember a flag; the read is read-only, on a repository the operator
is about to deploy from.

⚠️ WHY THE TESTS COULD NOT SEE IT, WHICH IS THE FINDING BEHIND THE FINDING.
`test_version_gate.py` invokes the gate with `sys.executable` against fixtures
it creates as its own uid, so no fixture could ever meet an ownership mismatch.
MUTATION.md habit 4 in a fourth shape, inside the file written to prevent it -
after the pickaxe bug, after the one-bump history, after the filter-only
survivor. Two fixtures added:

  - a directory that is NOT a git repository, so the lookup genuinely fails:
    the gate must exit 2 saying INCONCLUSIVE and must NOT say CLEARED.
  - ⚠️ THE EXIT CODES PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0` is
    satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
    earlier draft could not tell apart. 1 says "this deploy is wrong"; 2 says
    "nobody checked". Driven: collapsing 2 into 1 now goes red on exactly that
    check.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 17:02:01 +02:00
admin d0633665c9 Merge pull request 'PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship' (#64) from PKG-12 into release/1.0
PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship
2026-09-07 00:53:44 +02:00
glenn schrooyenandClaude Opus 5 8e3734f5ea PKG-12: the live options WERE checked, and the changelog said they were not
checks / unit (pull_request) Successful in 6m17s
Review approved the bump and, in doing so, dated one of its own paragraphs.
When this section was written the pre-deploy check had not been run and the
changelog said so, correctly. The owner then exported the reference install's
options and the check cleared, which makes the shipped sentence false in the
release notes an operator reads at the hardware.

Replaced with what was measured, twice:

  stored blob as-is                          exit 0, CLEARED
  effective post-upgrade config              exit 0, CLEARED
    (the 70 stored keys merged OVER 0.9.0's
     71 config.yaml defaults)

  max_w 5000, slew_w 400, rule-10 ceiling 555.6 W.

⚠️ AND THE SECOND CHECK IS NOT CEREMONY. The stored blob has 70 keys, not 71:
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so a box running 0.7.0 has
never stored it, and it is REQUIRED in the schema with no trailing `?` - the
exact shape that becomes exit 78. It resolves to its `options:` default of 185
because the Supervisor sets `App.options = deepmerge(defaults, stored)`, which
`tools/supervisor_selfapi_probe.py` §D pins against a real Supervisor image.
Review re-derived that independently rather than taking the reasoning on trust.

"Your saved settings are valid" and "what the box will actually run is valid"
are different questions, and only the second one is the one being asked before
a deploy.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 00:47:11 +02:00
glenn schrooyenandClaude Opus 5 86bdf49332 PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship
checks / unit (pull_request) Successful in 6m14s
⚠️ MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK, AND THAT DISTINCTION IS
CHECKED RATHER THAN ASSERTED. PKG-07 was minor because a config that started on
0.7.0 could be REFUSED on 0.8.0. Nothing of that kind is true here:

  - `options:` and `schema:` hold 71 top-level keys on both 06661c5 and HEAD.
    The one schema addition is a PER-ENTRY key, `capacity_kwh: float(0.1,200)?`,
    and the trailing `?` makes it OPTIONAL.
  - An unmodified 0.8.0-era config validates clean on 0.9.0. Measured through
    the shipped validator on both the flat single-inverter shape the reference
    install runs and an N=2 roster with no `capacity_kwh` on any entry: 0
    problems each.
  - OPT-09's two new refusals fire only on configs that USE the new key - an
    unparseable value, or the key stated on one entry and left empty on another.
    Neither can reach a config written before the key existed.

PKG-07's first draft justified its bump with an invented compatibility break and
review caught it. This one states what was measured, and the measurement says
existing installs upgrade untouched.

Contents since 0.8.0 (`06661c5`): UX-02 (`3d29993`), OPT-09 (`fdb3b69`),
PKG-09 (`fcaa724`).

⚠️ THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS - PKG-06 six
merges, PKG-07 six, this one five - and this time PKG-09's own tool caught it
during PKG-09's review rather than a person. The changelog carries OPT-09's
disclosed N=2 detection regression and points at the pre-deploy check.

⚠️ STILL NOT DONE, AND STILL NOT DOABLE FROM HERE: the live options have not
been checked against this version's validator. The gate reports exit 2 - NOT
CLEARED - rather than clearing, which is correct. Export the options from the
Supervisor and re-run before `ha apps update`.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 00:18:36 +02:00
admin fcaa72418d Merge pull request 'PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject' (#63) from PKG-09 into release/1.0
PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject
2026-09-07 00:04:32 +02:00
glenn schrooyenandClaude Opus 5 04b3330def PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject
checks / unit (pull_request) Successful in 6m16s
Two ways a deploy of this add-on has gone wrong, and the Supervisor reports
both as success.

THE SILENT NO-OP. The Supervisor keys the built image by `version:`, so
`ha apps update` against an unchanged version installs nothing and says it
worked. PKG-06 recorded this on 2026-09-05 with six merges behind an unbumped
version; PKG-07 recorded it again on 2026-09-06 with six. Both times a human
noticed. Nothing else was going to.

THE REFUSED CONFIG. A config the shipped validator rejects is SystemExit(78) -
the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES WITH IT, because the
add-on serving it is not running, so recovery means the Supervisor UI.

⚠️ IT CAUGHT A LIVE ONE ON ITS OWN PULL REQUEST. Run against release/1.0 while
this branch was in review: version 0.8.0 on both sides with UX-02's and
OPT-09's runtime commits already merged behind it. The class this exists to
catch had recurred, the same day, unnoticed - which is the argument for PKG-10,
a non-required post-merge check, since this script only helps someone who
remembers to run it.

⚠️ THE FIRST VERSION WAS BROKEN ON EVERY REAL REPOSITORY AND ITS TESTS COULD NOT
SEE IT. The "when did the version last change" lookup was `git log -S`, the
pickaxe, which finds commits that change the COUNT of a literal string. A bump
edits the line IN PLACE, so the count never moves and it returned only the
commit that first INTRODUCED the line. Measured here:

    -S  ->  0a1e61d  2026-08-23  Packaged as a Home Assistant add-on
    -G  ->  06661c5  2026-09-06  PKG-07: bump 0.7.0 -> 0.8.0

so a correctly bumped tree was reported as 87 COMMITS BEHIND AN UNBUMPED
VERSION, PKG-06's and PKG-07's own bump commits among the offenders. A tool
whose whole purpose is to be trusted cried wolf on every run.

⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER - it matches a reformat,
a quote change, a comment moved onto the line. `-G` narrows the candidates and
the VALUE is then compared against the parent's.

⚠️ THREE DISTINCT SHAPES OF HABIT 4 IN ONE FILE, each found by a different
means, which is the honest record of how this landed:
  1. Fixtures built fresh repositories whose version line is introduced once and
     never edited - the one shape in which `-S` looks correct. Found by review.
     Answered with a three-state history; the old lookup goes red on 4 checks.
  2. That three-state history still could not tell "value-compared" from
     "filtered-only": review swapped in `-G`'s newest raw hit and THE WHOLE
     SUITE STAYED GREEN, so the docstring's central claim was pinned by nothing.
     Answered with a cosmetic touch of the version line - `-G` matches it, the
     value comparison does not. That mutant now goes red on 3 checks.
  3. The counts below were claimed fixed and two of them had not landed at all.

Two git shapes it still gets wrong are recorded in the docstring rather than
fixed - a bump resolved inside a merge commit (false refusal) and a bump
reverted while the work is kept (false clearance, the dangerous direction).
Neither has ever occurred here; both were constructed in review. Card PKG-11.

⚠️ AND THE PROSE COUNTS, WHICH I REPORTED AS FIXED WHEN THEY WERE NOT. Round 1
found "PKG-06 ... four merges" (its own commit message says six) and "recorded
eleven times" (MUTATION.md:550 says TWELFTH). I replaced them without asserting
the replacement matched, said "all applied", and round 2 found both still in the
tree - one fixed in DOCS.md but not in the script, one not fixed anywhere. A
claim that a correction landed is itself a claim that needs checking, and this
is PROC-01's tenth instance.

WHAT IT DOES NOT DO. It does not read the live box: the options blob needs the
Supervisor API. Without --options the validator half SKIPS - loudly, exit 2 -
and never pretends. On a bare host `app/validate.py` needs aiohttp, so that half
skips too and names the container to re-run in. A missing or malformed --options
file is a REFUSAL with a sentence, not a traceback.

EXIT CODES: 0 cleared, 1 REFUSED, 2 NOT CLEARED. ⚠️ 2 IS NOT A PASS.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 23:57:51 +02:00
admin fdb3b69397 Merge pull request 'OPT-09: the weights parameter nothing ever passed, and what it cannot do' (#62) from OPT-09 into release/1.0
OPT-09: the weights parameter nothing ever passed, and what it costs SAFETY-42
2026-09-06 23:33:17 +02:00
glenn schrooyenandClaude Opus 5 cab267c4f3 OPT-09: the 6500 W window is a bound the ratings never let the site reach
checks / unit (pull_request) Successful in 6m10s
Round-2 non-blocking findings, both fixed.

⚠️ THE WINDOW FIGURE WAS ARITHMETIC NOBODY HAD RUN. `min_w * (w_A + w_B) / w_B`
= 6500 W is correct about `_fill()`'s PROPORTIONAL regime and is not a
reachable steady state: the healthy pack's own 5000 W rating binds first.

SWEPT on part 8b's own fixture, reproduced rather than transcribed:

    load   equal   OPT-09   site target   A      B
    1000   199/200   0/200       1080    997     83
    2000   199/200   0/200       2170   2003    167
    3000   199/200   0/200       3250   3000    250
    4000   199/200   4/200       4340   4006    334
    4500   199/200   8/200       4890   4514    376
    4900   199/200  41/200       5320   4911    409   <- largest suppressed
    5000   199/200 195/200       8000   5000   3000   <- A at its rating
    6000   199/200 195/200       8000   5000   3000

So the alarm returns BELOW the formula bound, not above it, and the largest
site target reached with the wedged unit still under the 500 W threshold is
5320 W - not 6500. The equal-weights "suppressed below 1000 W" bound is
unreachable for the same reason: the equal arm names the wedged unit 199/200 at
EVERY load swept, 1000 W included.

The formula and its check are kept, because the mechanism they state is right
and it is the mechanism that generalises to other ratings. What was wrong was
the sentence around them, in four places, implying the site passes through the
bound. Each now carries the sweep and says the rating binds first.

⚠️ THIS NUMBER CAME FROM THE REVIEW, NOT FROM THE BRANCH, and was adopted in
good faith by the implementer. The reviewer found it in round 2 and said so.
That is worth recording: a figure arriving from a trusted reviewer got less
scrutiny than one written by the author, and PROC-01's eight fabricated numbers
in two days is now nine. The rule that catches it is the same either way - run
it before you ship it.

Also: the DECISIONS header's base sha, stale for the third time (464d398 ->
3368231 -> 3d29993, moved by two rebases and caught by review each time). It now
records that history, and the header is why `test_translations.py`'s counts are
computed rather than typed.

SAFETY-43 is now named by id where the follow-up is referenced, so a reader can
follow the thread from the disclosure to its closure.

Suite: 25 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 23:26:22 +02:00
glenn schrooyenandClaude Opus 5 8d83ef0dfb OPT-09: the weights DO move on a wedged unit, and that narrows SAFETY-42
checks / unit (pull_request) Successful in 6m11s
B1, the blocking review finding. §5 of the decision said:

    "A wedged unit reads a healthy SoC, so no envelope closes and no weight
     moves - OPT-09 cannot see it at all."

The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight is a function of a pack's ENERGY HEADROOM, not
of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's - the ordinary case this card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.

So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.

1. THE CORRECTION, in the three places that carried the claim

   * DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
     accurately, quotes the sentence it is replacing, carries the measured
     rows and the window arithmetic, and names the residual in the same voice
     as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
     exist". Also drops "they meet and do not overlap" from the opening.
   * app/packshare.py - a new section in the module docstring, immediately
     after "weights are a comfort choice for stability and not for delivery",
     which is where the over-read starts.
   * CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
     operator-facing sentence: a wedged inverter with less energy headroom
     than its sibling can now go unreported at ordinary house loads.

   test_packshare.py part 8's closing check and its "what this file cannot
   see" list repeated the same false clause; both now say the ENVELOPE cannot
   reach a wedged unit and point at part 8b for what the WEIGHTS do to it.

2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks

   The reviewer's probe, reproduced: real compute(), real plan(), real
   packshare.weights(), real delivery.account(), real delivery.delinquent();
   the only model is the plant (A obeys within one cycle inside its rating,
   B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
   its own 10 % floor, so envelope() does not close and account() explains
   nothing - a SAFETY-42 true positive by that card's own definition, which
   a PRECONDITION check asserts before anything is concluded from it.

       load 1500 W, B wedged at 15 %, equal weights  named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 1500 W, B wedged at 15 %, OPT-09 weights named on   0/200 cycles,
         site target +1630 W, shares a+1505, b+125,  grid -5 W, named=(nobody)
       load 6000 W, B wedged at 15 %, equal weights  named on 199/200 cycles
       load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles

       suppression window: equal weights, site target < 1000 W; OPT-09
       weights, site target < 6500 W of a 9600 W site

   THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
   the weighted arm names NOBODY where the equal arm names the wedged unit.
   A check asserting the alarm still fires would fail, and papering over it
   would be worse than the original defect. The 6000 W row is there so the
   part is not pinning "the weighted arm never names anybody": past the
   healthy pack's own rating, _fill()'s last-resort pass hands the wedged
   unit 3000 W and both arms name it. The window is real and it has an edge.

   MUTATION - packshare.weights() keeps its shape and forces every weight to
   1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
   with python3 -B, not reasoned about:

       load 1500 W, B wedged at 15 %, equal weights  named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
         site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
       suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W

     FAIL  AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
     FAIL  ...and the arithmetic behind it, stated as a window [...]
     FAIL  AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]

   test_packshare.py exits 1. Restored: exits 0.

3. THREE PROC-01 PROSE DEFECTS

   * DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
     464d398". The branch was rebased; git merge-base HEAD 3368231 is
     3368231. Corrected, and it says why the old number was there.
   * Same file:13 cited main.py:2272 for "resolution = resolve(...)".
     git show 3368231:goodwe_controller/app/main.py | grep -n says 2422.
     Corrected. The sibling citation arbiter.py:535 is right and is left.
   * DOCS.md:174 said "Since 0.7.x the split is driven by two separate
     things", which tells an operator the fix already shipped. config.yaml
     is 0.8.0 and the live install is 0.7.0, where the split is 50/50 - this
     card's own premise. Now "from 0.8.0 onwards", with an explicit warning
     that 0.7.0 and earlier are a fixed 50/50.

NOT TOUCHED, deliberately: delinquent()'s absolute threshold (SAFETY-42's
code, its own card), and app/main.py:2699's double-logged scope refusal (N4,
filed separately).

INVERTER B DOES NOT EXIST (expected ~2026-09-12). Every two-unit figure above
is TWO COPIES OF A through a plant modelled in test_packshare.py; B's 4600 W /
20.48 kWh / 10 % floor are from the datasheet and the serial. Nothing here was
measured on hardware, and the added disclaimers say so in each place.

Verified with the real harness per .gitea/workflows/checks.yml
(goodwe-addon-checks:1): COLLECTED 24 files, ALLGREEN. Harness positive
control run first - test_validate.py deliberately broken, harness reported
"FAILED: test_validate.py" and exited 1; restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00
glenn schrooyenandClaude Opus 5 f8e501393f OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:

  * a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
    `bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
    pack is not asked to discharge and its share goes to the sibling;
  * SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
    `_fill()`'s last-resort pass will override the moment the sibling saturates.

Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.

Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.

SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.

Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.

⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00
admin 3d2999337d Merge pull request 'UX-02: the validator's refusals, next to the field, before the save' (#61) from UX-02 into release/1.0
UX-02: the validator refusals, next to the field, before the save
2026-09-06 22:45:49 +02:00
glenn schrooyenandClaude Opus 5 bcee2356e0 UX-02: pin both halves of the max_w fix, because neither was graded
checks / unit (pull_request) Successful in 6m16s
Review of the fix commit found that BOTH halves reverted with the whole 24-file
suite still green - including the half that turns a crash into a SILENT
ACCEPTANCE, which was my own wrong first attempt at this fix. A fix nothing
grades is a fix that comes back. MUTATION.md habit 4.

⚠️ AND THE OBVIOUS PIN HAD THE SAME DEFECT IT WAS WRITTEN TO CATCH. "problems_
and_ranges does not raise" is ALSO satisfied by the backstop swallowing the
exception and returning [] - the silent acceptance half two exists to prevent.
MEASURED: with both halves reverted, every max_w check reported `ok` and only
the forced-boom pins went red. So half one is now pinned on `build_roster`
ITSELF, where the backstop cannot answer for it. That is the same defect as the
one being fixed - a check whose data cannot reach the failure it names - written
into the check for it, and it is the twelfth recorded instance.

New section 2b of test_selfconfig.py:
  - `_flat_max_w` agrees with `_number()` across ten shapes, so composition
    tolerates exactly what rule 9 tolerates.
  - ⭐ `build_roster(max_w="")` does not raise - half one, standing alone.
  - a cleared box answers with the validator's own verdict; an unparseable one
    answers "max_w 'abc' is not a number".
  - ⭐ clearing max_w on the LIVE shape (5000 W rail, explicit slew_w 400) is
    REFUSED by rule 10 "against a site rail of 2000 W". `unit_caps_indexed`
    reads the flat rail through the same `_number`, so the rail collapses in
    lockstep and rule 10 catches the collapse. This is what makes the fix safe
    rather than merely non-crashing: the box cannot be emptied into a quietly
    halved rail.
  - ⭐ the backstop never returns an empty list, forced by monkeypatching
    build_roster to raise; and the patch is shown undone.

DRIVEN, all four arms through the real harness:
  clean            rc=0  fails=0
  revert half one  rc=1  fails=2     (_flat_max_w -> bare float())
  revert half two  rc=1  fails=2     (backstop returns [] again)
  revert BOTH      rc=1  fails=3
Each half is caught on its own, which is the property the earlier draft lacked.

Suite: 24 files, all pass.

Not addressed here, filed instead: N3 (`/config/` 404s), N4 (undeclared keys on
a hand-crafted POST, which the page cannot produce and Supervisor drops), N5
(saving pins every default as an explicit option), the page reporting "would
start" for a blob Supervisor's own schema refuses, and 2000.0 hardcoded in three
places that agree today with nothing pinning them together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:38:54 +02:00
glenn schrooyenandClaude Opus 5 2bb07ccdde UX-02: the panel was weaker than the startup gate for one field
checks / unit (pull_request) Successful in 6m9s
Applying review finding N1, which was non-blocking because it fails closed, and
N2, which is a fabricated number.

N1. Clearing the `max_w` box answered HTTP 500 on a non-JSON body, which the
page rendered as "cannot reach the add-on" - untrue, and for the most prominent
limit field's most likely transient state. The cause was a disagreement between
two files that `inverter.py`'s own docstring says to keep in step:

  validate.py `_number()`  reads "" as "use the default", so rule 9 saw 2000
                           and was content
  inverter.py line 515     did a bare `float(opts.get("max_w", 2000))`, which
                           raises ValueError on ""

So a shape rule 9 TOLERATED could not be composed at all - the docstring's rule
inverted. The roster path never had this: `_entry_max_w` already guards. Only
the flat single-inverter path did. `_flat_max_w()` now reads empty as the
default exactly as `_number()` does, and unparseable as 0.0 exactly as
`_entry_max_w` does, where rule 9 refuses it by name at line 790.

⚠️ THE FIRST ATTEMPT AT THIS MADE IT WORSE, AND THE PROBE CAUGHT IT. Guarding
`build_roster` in `selfconfig` with `except: return validate(opts), []` turned
the 500 into ZERO PROBLEMS for `max_w: ""` - the validator has nothing to say
about it, so an empty list came back and the page would have ENABLED SAVE. A
crash became a silent acceptance. The guard is kept as a backstop for shapes
neither file anticipates, but it can no longer return an empty list: if the
roster cannot be composed and the validator names no reason, that disagreement
is itself reported.

MEASURED after the fix, `problems_and_ranges` with no HA:
  max_w 5000, slew_w 400   0 problems      (the live shape)
  max_w "",   slew_w 400   1 problem       rule 10, "against a site rail of
                                           2000 W" - clearing the box FAILS
                                           CLOSED on the live install rather
                                           than silently halving the rail
  max_w "",   slew_w 0     0 problems      accepted at the 2000 default
  max_w "abc"              1 problem       "max_w 'abc' is not a number"
Before the fix, both "" and "abc" raised ValueError instead.

N2. `translations/en.yaml:7` said "all 71 keys ... so ALL SEVENTY are rendered".
The 70->71 correction had been made in one half of the sentence and not the
other. 59-of-71 required is correct and was re-measured.

Suite: 24 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:17:37 +02:00
glenn schrooyenandClaude Opus 5 fdaf3fb54f UX-02: the validator's refusals, next to the field, before the save
checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.

WHAT IT ADDS
  app/configui.py     the field model, built from `GET /addons/self/info`'s own
                      `schema` + `translations`. No option key, no label, no
                      rule of its own. UX-01's `Group · ` label prefix becomes a
                      real section heading, in whichever language resolved.
  app/selfconfig.py   the Supervisor conversation and the write gate.
  app/config_page.py  the page. Its longest function decides which HTML input a
                      type deserves; it contains no rule.
  app/web.py          five routes. Still the only file a reviewer has to read to
                      answer "does anything here reach the inverter".

THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
  1. A blob the SHIPPED validator refuses is never written - not warned about,
     not written. An invalid blob plus a restart is exit 78, and the panel is
     gone with the add-on that serves it.
  2. A blob that moved underneath is refused, not clobbered. Read hands out a
     sha256 of the canonical blob, save hands it back, and the stored blob is
     re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
     applied to configuration - "a blob reaches the Supervisor only if it was
     derived from the blob stored right now" - with the honest weakening stated
     in the decision record: a second writer is DETECTED, not made
     inexpressible.
  3. Every message is the validator's own, byte for byte. test_selfconfig.py
     part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
     under app/ to show validate.py is the only one that differs, and serves the
     real /config/validate route out of that copy in a subprocess. The new
     sentence arrives, filed under the key it names.
  4. Nothing on the surface commands an inverter or clears a latch. Saving does
     not restart: applying costs a control interruption - the wind-down commands
     0 W on every inverter - so that is a separate, confirmed button that says
     so first.

MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
  - the three self-paths are permitted, and `/addons/self/info` is admitted by
    the `default` role too while `options` and `restart` rest on the bypass
    ALONE. The probe's first draft claimed the role admitted none of the three
    and went red.
  - `/addons/self/options/validate` is NOT reachable from inside an add-on, so
    the only validator this panel can call is ours - which is the one with the
    cross-field rules.
  - a POST that omits a key does not preserve it: the key reverts to
    config.yaml's default. Hence whole-blob read-modify-write.
  - the Configuration tab CAN be removed, and doing so drops 0-of-71 options
    through and takes the add-on down. The card said it could not be; it stays
    by decision, which is a better reason.

DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.

Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

---

⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.

So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 21:38:31 +02:00
25 changed files with 5101 additions and 104 deletions
+8 -1
View File
@@ -14,13 +14,20 @@ goodwe_controller/ the add-on
hass.py Supervisor/Core API access
store.py persistent state in /data
mqtt.py optional status entities
web.py ingress UI
web.py ingress UI — routes only, status and configuration
config_page.py the configuration page's markup, style and script
configui.py its field model, built from the Supervisor's own
schema + translations — owns no key, label or rule
selfconfig.py reading and writing this add-on's own options,
with the validate-first / compare-and-swap gate
main.py orchestration, heartbeat, failsafe behaviour
test_control.py runnable checks — no framework needed
test_maintenance.py runnable checks — walks a full cycle in fake time
DOCS.md the add-on's documentation tab
MUTATION.md how to run a mutation so the result means something
tools/mutation_run.sh the mutation harness - purges bytecode, then grades
tools/supervisor_selfapi_probe.py what the Supervisor really permits and
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
+219 -1
View File
@@ -1,5 +1,219 @@
# Changelog
## 0.9.0
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
POINT.** 0.8.0 was minor because a config that started on 0.7.0 could be
REFUSED on it (rule 10 against 0.7.0's own default `slew_w: 1000`). Nothing of
that kind is true here, and it was checked rather than assumed:
- `options:` and `schema:` both hold **71 top-level keys**, the same 71 as
0.8.0. The one schema addition is a per-entry key, `capacity_kwh:
float(0.1,200)?` - **note the trailing `?`, it is OPTIONAL**.
- An unmodified 0.8.0-era config validates clean on 0.9.0. Measured on both the
flat single-inverter shape the reference install runs and an N=2 roster with
no `capacity_kwh` on any entry: **0 problems each**.
- The two refusals [[OPT-09]] adds fire only on configs that USE the new key -
an unparseable value, or the key stated on one roster entry and left empty on
another. Neither can reach a config written before the key existed.
So: existing installs upgrade untouched, and the bump is minor because the
option surface and the behaviour grew.
Contents, in merge order since 0.8.0 (`06661c5`):
| | |
|---|---|
| `3d29993` | UX-02 - the validator's refusals, next to the field, before the save |
| `fdb3b69` | OPT-09 - the weights parameter nothing ever passed, and what it cannot do |
| `fcaa724` | PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject |
**UX-02** builds the real configuration surface in the ingress panel, beside the
Supervisor Configuration tab that cannot be removed. The prize is not styling:
`validate.py` writes long cross-field refusals the Supervisor UI *structurally
cannot show*, and until now an operator met them as **exit 78 in a log**, after
saving and restarting. ⚠️ Two config surfaces now exist and the DECISIONS record
names the **stored options blob** as authoritative, neither UI.
**OPT-09** makes the architecture's one free parameter real. `main.py` called
`resolve()` without weights, so the split was a fixed 50/50 regardless of what
either pack could deliver; it now passes SoC- and capacity-aware weights.
⚠️ **OPT-09 SHIPS A DISCLOSED DETECTION REGRESSION AT N=2.** `delivery.
delinquent()` reads an ABSOLUTE 500 W gap, so weighting a low-headroom unit down
to a small share puts a unit delivering **none of what it was asked** under the
threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
UNAFFECTED**: `allocator.plan()` returns before weights are read at all. The
closure is card SAFETY-43.
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
behind it, which is why this section exists:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.8.0 --options /w/live-options.json
Exit `0` cleared, `1` REFUSED, `2` NOT CLEARED. ⚠️ **2 is not a pass.**
✅ **RUN AGAINST THIS RELEASE, 2026-09-07: CLEARED, exit 0.** The reference
install's options were exported from the Supervisor (70 keys) and checked twice
- the stored blob as-is, and the **effective post-upgrade config**, being those
70 keys merged over 0.9.0's 71 defaults. Both pass. `max_w` 5000, `slew_w` 400
against a rule-10 ceiling of 555.6 W.
⚠️ **THE STORED BLOB HAS 70 KEYS, NOT 71, AND THAT IS THE INTERESTING PART.**
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running 0.7.0
has never stored it - and it is REQUIRED in the schema, no trailing `?`, which
is the shape that becomes exit 78. It resolves to its `options:` default of
**185**, because the Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`; `tools/supervisor_selfapi_probe.py` §D pins that
against a real Supervisor image rather than assuming it. The second check above
exists because "your saved settings are valid" and "what the box will actually
run is valid" are different questions, and only the second one matters.
⚠️ **THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS** - PKG-06 six
merges, PKG-07 six, this one five - and this time it was caught by PKG-09's own
tool during PKG-09's review rather than by a person. `ha apps update` against an
unchanged version installs nothing and reports success. PKG-10 carries the
post-merge alarm so that noticing stops being a human's job.
## Unreleased
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
so `ha apps update` is a silent no-op on an unbumped change. This branch
deliberately does **not** pick the number, because the right one depends on a
fact the branch cannot observe - whether 0.8.0's image has already been built
and pulled. If it has not, this belongs inside 0.8.0's section before it ships;
if it has, it needs 0.8.1. Whoever deploys decides, and PKG-07 is the record of
what happens when nobody does.
| | |
|---|---|
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
continuous legitimate episode at the shipped threshold across all 2640 runs"* -
from a harness `DECISIONS/SAFETY-42-unit-delivery.md` §1 deliberately did **not
commit**, because it imports `plantsim` and `frame_check` out of `admin/goodwe`.
A shipped safety constant the next reviewer cannot re-run is PROC-01's shape.
The harness is committed now, **in `admin/goodwe` as `sim/delivery_window.py`**,
beside `sitesim.py`. It is there and not here on purpose: those two modules are
that repo's, and restating them in this tree is the CI-08 coupling that has
broken three times. The dependency runs one way instead - this repo's `control`,
`arbiter` and the shipped `delivery.delinquent()` are imported live out of
whichever checkout is under test. Re-run it with:
```
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --blag
```
⚠️ **AND 30.0 s DID NOT REPRODUCE - IT IS 35.0 s.** Reported as measured rather
than adjusted to agree. What DID reproduce is the part the compatibility promise
rests on: the **N=1 control arm is 0.0 s at every threshold in every run**, and
the **flapping family is 0.0 s everywhere** in all 840 runs. What did not: the
headline (one control cycle worse), the reversal family (20.0 → 30.0 s), the
non-monotone curve (this rebuild's is monotone, so that finding is now an
argument and not a measurement), and the low-threshold rows, badly. The B-lag
rows moved the other way - a hypothetical unit four times slower than A gives
**75.0 s**, not 90.0 s - and they are relabelled throughout as a **sensitivity
study on a hypothesis about inverter B, which does not physically exist yet**,
rather than as a measurement of it.
**`delivery_hold_s` is NOT changed.** 185 s is 5.29× the re-measured worst
(was claimed 6.2×), 2.47× the four-times-slower case, 6.2× the firmware's
`LINK_STALE_S` veto and 4.9× inside the billing quarter: all four bounds still
clear and none narrowly. ⚠️ One consequence is worth an operator's attention:
at 35.0 s the rig's floor is now **above** the schema's own `int(30,900)`
minimum, so `delivery_hold_s: 30` is below a measured legitimate episode and
would report on ordinary saturation. The option is marked *derived, do not
change*; raising the schema floor is left to its own card.
⚠️ **UX-02 ADDS A SECOND PLACE TO CONFIGURE THIS ADD-ON, AND NO NEW WAY TO
COMMAND THE INVERTER.** The ingress panel gains a Configuration page beside
Home Assistant's Configuration tab. Both edit the same options blob; neither is
authoritative, the stored blob is. What the new one can do that the old one
structurally cannot is show `validate.py`'s CROSS-FIELD refusals - two inverters
naming one `setpoint_entity`, a `maintenance_charge_w` above `max_w`, a flat
`setpoint_entity` left set beside a roster - **next to the field, before saving**,
instead of as exit 78 in a log afterwards. The Supervisor's tab knows only types
and ranges and has no way to express a rule spanning two keys.
Four properties are the ones that could hurt somebody, and each is checked
rather than asserted (`test_selfconfig.py`):
* a blob the shipped validator refuses is **never written** - because an invalid
blob plus a restart is exit 78, and the panel goes down with the add-on;
* a blob that **changed underneath** the page is refused, not clobbered, and the
keys that moved are named. Two surfaces, one blob;
* **every message comes from the shipped validator.** Proven by inserting one
new rule into a COPY of `validate.py` and watching it arrive on the page with
every UI file byte-identical;
* **nothing on the surface commands an inverter or clears a latch.** Saving does
not even restart: applying options costs a control interruption - the
wind-down commands 0 W on every inverter - so that is a separate button that
says so first.
⚠️ **NO SCHEMA CHANGE AND NO CONTROL CHANGE.** `config.yaml` is not modified at
all by this card: the permissions the panel needs - `ingress: true`,
`ingress_port: 8099`, `hassio_api: true`, `homeassistant_api: true` - were
already declared, and the panel was already live.
`tools/supervisor_selfapi_probe.py` re-measures them against Supervisor
2026.08.0's own code, and found things the card had wrong: Supervisor's own
`/addons/self/options/validate` is **not** reachable from inside an add-on, and
the Configuration tab **can** be removed - at the cost of all 71 options, which
is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both.
⚠️ **UX-04 CHANGES NO BEHAVIOUR, NO SCHEMA AND NO DEFAULT.** It adds one check
file, three assertions to a probe, and a decisions record; `config.yaml`,
`translations/` and every module under `app/` are byte-identical to `6e7b233`.
It closes out [[UX-01]], which shipped in **0.7.0 and is live**, with three
criteria that were never met:
* **AC 3 - "the visible count drops to ~15" - is WITHDRAWN as unsatisfiable.**
`options:` and `schema:` declare the same 71 keys, so the set HA's *show
unused optional configuration options* toggle can hide is **empty**, and
Supervisor's own `UiOptions` marks **59 of the 71 required** - the frontend
shows those unconditionally, so 59 is the floor even with `options:` emptied
altogether. The target was ~4x below what any manifest can reach. ⚠️ And the
12 hides that *are* expressible would be bought with the deep-merge: a key
absent from `options:` has no default for the Supervisor to merge into a
0.7.0 box's stored blob, which is exactly the `delivery_hold_s` mechanism
that makes this release's upgrade safe rather than exit 78. The count was
**printed** by `tools/supervisor_shape_probe.py` §F from the day it was
written and never graded, which is how this passed review; it is now three
assertions, and removing one key from `options:` turns two of them red.
* **AC 4's third clause - the AST check for stray in-code literal fallbacks -
is BUILT**, as `test_defaults_pin.py`. It discovers the six
`*_OPTION_DEFAULTS` mirrors from the tree rather than naming them, grades all
30 mirrored keys against `config.yaml`, and freezes the CFG-03 backlog at
exactly today's **27 (module, key) pairs** so it can only shrink. It found
one previously unreported divergence: `main.py` answers a missing
`target_grid_w` with **0** where `config.yaml` ships **-10** - unreachable on
a Supervisor install, recorded rather than corrected. Its red direction runs
on every invocation, and a literal planted in the real tree was shown red.
* **AC 5 / the CFG-04 double-literal `max_w` hole is closed, by
[[UX-02]]'s `bcee235` and not by UX-01's `fef6ef2`.** Re-measured on this
branch: the `2000 -> 2500` triple mutation is **RED at `test_selfconfig.py`**,
where it survived 21/21 green on 0.7.0. The attribution is corrected in
`DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md`; nothing was
re-done.
The 8 per-entry `inverters:` translations **stay**. They are shipped, validated
and measured *not* to reach the Supervisor tab - `ha-selector-object` labels
those fields `fields[name].label || name` and the converter sets no `label` -
and UX-02's ingress panel is the surface that can render them. A written
decision, not a change.
## 0.8.0
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
@@ -239,7 +453,11 @@ own rating, the allocator's last-resort pass hands the wedged unit a big share
again and the alarm returns. So in the quiet regime - where `unmet_w` is 0, the
meter is satisfied and no action-log line fires - **a wedged inverter with less
energy headroom than its sibling can now go unreported at ordinary house
loads.** `test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so
loads.**
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
`test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so
the day it changes a named check moves. Closing it means making `delinquent()`
relative to the ask rather than absolute, which is SAFETY-42's code and a design
change; that is filed as its own card and is deliberately not done here.
@@ -1,6 +1,6 @@
# OPT-09 - the weights parameter, and what it is honestly for
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3368231`
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3d29993` (rebased onto the UX-02 merge; it read `3368231`, and before that `464d398` - each rebase moved it and review caught it each time, which is why the counts in `test_translations.py` are now computed rather than typed)
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
the pre-rebase base, `464d398`).
@@ -198,8 +198,12 @@ named**. The suppression window widens from `site target < 2 * min_w` = 1000 W t
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
a 9600 W site; above it - once the healthy pack is at its own rating and
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
returns. `test_packshare.py` part 8b grades all of it and **pins the current,
unflattering behaviour**, so that the day it changes a named check moves.
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
`test_packshare.py` part 8b grades all of it and **pins the current,
unflattering behaviour**, so that the day it changes a named check moves. The
closure - `delinquent()` measured against the ask rather than a flat 500 W - is
card **SAFETY-43**.
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit.** The two cards do not merely meet from
@@ -68,22 +68,92 @@ deliberately *not* in the model). **Every two-inverter figure in this document
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
margin that costs.
### Reproducing it
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
days. The paragraph this replaces was true — what the harness does *is* small
enough to restate — and it was not enough: a restatement in prose leaves every
axis it does not name to the next person's judgement, and §3.3 below now records
how far that moves the number. **It moved. See §3.3's second table.**
It is committed in the **other** repository, `admin/goodwe`, as
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
harness it is built on. From clean checkouts of both repos:
```
cd goodwe/sim
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
```
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
that repo** and neither present in this one's CI image. Committed here it had
two possible fates and both are the coupling CI-08 has been broken by three
times: skipped for want of its imports — a green that establishes nothing — or
the plant and the encoder **restated in this tree**, which is a second copy of
something the other repo owns, free to drift, and drift in a fixture is
invisible until the number it defends is wrong. There the dependency runs one
way and stays one-directional: **this** repo's `control`, `arbiter` and
`delivery` are imported live out of whichever checkout is under test
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
number on the next run instead of silently disagreeing with a fixture. That is
the seam `sitesim.py` already stands on, which is the other reason the campaign
is its neighbour and not a third arrangement.
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
checks out this repo for `two_writers` and accepts that boundary in writing. So
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
shape of `delinquent()` turns that step red rather than leaving a stale number
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
the **rig** runner, which a container-free simulation has no business holding.
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
that is the block above.
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
everywhere is indistinguishable from a campaign whose detector was never wired
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
drives a genuinely **wedged** unit through the same loop and refuses to proceed
unless the shipped `delinquent()` names it for longer than the shipped hold
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
that arm passes.
What it does, unchanged from the restatement it replaces: build two
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`; close the loop with the
add-on's own `compute()` on the SITE sum (`Controller.site_batt_w`) and
`arbiter.allocate_record()` over per-unit envelopes of `±min(max_w, rating)`;
put each share on its own wire through `Inverter.command()`, honouring a
configurable **write stagger** so B's number can land up to four cycles after
A's (the serialised write path); advance `Site.settle()` one control cycle; and
record, per unit, the command standing *before* the write and that unit's 0x0518
power. Then grade each cycle with the **shipped** `delivery.delinquent()` and
take the **longest run of consecutive cycles** on which it names anybody — not a
count of naming samples, because the window is cleared by any cycle that does
not.
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
command standing before the write" is the **controller's** standing command —
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
wire. A write that has been issued but has not landed yet is standing as far as
this add-on is concerned, which is the whole of the write-stagger axis. Grading
against the plant's latch instead reads a number no add-on has, and it makes
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
shows a standing command of 0 W, is never `asked`, and is never named. That is
not a conservative harness, it is a harness that cannot fail — and the rebuild
did exactly this on its first run, which is how the falsifiability arm earned
its place above.
The harness is **not committed here**, for the reason SAFETY-30's and
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
other repository and are not present in this one's CI image. What it does is
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
envelopes of `±min(max_w, rating)`; put each share on its own wire through
`Inverter.command()`, honouring a configurable **write stagger** so B's number
can land up to four cycles after A's (the serialised write path); advance
`Site.settle()` one control cycle; and record, per unit, the command standing
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
cycles** on which it names anybody — not a count of naming samples, because the
window is cleared by any cycle that does not.
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
trees. Run it with `python3 -B` and purge.
@@ -213,7 +283,9 @@ Every run is executed a second time at a **roster of one** on the same stimulus.
check"* from *"a property of the second inverter"*.
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
threshold:
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
committed — SUPERSEDED, and kept here because a record that quietly replaced its
own numbers would be worth less than one that shows both:**
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|---:|---:|---:|---:|---:|
@@ -225,7 +297,100 @@ threshold:
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
Three findings worth stating rather than leaving in the table:
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
counts, same thresholds, the shipped `delinquent()`:
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|---:|---:|---:|---:|---:|
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
argmax is printed with the table on purpose (see below): a worst case sitting on
an edge of the grid would be a number about the grid.
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
argmax's other axes held fixed:**
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
0.10 -> 35.0 s
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
6) and `slew_w` (3rd of 5).
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
it. A site whose two writes land in the same cycle does not produce this episode
at all.
This does not move `delivery_hold_s`: 185 s must cover the worst case the
modelled site can produce, and a stagger of four cycles is inside what a real
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
the plant or about the grid?" deserves the answer, and the answer is: **partly
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
argmax was interior, and it is not.
**WHAT REPRODUCED, EXACTLY:**
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
§3.2's algebra measured, and it is the claim the compatibility promise rests
on. (This rebuild ran the control arm over all three families — 2640 runs —
where the original ran 1890, i.e. the two families whose stimulus is not
weighted. The weighted stimulus at a roster of one is the same stimulus with
inert weights, and it too reports nothing at any threshold.)
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
proper-subset rule really does defeat the family that defeated SAFETY-30's
site window.
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
**WHAT DID NOT:**
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
describes is real and is still the reason not to read either table as a
sensitivity curve — lowering the threshold really can turn a proper subset
into the whole roster and break the run — but **this grid does not exhibit
it**, so the claim now rests on the mechanism alone and no longer on a
measurement. That is a weaker claim than the one this record made, and it is
written down as weaker.
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
of how much of the original table was a property of axes nobody wrote down.
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
**not** fix the individual values, the run length, or the per-unit cap. The
rebuild spread them over the admissible ranges and **prints every one of them
above its own table**, so the caveat travels with the number instead of living
in somebody's memory. The residual disagreement — one to two control cycles at
the shipped threshold — is inside the sensitivity to that choice; the
low-threshold rows are not, and are reported as not.
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
correction made during the rebuild was made *against* the doc's number, not
towards it, and it is recorded in §1: grading on the plant's latch rather than
the controller's standing command produced a harness that could not name even a
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
Findings 1-3 below are the original campaign's, kept with finding 3 now
qualified by the re-run above:
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
threshold.** That is the compatibility promise measured, not argued.
@@ -268,37 +433,77 @@ would be a preference, and all the margin this check needs is in the window.
Four bounds, and the value clears all four.
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
reversals, 0.0 s on flapping).
* **lower, from the rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
said 30.0 s and it did not reproduce**). The worst continuous legitimate
episode at the shipped threshold across all 2640 runs — weighted family, worst
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
At 30.0 s it coincided with the firmware's veto below and a reader could not
tell which of the two was doing the work. At 35.0 s the rig's number is on its
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
low**, and the schema's `int(30,900)` floor no longer sits above the worst
legitimate episode. An operator who sets it to the schema minimum of 30 s is
now **below** a measured legitimate episode and will get false reports; the
option is marked *derived, do not change* and this is what that marking is
for. Raising the schema floor to 35 is a change to a shipped option's
contract and is left to its own card rather than smuggled in here.
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
executing its share for less than the firmware's own veto window is a *link*
fault the add-on already answers by dropping it from the split. Reporting
inside that window would be a second, louder alarm about a state already
being handled — the SAFETY-15/SAFETY-19 failure exactly.
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
*different* second unit through the weighted family (750 runs each):
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
False`, so the 35.0 s above is **two identical machines** and every row below
is a *what-if*: what a slower second unit would cost this window. **No row
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
margin — which term the window has to be sized against — and that is what it
is used for. Sweeping a *different* second unit through the weighted family
(750 runs each). Second column is the original record, third is TEST-21's
re-run with the committed harness (`--blag`):
| B lag | B ramp | worst legitimate | runs naming |
|---:|---:|---:|---:|
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|---:|---:|---:|---:|---:|
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/750 |
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
The shortfall lasts as long as the unit's first-order lag holds it more than
`saturation_w` below its share; at these step sizes the ramp is never the
binding term. So the number to carry margin against is B's lag, and a unit
**four times slower than A** produces 90.0 s.
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
the original finding and it survives — with one correction: *"the RAMP does
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
rather than no term. The shortfall lasts as long as the unit's first-order lag
holds it more than `saturation_w` below its share; at these step sizes the
ramp is rarely the binding one. So the number to carry margin against is still
B's lag — and a hypothetical unit **four times slower than A** produces
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
row moved **in the margin's favour**, while the floor above moved against it.
Neither was adjusted to suit the other.
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
reader must not be able to think this was carried across — and not 150 or 200.
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ Deliberately **not 180** —
that is SAFETY-02's site hold, and a reader must not be able to think this was
carried across — and not 150 or 200.
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
four bounds still clear, none of them narrowly, and the *ordering* of the
argument is unchanged: the rig's floor is the binding one, the quarter is the
ceiling, and 185 sits between them with margin at both ends. So the value is
**not changed** — a shipped safety constant is not moved because its margin
went from 6.2× to 5.3× while every bound still holds, and moving it would
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
s, because neither is what the harness produces.
**What it costs, stated rather than hidden:** up to 185 s of a site running
short by one unit's share before anybody is told. Against a fault that ran
@@ -0,0 +1,207 @@
# UX-02 — two configuration surfaces, one blob, and which one is authoritative
**Status: DECIDED 2026-09-06 — the STORED OPTIONS BLOB is authoritative and
neither user interface is.** The ingress panel is the surface an operator
should normally use, because it is the only one that can show the add-on's
cross-field refusals *before* a save. The Supervisor's Configuration tab stays,
unchanged, and is the **recovery** surface: it is the only one of the two that
still exists when the add-on is not running.
Filed with UX-02. Card 6a9afc52d4e3662463274e9d. Depends on [[UX-01]], which is
not superseded by this — see §4.
---
## 1. The question
The owner asked, 2026-09-04: *"can't we use some kind of webUI in HA to have a
cleaner config with custom styling?"* Yes — `config.yaml` already declares
`ingress: true`, `ingress_port: 8099` and `hassio_api: true`, and the panel is
live. But building a second editor for the same settings creates a question the
first one never had: **when two surfaces can write one blob, which one is
right?**
Getting that wrong is not a cosmetic problem. `POST /addons/self/options`
*replaces* the stored user options, so a page that writes a blob it read five
minutes ago silently reverts whatever the other surface did in between.
## 2. What was measured, not assumed
All of it out of `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` —
the version this site runs — by `tools/supervisor_selfapi_probe.py`, which runs
Supervisor's own code rather than re-reading it. It touches nothing: no
Supervisor is contacted, no options are posted and nothing is restarted.
**(a) An add-on may read and write its own options, and needs no role.**
`_V1_PATTERNS.api_bypass` was run against the exact paths. `/addons/self/info`,
`/addons/self/options` and `/addons/self/restart` all match; `security` and
`update` do not. `token_validation()` tests that bypass *before* it consults
`access_hassio_api` or any role, so no `hassio_role` is involved.
Two results the card did not predict, and both changed the design:
* `/addons/self/options/validate` — Supervisor's **own** options validator — is
**not** reachable: it is two path segments after `self`, so `[^/]+` cannot
match it, and it is not one of the listed literals. There is exactly one
validator an add-on can call from inside itself, and it is `app/validate.py`.
Which is the one we want: Supervisor's knows only types and ranges.
* `/addons/self/info` is admitted by the `default` role *as well as* by the
bypass (`/.+/info`), while `/addons/self/options` and `/addons/self/restart`
are admitted by the **bypass only**. The two *writing* paths rest on that one
list. The first draft of the probe asserted that the role admitted none of
the three, and it went red — which is why the split is written down here.
**(b) `POST …/options` replaces; an omitted key reverts to the config.yaml
default.** `App.options` is `deepmerge(config defaults, persisted user
options)`, and the setter is `persist[options] = deepcopy(value)`. Run on real
objects: with `max_w: 4200` stored, a POST that simply omits `max_w` leaves the
effective value at **2000**, config.yaml's default — not at 4200.
That is why the panel posts the **whole** blob it read, and why it refuses to
post one built on a stale read.
**(c) Validation does not coerce.** The full merged blob goes through
`AppOptions` unchanged, key for key. That is what makes the panel's *"stored
but not running"* comparison honest: a difference shown is a difference that
exists.
**(d) The Configuration tab *can* be removed — and doing so destroys every
setting on the site.** The card said it cannot be. It can: `schema:` is
optional, a manifest without it is accepted, and `schema_ui` is then `[]`, so
the tab renders nothing. What goes with it is the add-on. `AppOptions` drops
every option the schema does not declare — **0 of 71 survive** — so
`/data/options.json` becomes `{}`, the controller starts with no actuator, the
startup gate refuses it (exit 78), and the ingress panel goes down with the
process serving it.
So the tab stays **by decision, not by constraint**. That distinction matters:
a constraint nobody re-examines is not a decision, and this one has a cost
(§4) that has to be paid knowingly.
## 3. The decision
> **The authoritative configuration is the options blob the Supervisor stores.
> Both surfaces are editors of it. Neither is a cache of the other, and neither
> owns a copy of anything.**
Consequences, in the order they bind:
1. **The panel never holds state.** Every page load re-reads
`GET /addons/self/info`. There is no local copy of the options, the schema
or the labels — the form is *built* from that response by
`app/configui.py`, which contains no option key, no label and no rule.
2. **A save is a compare-and-swap.** The read hands the page a
`fingerprint()` (a sha256 of the canonical blob); the save hands it back;
`selfconfig.save()` re-reads the stored blob **immediately before writing**
and refuses if the digest moved. The window in which the other surface can
slip a change past is one HTTP round trip, not the length of an editing
session.
3. **Validation happens before the compare, and a refused blob is never
written.** Not warned about — not written.
4. **Saving never restarts.** Applying options costs a control interruption
(§5), so it is a separate, explicitly confirmed act.
### Why a digest and not a version number
The Supervisor exposes no revision or ETag for an add-on's options. The only
thing a page can hold between reading and writing is the *content* it read —
and that is enough, because the question is not "how many times has this
changed" but "is what I am about to overwrite still what I was shown".
Identical content is by definition safe to overwrite.
### This is [[SAFETY-28]]'s discipline, applied to configuration
`arbiter.py` and `Controller.write_refusal()` enforce *exactly one writer per
actuator* not by convention but by a check on the write path:
> A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS
> TICK, FOR THIS UNIT.
The configuration analogue is written the same way and for the same reason:
> A BLOB REACHES THE SUPERVISOR ONLY IF IT WAS DERIVED FROM THE BLOB THAT IS
> STORED RIGHT NOW.
Note what is *not* claimed. SAFETY-28's rule makes a second writer
inexpressible; this one does not — the Supervisor's tab is a second writer and
stays one. What this gets is the weaker, honest property: **a second writer is
detected rather than silently overwritten.** That is the most a
compare-and-swap can give, and pretending otherwise would be the "check that
observes something other than what it claims to observe" this repository keeps
finding.
## 4. What this costs, stated plainly
**Two surfaces means two places to be confused.** The Configuration tab still
renders all 71 keys as a flat list, and it can still store a configuration the
add-on will refuse to start on — it knows only types and ranges. UX-01's
translations are therefore **not** superseded by this card: they are the only
thing standing between that tab and raw key names, and they are also where this
panel's labels, descriptions and section headings come from. The two surfaces
share one set of words on purpose; two vocabularies for one setting would be
worse than one plain list.
**The panel is not available when it is most needed.** If a configuration that
refuses to start is ever stored — from the Configuration tab, or by a hand-edit,
or by this panel in some future state it does not currently permit — the add-on
exits 78 and the panel goes with it. Recovery is the Configuration tab. That is
the reason it stays, and the reason the panel refuses to write a blob the
validator rejected: this surface must never be the thing that removes itself.
**The panel shows fewer refusals than the startup gate can, never more.** Two
of `validate.py`'s rules compare a configured ceiling against the setpoint
device's *own* reported range, which is only knowable by asking Home Assistant.
When ESPHome is asleep the range is `None` and those two rules are skipped —
here and at startup, identically. The page says so rather than looking green
for a reason the operator cannot see.
## 5. The restart cost, and why it is a separate button
Applying options needs a restart, and `Controller.shutdown()` commands **0 W on
every inverter** on the way out ("shutting down - commanding 0 W on every
inverter", main.py). A configuration save is therefore a real, if brief, loss
of control of the battery — and the operator is told that *before* they commit,
in the page's own markup, not in a message that appears afterwards.
So the panel splits the act in two:
* **Save** stores the blob. Nothing restarts, nothing is applied, and the page
then says which settings are stored but not running.
* **Restart to apply** asks again, names the 0 W again, and only then posts
`/addons/self/restart`. The request carries an intent token, so a reload, a
retry or a stray POST cannot spend a control interruption on the operator's
behalf.
A restart does **not** clear anything: SAFETY-02's latches are restored from
the Store on the way back up, which is what `Inverter.__init__` is written to
do and what [[SAFETY-38]] depends on.
## 6. The scope line, and it is a safety line
**Nothing on this surface commands an inverter, clears a latch or bypasses a
safety rule.** The set of things an operator can do to the hardware through the
panel is exactly what `/action` offered before this card. `app/selfconfig.py`
talks to the Supervisor about a JSON blob and to `validate.py` about whether
that blob is startable; the only actuator-shaped verb in it is `restart()`,
which restarts *this add-on* and whose 0 W is the existing, documented
wind-down.
test_selfconfig.py part 7 asserts that mechanically — it lists the routes, and
it greps `selfconfig.py` and the page for `set_number`, `write_setpoint`,
`handle_action`, `command_site_zero`, `sign_wd` and `reset(`. Adding "and also
set the setpoint while we are here" would put a second writer on the actuator
through the configuration surface, which is the non-goal this add-on is built
around.
## 7. How "every message comes from the shipped validator" is proven
Not by inspection. `test_selfconfig.py` part 6 copies the package, inserts **one
new rule** into `validate_startup_config()` in the copy, hashes every file under
`app/` to show that **validate.py is the only one that differs**, and then
serves the real `/config/validate` route out of that copy in a subprocess. The
new sentence arrives on the page, filed under the option key it names, with no
UI file touched.
That is the acceptance criterion run rather than argued, and it is what makes
"do not reimplement any rule in JavaScript" a property of the build instead of
a promise in a comment.
@@ -0,0 +1,379 @@
# UX-04 — what the Configuration tab renders, and the pin that was never built
**Status: DECIDED 2026-09-07.** Three decisions and one confirmation, in the
order [[UX-01]]'s unmet acceptance criteria are numbered:
1. **UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as
unsatisfiable**, not deferred. The target was arithmetically unreachable, and
the part of it that *was* reachable would be bought with the upgrade path.
The measurement is now **graded** in `tools/supervisor_shape_probe.py` §F
rather than printed, so the withdrawal cannot be quietly reversed.
2. **UX-01 AC 4's third clause is now BUILT**: `test_defaults_pin.py`, an AST
check, with its red direction executed on every run.
3. **UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on
`release/1.0`, by [[UX-02]]'s `bcee235`, not by UX-01's `fef6ef2`.** The
attribution is corrected here; no work was re-done.
4. **The 8 per-entry `inverters:` translations STAY.** They do not reach the
Supervisor tab and cannot be made to; [[UX-02]]'s ingress panel is where they
become visible.
Filed with UX-04. Card `6a9ec94617e9c48ed74ca5dd`. Every number below was
measured on `release/1.0` at `6e7b233` on 2026-09-07, against
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — the version
`ha info` reports on the live install — and against the 26-file check suite in
`goodwe-addon-checks:1`. Nothing here contacted the live Supervisor at
192.168.2.158, posted options, or restarted anything.
---
## 1. Hiding: withdrawn, and why that is the *safe* answer
### 1.1 What AC 3 claimed
> "The visible count drops to ~15, with the rest behind HA's *show unused
> optional configuration options* toggle."
### 1.2 What the tab actually renders
`config.yaml` declares **71 keys under `options:` and the same 71 under
`schema:`**. The set that HA's toggle is *able* to hide — schema-only keys — is
therefore **empty**:
```
hideable = set(schema) - set(options) -> [] (0 of 71)
```
On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden
before UX-01, nothing is hidden after it, and no line of `fef6ef2` moved a key
either way. AC 3 is not partially met; it is unstarted.
### 1.3 The rule that decides it, replayed rather than paraphrased
From the frontend bundle shipped **in this image**,
`api/panel/frontend_latest/5680.*.js` (frontend 20250925.1):
```js
_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)
```
`opts` is the add-on's options **as Supervisor serves them** — the operator's
stored blob after `options:` has been deep-merged into it. So a field is hidden
only when it is **both** absent from `options:` **and** not `required`. The
probe now runs that filter on this manifest rather than quoting it.
### 1.4 Reason one: the target was never reachable
Supervisor's own `UiOptions` marks `required` on **59** of the 71 fields; 12 are
optional. The filter above shows a required field unconditionally. So the
**floor** — what the tab renders with `options:` emptied *altogether*, which is
the most hiding that is even expressible — is 59 fields.
| `options:` block | fields rendered |
|---|---|
| as shipped (71 keys) | **71** |
| emptied entirely (0 keys) | **59** |
| AC 3's target | ~15 |
AC 3 asked for a number roughly four times below the floor. No arrangement of
`options:` reaches it. The criterion could not have been met by any
implementation; it was mis-specified, and the honest disposal of a
mis-specified criterion is withdrawal, not a partial gesture toward it.
### 1.5 Reason two: the 12 hides that *are* expressible cost the deep-merge
Moving a key out of `options:` is not free. `App.options` is
`deepmerge(config.yaml defaults, the stored user options)`, so **a key absent
from `options:` has no default for the Supervisor to merge**.
That is exactly the mechanism the 0.9.0 upgrade rests on. From `CHANGELOG.md`,
the pre-deploy check run against this release on 2026-09-07:
> The reference install's stored blob has **70 keys, not 71**.
> `delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running
> 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing
> `?`, which is the shape that becomes **exit 78**. It resolves to its
> `options:` default of **185** because the Supervisor deep-merges.
Hiding a key is done by taking it *out* of `options:` and marking it `?`. That
is, precisely, the edit that removes a key's merge default — the thing standing
between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12
fewer rows on a form, on a site where the add-on is holding a real house's grid
exchange at zero.
### 1.6 Reason three: it would promote 27 unpinned literals to load-bearing
And the cost compounds with §2 of this document. `test_defaults_pin.py` part 4
ledgers **27 (module, key) pairs** in `app/` that still answer a missing option
with an in-code literal. Today those literals are unreachable on a Supervisor
install, because the deep-merge guarantees every `options:` key is present in
the served blob. Remove keys from `options:` to hide them and those literals
stop being dead code: they become **the actual source of the defaults on a live
install** — the CFG-03 defect the same card's AC 4 exists to police, made
load-bearing by the card's own AC 3.
### 1.7 What now grades this
`tools/supervisor_shape_probe.py` §F. The count was printed there from the day
it was written — *"the Configuration tab renders 71 fields"* — and the probe
exited 0 through UX-01's whole review while displaying the number that
contradicted the criterion it was evidence for. **A measurement that is
displayed but not asserted is how this passed review.** Three assertions
replace the bare print:
```
PASS every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
- the only set HA's toggle can hide - is EMPTY. Hideable: []
PASS ...so replaying the frontend's own _filteredSchema on the served options
hides NOTHING: 71 of 71 fields render
PASS ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
expressible - the tab still renders 59 required fields. THAT is the
floor, and AC 3 asked for ~15.
```
Every number in them is computed from the manifest and from Supervisor's own
`required` flag; none is typed. **The red direction was run**: deleting one key
(`price_feed_url`) from `options:` and nothing else turns the first two to FAIL
— `Hideable: ['price_feed_url']`, `70 of 71 fields render` — and the probe
exits 1. The counterfactual is isolated: one key varied, nothing else.
So if a future editor empties `options:` to shorten the form, this goes red and
somebody has to read §1.5 before merging.
---
## 2. The defaults pin: `test_defaults_pin.py`
### 2.1 What AC 4 asked for and what shipped
AC 4 has three clauses.
| clause | state before UX-04 |
|---|---|
| "every `schema:` key absent from `options:` has exactly one defaults-table entry" | **vacuous** — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all |
| "the table changed → red" | **works**, six times over, once per mirror in its own suite |
| "no module carries its own literal fallback for a schema default — an AST check, not a grep" | **never built** |
The only literal scan in the repository before this card is
`test_validate.py:1025`: a `re.findall` restricted to eight `maintenance_*`
keys across three named files. It is a grep, it predates UX-01, and it was not
written for this.
### 2.2 What an "in-code literal fallback" is, structurally
A call in `app/` taking a string literal that names a `schema:` key, with a
constant in the next positional slot:
```python
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
```
Found by walking `ast.Call` nodes — so a multi-line call and a renamed `opts`
are found, and the same text inside a comment or docstring is not. Two
exclusions, both structural and both argued at the site:
* **`getattr(obj, "max_w", 0.0)`** reads an attribute off an object, not an
option off a blob. Excluded by callee name.
* **the empty string.** `opts.get("static_profile", "")` is Python's "not
stated", and all six such sites in `validate.py` normalise it immediately —
`str(opts.get(k, "") or "").strip()`. It cannot be a stale copy of a *number*,
which is the whole of the CFG-04 defect class. Excluded by value. **`0` is
not excluded**: `slew_w: 0` and `integrator_max_w: 0` are real shipped
defaults with real meanings.
### 2.3 What it grades, measured on `release/1.0` @ `6e7b233`
```
2. the mirrors, DISCOVERED from the tree rather than listed here
6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
30 mirrored key(s) of 71 in `schema:`
3. the AST scan
scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
34 over unmirrored ones
4. the CFG-03 ledger
27 (module, key) pair(s) carry a literal for a key no mirror answers for
```
Four properties, none of them vacuous:
1. **The mirrors are discovered, not listed.** Six suites already pin their own
table to `config.yaml` — and every one of those pins names its table by
hand, so a *seventh* table added tomorrow would be pinned by nobody and
nothing would say so. This walks `app/` for module-level `*_OPTION_DEFAULTS`
assignments and grades whatever it finds against `config.yaml`. That is the
property the six hand-written pins cannot have.
2. **No literal survives for a key a mirror already answers for.** 30 keys,
0 violations.
3. **The CFG-03 ledger is FROZEN, by (module, key) pair, not by count.** A
count goes green again the moment somebody deletes one site and adds
another. Line numbers are deliberately *not* in the key: a ledger that goes
red on an unrelated reformat is a ledger people learn to re-bless without
reading. The 27 pairs are the open CFG-03 backlog; they are **not** removed
here, because that is behaviour change across `main.py`, `p1.py`,
`inverter.py` and `validate.py` that no single card should make in passing.
The set can now only shrink.
4. **Every remaining literal is graded against `config.yaml`** — the half that
catches CFG-04's actual failure mode, two copies of one number and one moves.
### 2.4 One divergence found, and it is new
```
main.py target_grid_w: code 0 vs config.yaml -10
```
`main.py:290` reads `target_grid_w=float(opts.get("target_grid_w", 0))` while
`config.yaml` ships **-10**. A dict handed to `Controller` without the key holds
net exchange at 0 W instead of the shipped -10 W bias into import. **It cannot
reach an operator**: on a Supervisor install the deep-merge guarantees the key
is present, and the only paths that hand over a partial dict are
`sim/inject.py` and this repository's own fixtures. It is recorded in the check
as the single permitted divergence, with the reason, rather than corrected —
changing it alters the control loop's target and belongs to a card that grades
the loop. A *second* divergence goes red.
### 2.5 ⭐ The red direction, shown rather than claimed
A check that has never been shown failing is not a check — which is exactly
what clause one of this AC was. Two things follow from that:
**(a) The red direction runs on every invocation.** Part 5 feeds the scanner a
synthetic module containing a deliberate violation of each rule and *requires*
it to be flagged, in-process, through the same `literal_fallbacks()` the graded
parts call. A scanner that goes blind — a renamed helper, an `ast` change, a
`SCHEMA` that comes back empty — stops working here too, and the total site
count is additionally graded against a floor.
**(b) A literal was planted in the real tree and the check went red.** Planted
at `main.py:290`, `float(opts.get("maintenance_charge_w", 2000))` — a mirrored
key:
```
3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
ok the scanner is not blind: it found 35 sites (floor 20)
main.py:290 get('maintenance_charge_w', 2000)
FAIL no literal fallback survives for any of the 30 keys a mirror already
answers for - those sites must index the mirror
1 FAILED (exit 1)
```
And part 4's two directions, planted the same way with
`opts.get("p1_host", "x")` and `opts.get("slew_w", 999)`:
```
4. the CFG-03 ledger
28 (module, key) pair(s) carry a literal for a key no mirror answers for
FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
main.py p1_host: code 'x' vs config.yaml ''
main.py slew_w: code 999 vs config.yaml 0
main.py target_grid_w: code 0 vs config.yaml -10
FAIL every remaining literal still AGREES with config.yaml, except the 1
divergence(s) recorded above it
(exit 1)
```
The second of those is the CFG-04 defect class itself: a literal that has
drifted away from `config.yaml` while the file still says something else. Both
plants were removed; the tree is byte-identical to `6e7b233` apart from this
card's own files.
---
## 3. CFG-04: closed on `release/1.0`, by UX-02, not by UX-01
UX-01's AC 5 claimed the double-literal `max_w` hole. It did not close it.
`fef6ef2` touched `config.yaml`, `translations/`, `test_translations.py` and the
probe — **no defaults code at all** — and on the 0.7.0 tree the triple mutation
survives: 21 files, 0 RED.
**Confirmed on current `release/1.0` @ `6e7b233`, 2026-09-07.** The same triple
mutation, `2000 -> 2500` at the three descendants of 0.7.0's
`inverter.py:438` / `validate.py:383` / `validate.py:684`:
| 0.7.0 | `release/1.0` @ `6e7b233` |
|---|---|
| `inverter.py:438` `max_w=float(opts.get("max_w", 2000))` | `inverter.py:640` `raw = opts.get("max_w", 2000)` |
| `validate.py:383` `cap = _number(opts, "max_w", 2000.0)` | `validate.py:466`, same line |
| `validate.py:684` `max_w = _number(opts, "max_w", 2000.0)` | `validate.py:836`, same line |
Result — 26 files, **RED at `test_selfconfig.py`**, exit 1:
```
1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the
rail collapsing to the 2000 default under an explicit 400 W slew, so the box
cannot be emptied into a quietly halved rail
```
`git log -L 310,325:goodwe_controller/test_selfconfig.py` attributes that check
to **`bcee235`, "UX-02: pin both halves of the max_w fix, because neither was
graded", glenn schrooyen, 2026-09-06** — not to UX-01. The hole ships closed
with the pending 0.9.0 deploy. **The AC is met by UX-02.** No work was re-done
here; the mutation was run once, on a throwaway copy, to confirm it.
---
## 4. The 8 per-entry translations: they stay
### 4.1 The measurement, and it is negative
`translations/{en,nl}.yaml` carry a name and a description for all 8 per-entry
keys of the repeatable `inverters:` section. They are loaded by Supervisor's own
loader, they pass `SCHEMA_APP_TRANSLATIONS`, and `test_translations.py` grades
their coverage. **They do not reach the screen**, and this is not a defect in
them. `UiOptions` gives `inverters` `type: "schema"` with `multiple: True`, and
the frontend's `_convertSchemaElement` sends exactly that case to a selector
rather than to a nested `ha-form`:
```js
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 `fields[name].label || name`, while
`_convertSchemaElementToSelector` sets **neither `label` nor `translation_key`**.
So an operator sees raw `name`, `setpoint_entity`, `max_w`, `bms_soc_floor`. The
probe already asserts this, negatively and by name.
### 4.2 Decision: keep them. Four reasons.
1. **The Supervisor tab cannot be made to render them.** The gap is in
somebody else's converter, not in this repository. There is no change here
that fixes it; deleting the translations does not fix it either.
2. **[[UX-02]]'s ingress panel is where they become visible**, and it renders
the roster itself rather than handing it to `ha-selector-object`. The
`fields:` block is the shape Supervisor's own schema defines for exactly
this, and it is what a surface that wires `label` through consumes.
3. **They cost nothing.** No runtime path reads them; they are two YAML blocks
in files the Supervisor already parses, and the parse is graded green.
4. **Deleting them would remove the only thing that catches a per-entry key
added without a translation.** `test_translations.py` grades top-level and
per-entry coverage symmetrically; drop the per-entry half and SAFETY-32's
`bms_soc_floor` — a *required* per-entry key added after UX-01 — would have
joined the roster with no label and nothing to say so.
This is a written decision, not a change. The tree is unmodified in
`translations/`.
### 4.3 Not re-filed
`tools/supervisor_selfapi_probe.py` has been known red since OPT-09 — it
hardcodes `len(_sub) == 7` against 8 per-entry keys. That is **TOOL-04**,
already filed, and it is not re-filed or fixed here. It is named only so a
reader of §4.1 knows why the *other* probe is the one quoted.
---
## 5. What this card did not do
* It did not remove a key from `options:`. See §1.5 — the deep-merge default is
what makes the pending 0.9.0 upgrade safe on a 0.7.0 box.
* It did not pay down the 27-pair CFG-03 ledger. See §2.3.
* It did not correct `main.py`'s `target_grid_w` fallback. See §2.4.
* It did not touch `translations/`. See §4.2.
* It did not re-do CFG-04. See §3.
* It did not touch hardware, the live Home Assistant at 192.168.2.158, or
`sim/`. Both probe runs were throwaway containers over a read-only mount.
+115 -2
View File
@@ -40,6 +40,69 @@ hardware holds it there.
## Configuration
### ⚠️ There are TWO places to configure this add-on (UX-02)
Since **UX-02** the ingress panel has a **Configuration** page of its own,
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
there is one options blob, and neither surface is a copy of it.
| | Ingress panel → **Configuration** | Supervisor → **Configuration** tab |
|---|---|---|
| **Use it for** | everyday editing | recovery, and when the add-on is down |
| **Sections, units, help** | real headings, units beside the boxes, the description under each field | one flat list of 71 fields, no headings, no units |
| **Tells you a setting is wrong** | **yes — before you save**, next to the field | no. It knows types and ranges only |
| **Can express a rule spanning two keys** | yes, because it runs the add-on's own validator | **no.** The schema grammar cannot say "must be at or below `max_w`" |
| **Available when the add-on is not running** | **no** — it is served *by* the add-on | **yes** |
| **Editing the inverter list** | cards you add and remove, with the per-entry labels rendered | a repeatable section whose per-entry keys render **raw** on frontend 20250925.1 |
**Which one is authoritative: neither.** The stored options blob is, and both
are editors of it. `DECISIONS/UX-02-two-config-surfaces.md` is the record, with
the measurements behind it.
The one thing you need to know in practice: **the panel refuses to save a
configuration the add-on would not start on, and the Configuration tab does
not.** The tab will happily store `maintenance_charge_w: 2500` beside
`max_w: 2000`; the add-on then exits 78 on its next start, the panel goes down
with it, and the tab is where you fix it. That asymmetry is the reason both
exist.
#### What the panel does when you press Save
1. It runs the **shipped** validator — the same `validate_startup_config()` and
`validate_unit_ranges()` the add-on runs at startup, not a copy of them — and
if anything is refused, **nothing is written** and your edits stay on screen.
2. It re-reads the stored blob and refuses if it changed while you were editing
— someone in the Configuration tab, or you on another device. It names the
keys that moved and shows both values; pressing Save again then writes yours.
3. It writes the whole blob. It does **not** restart: `POST …/options` replaces
the stored options, and a key left out would revert to the shipped default,
so the panel always sends everything it read.
#### ⚠️ Saving costs nothing. Applying costs a control interruption
Stored settings take effect when the add-on next starts. The panel shows which
stored settings are not what the add-on is currently running, and **Restart to
apply** is a separate button with its own confirmation — because the wind-down
commands **0 W on every inverter** before the process exits, and the battery
does nothing until it is back. Nothing else is changed by that restart: a
latched sign-disagreement failsafe is restored from the Store on the way back
up, not cleared.
#### Two range checks can be skipped, and the page says so
Two of the refusals compare a configured ceiling against the setpoint device's
**own** reported range, which means asking Home Assistant. If ESPHome has not
come up, that range is unknown and those two checks are skipped — on the page
and at startup, identically. The page prints a note saying so rather than
looking green for a reason you cannot see. If the device answers later and
disagrees, the add-on refuses to start then, with the same sentence.
#### On a phone
One column, big targets, sections collapsed until you open them, and the
refusals pinned where you cannot scroll past them. It is the screen for
standing in front of the inverter.
### What the Configuration tab actually shows you (UX-01)
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
@@ -47,12 +110,13 @@ 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
rather than assumed — `tools/supervisor_shape_probe.py` section F GRADES (it
used only to print) 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 |
| **All 71 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 |
@@ -1497,9 +1561,58 @@ is, and a **Commissioning** checklist that names any problem in words. It also
carries the three buttons: start/stop control, force a maintenance cycle, and
abort one.
Since **UX-02** it has a second page, **Configuration** — see *There are TWO
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
inverter.** The configuration page reads and writes the add-on's own settings
and can restart the add-on; the set of commands that reach the hardware is
exactly the three buttons it always had.
## Status entities
If an MQTT broker is available the add-on publishes setpoint, grid power,
battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control.
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
Two ways a deploy has gone wrong on this system, both of which the Supervisor
reports as success:
**The silent no-op.** The Supervisor keys the built image by `version:` in
`config.yaml`. `ha apps update` against an unchanged version installs nothing
and says it worked. This happened twice: PKG-06 (six merges behind an unbumped
version) and PKG-07 (six again). Both times a human noticed. Nothing else would have.
**The refused config.** A config the shipped validator rejects is
`SystemExit(78)` — the add-on does not start. ⚠️ **And the ingress panel goes
with it**, because the add-on serving it is not running, so recovery means the
Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that
started fine on the previous version: 0.7.0's own default `slew_w: 1000`
exceeds the 555.6 W ceiling on a 5000 W rail.
Run it inside the container, so the validator half actually runs:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.7.0 --options /w/live-options.json
Export `live-options.json` from the Supervisor first — the options blob is not
reachable through the Home Assistant REST API, and no entity carries it.
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
add-on's dependencies the validator half skips and returns 2 rather than
pretending; `test_version_gate.py` pins that a skipped check can never exit 0,
because the first draft of that test reported four silent skips as passes.
⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching
runtime code must bump the version" — is the wrong shape here. This project
deliberately batches many merges into one deploy, so a per-PR bump would put
every branch in conflict over one line and still not prevent the failure, which
happens at deploy and not at merge. The check belongs where the mistake is made.
⚠️ This file's own change does **not** need a version bump, and the gate agrees:
`tools/` and `test_*.py` are not in its runtime set, because demanding a bump
for a typo fix trains people to bump without reading — the habit the check
exists to prevent.
+562
View File
@@ -0,0 +1,562 @@
"""UX-02: the configuration page itself - markup, style and the form's script.
⚠️ WHY THIS IS ITS OWN FILE. `web.py` is the ROUTES: three status handlers and,
since this card, five configuration ones. The page is ~500 lines of HTML, CSS
and JavaScript, and inlining it there would bury the routing - which is the
half a reviewer has to check for "does anything here command an inverter".
⚠️ AND WHAT IS NOT IN THIS FILE, WHICH IS THE POINT OF THE CARD. There is no
option key, no label, no unit, no range, no default and NO RULE in the script
below. It renders a model the server built out of `GET /addons/self/info`
(app/configui.py) and it renders refusals the server got out of
`validate_startup_config()` (app/selfconfig.py). Its longest function is the one
that decides which HTML input a `type` deserves.
That is deliberate and it is checkable: test_selfconfig.py part 5 greps this
file for the option keys and for phrases out of validate.py's refusals and
asserts it contains none of them, and part 6 adds a NEW rule to a copy of
validate.py and watches it appear on the page with this file untouched.
⚠️ EVERY URL IS RELATIVE. Ingress serves the add-on under a generated path
prefix (`/api/hassio_ingress/<token>/`), so an absolute `/config/model` 404s in
the field while working perfectly on a laptop. web.py's own header has said so
since the status page shipped; this file has five more chances to get it wrong.
⚠️ AND IT IS BUILT FOR A PHONE FIRST. This is the screen someone stands in
front of the inverter with: one column, big touch targets, sections that start
collapsed so 71 options do not arrive as one wall, and the refusal block pinned
where it cannot be scrolled past.
"""
CONFIG_PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoodWe - Configuration</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
--ok:#137333;--warn:#b25e02;--bad:#c5221f;--accent:#1a73e8;
--badtint:rgba(197,34,31,.10);--warntint:rgba(178,94,2,.10);
--oktint:rgba(19,115,51,.10)}
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82;
--accent:#8ab4f8;--badtint:rgba(242,139,130,.14);
--warntint:rgba(253,214,99,.14);--oktint:rgba(129,201,149,.14)}}
*{box-sizing:border-box}
body{margin:0;padding:0 0 96px;background:var(--bg);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
.wrap{max-width:760px;margin:0 auto;padding:14px}
h1{font-size:18px;margin:0 0 2px}
a{color:var(--accent)}
.sub{color:var(--muted);font-size:13px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px;margin-bottom:12px}
.note{border-left:4px solid var(--warn);background:var(--warntint);
border-radius:8px;padding:11px 13px;margin-bottom:12px;font-size:14px}
.note.bad{border-left-color:var(--bad);background:var(--badtint)}
.note.ok{border-left-color:var(--ok);background:var(--oktint)}
.note b{display:block;margin-bottom:3px}
details.grp{background:var(--card);border:1px solid var(--line);
border-radius:10px;margin-bottom:10px;overflow:hidden}
details.grp>summary{cursor:pointer;padding:14px;font-weight:600;
list-style:none;display:flex;justify-content:space-between;gap:10px;
align-items:center}
details.grp>summary::-webkit-details-marker{display:none}
details.grp>summary::after{content:"\\25be";color:var(--muted);font-weight:400}
details.grp[open]>summary::after{content:"\\25b4"}
details.grp[open]>summary{border-bottom:1px solid var(--line)}
.gbody{padding:4px 14px 14px}
.pill{font-size:12px;font-weight:600;border-radius:999px;padding:2px 9px;
background:var(--badtint);color:var(--bad)}
.pill.q{background:var(--warntint);color:var(--warn)}
.f{padding:13px 0;border-bottom:1px solid var(--line)}
.f:last-child{border-bottom:0}
.f>label{display:block;font-weight:600;margin-bottom:2px}
.k{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
.row{display:flex;gap:8px;align-items:center;margin:6px 0 0}
.row input[type=text],.row input[type=number],.row select{flex:1 1 auto;
min-width:0;font:inherit;padding:10px;border-radius:8px;
border:1px solid var(--line);background:var(--bg);color:var(--ink)}
.row .unit{color:var(--muted);font-size:13px;white-space:nowrap}
.rng{color:var(--muted);font-size:12px;margin-top:4px}
.help{color:var(--muted);font-size:13px;margin-top:6px;cursor:pointer;
max-height:3.1em;overflow:hidden;position:relative}
.help.open{max-height:none}
.help::after{content:" more";color:var(--accent)}
.help.open::after{content:" less";color:var(--accent)}
.prob{margin-top:8px;border-left:4px solid var(--bad);background:var(--badtint);
border-radius:6px;padding:9px 11px;font-size:13.5px}
.prob+.prob{margin-top:6px}
.sw{display:flex;align-items:center;gap:10px;margin-top:6px}
.sw input{width:20px;height:20px}
.entry{border:1px solid var(--line);border-radius:9px;padding:10px 12px;
margin:10px 0;background:var(--bg)}
.entry h4{margin:0 0 2px;font-size:14px}
button{font:inherit;padding:10px 14px;border-radius:8px;
border:1px solid var(--line);background:var(--card);color:var(--ink);
cursor:pointer}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
button[disabled]{opacity:.5;cursor:not-allowed}
button.small{padding:6px 10px;font-size:13px}
.bar{position:fixed;left:0;right:0;bottom:0;background:var(--card);
border-top:1px solid var(--line);padding:10px 14px;z-index:5}
.bar .in{max-width:760px;margin:0 auto;display:flex;gap:10px;
align-items:center;flex-wrap:wrap}
.bar .msg{flex:1 1 200px;min-width:0;font-size:13px;color:var(--muted)}
dialog{border:1px solid var(--line);border-radius:12px;background:var(--card);
color:var(--ink);max-width:520px;width:92%;padding:18px}
dialog::backdrop{background:rgba(0,0,0,.5)}
.diff{font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
white-space:pre-wrap;word-break:break-word;margin-top:6px}
</style></head><body>
<div class="wrap">
<h1>GoodWe - Configuration</h1>
<p class="sub"><a href="./">&larr; Status and controls</a></p>
<div class="note" id="authority">
<b>This page and the Supervisor's Configuration tab edit the same settings.</b>
Both write one options blob. This page is the one that can tell you a
setting is wrong <i>before</i> you save it; the Configuration tab cannot,
because it only knows types and ranges. If the add-on is not running, the
Configuration tab is the only one of the two that still exists - that is
what it is for.
</div>
<div class="note" id="restartcost">
<b>Saving does not restart anything, and does not take effect on its own.</b>
Stored settings are applied when the add-on next starts. Restarting to
apply them costs a short loss of control: the add-on commands
<b>0 W on every inverter</b> as it shuts down, and the battery does nothing
until it is back up. That is a separate button, below, with its own
confirmation.
</div>
<div id="banner"></div>
<div id="staleness"></div>
<div id="form"></div>
</div>
<div class="bar"><div class="in">
<button class="primary" id="save" disabled>Save settings</button>
<button id="restart" disabled>Restart to apply</button>
<button id="reload">Discard edits</button>
<span class="msg" id="msg">loading&hellip;</span>
</div></div>
<dialog id="confirm">
<h3 style="margin-top:0">Restart the add-on?</h3>
<p>The add-on will command <b>0 W on every inverter</b>, stop, and start
again with the stored settings. Control of the battery is lost for as long as
that takes. Nothing else about the inverter is changed, and no latched
failsafe is cleared.</p>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button id="cancel">Cancel</button>
<button class="danger" id="go">Yes, restart now</button>
</div>
</dialog>
<script>
"use strict";
var MODEL = null; // groups/fields, from the server
var STORED = null; // the options blob the server last read
var FP = ""; // its fingerprint - the compare-and-swap token
var VALS = {}; // what is on screen right now
var timer = null;
function el(tag, cls, text){
var e = document.createElement(tag);
if(cls) e.className = cls;
if(text !== undefined && text !== null) e.textContent = text;
return e;
}
function msg(text, kind){
var m = document.getElementById('msg');
m.textContent = text;
m.style.color = kind === 'bad' ? 'var(--bad)'
: kind === 'ok' ? 'var(--ok)' : 'var(--muted)';
}
function note(host, kind, title, body){
var d = el('div', 'note ' + (kind || ''));
if(title) d.appendChild(el('b', null, title));
if(body) d.appendChild(document.createTextNode(body));
host.appendChild(d);
return d;
}
// --- the value of one input, by the type the SERVER gave the field ---------
// The only "knowledge" in this script: which HTML control a Supervisor type
// deserves, and how to read it back. No key names, no bounds, no rules.
function readInput(input, type){
if(type === 'boolean') return input.checked;
if(type === 'integer' || type === 'float'){
if(input.value === '') return '';
var n = Number(input.value);
return isNaN(n) ? input.value : n;
}
return input.value;
}
function makeInput(field, value, onchange){
var row = el('div', 'row');
var input;
if(field.type === 'boolean'){
row.className = 'sw';
input = el('input');
input.type = 'checkbox';
input.checked = !!value;
var lab = el('span', 'k', field.key);
row.appendChild(input); row.appendChild(lab);
} else if(field.type === 'select'){
input = el('select');
(field.options || []).forEach(function(o){
var opt = el('option', null, o); opt.value = o;
if(String(value) === o) opt.selected = true;
input.appendChild(opt);
});
row.appendChild(input);
} else {
input = el('input');
if(field.type === 'integer' || field.type === 'float'){
input.type = 'number';
input.inputMode = field.type === 'integer' ? 'numeric' : 'decimal';
input.step = field.type === 'integer' ? '1' : 'any';
if(field.min !== undefined) input.min = field.min;
if(field.max !== undefined) input.max = field.max;
} else {
input.type = 'text';
input.autocapitalize = 'none';
input.spellcheck = false;
}
input.value = (value === undefined || value === null) ? '' : value;
row.appendChild(input);
if(field.unit) row.appendChild(el('span', 'unit', field.unit));
}
input.addEventListener('input', onchange);
input.addEventListener('change', onchange);
return {row: row, input: input};
}
function helpBlock(field){
if(!field.help) return null;
var h = el('div', 'help', field.help);
h.title = field.help;
h.addEventListener('click', function(){ h.classList.toggle('open'); });
return h;
}
function rangeHint(field){
var bits = [];
if(field.min !== undefined || field.max !== undefined){
bits.push('accepted: ' + (field.min !== undefined ? field.min : '-')
+ ' to ' + (field.max !== undefined ? field.max : '-')
+ (field.unit ? ' ' + field.unit : ''));
}
bits.push(field.key);
return el('div', 'rng', bits.join(' \\u00b7 '));
}
// --- a list of scalars (`- str` in the schema) -----------------------------
function scalarList(field, value){
var box = el('div');
var items = Array.isArray(value) ? value.slice() : [];
function draw(){
box.textContent = '';
items.forEach(function(v, i){
var row = el('div', 'row');
var input = el('input'); input.type = 'text'; input.value = v;
input.autocapitalize = 'none'; input.spellcheck = false;
input.addEventListener('input', function(){
items[i] = input.value; VALS[field.key] = items.slice(); queue();
});
var rm = el('button', 'small', 'Remove');
rm.addEventListener('click', function(){
items.splice(i, 1); VALS[field.key] = items.slice(); draw(); queue();
});
row.appendChild(input); row.appendChild(rm);
box.appendChild(row);
});
var add = el('button', 'small', 'Add');
add.addEventListener('click', function(){
items.push(''); VALS[field.key] = items.slice(); draw(); queue();
});
box.appendChild(add);
}
draw();
return box;
}
// --- a list of objects: the roster editor ----------------------------------
function objectList(field, value){
var box = el('div');
var entries = Array.isArray(value) ? JSON.parse(JSON.stringify(value)) : [];
function push(){ VALS[field.key] = JSON.parse(JSON.stringify(entries)); }
function draw(){
box.textContent = '';
if(!entries.length){
var e = el('div', 'rng',
'No entries. With none, the settings above describe the one inverter.');
box.appendChild(e);
}
entries.forEach(function(entry, i){
var card = el('div', 'entry');
card.appendChild(el('h4', null, 'Entry ' + (i + 1)
+ (entry && entry.name ? ' \\u2014 ' + entry.name : '')));
(field.fields || []).forEach(function(sub){
var f = el('div', 'f');
// The label is the translation's own, verbatim - UX-01 already says
// "(optional)" in the ones that are, and adding a second one here
// would be this panel putting words next to a field.
f.appendChild(el('label', null, sub.label));
var made = makeInput(sub, entry ? entry[sub.key] : undefined,
function(){
entries[i][sub.key] = readInput(made.input, sub.type);
push(); queue();
});
f.appendChild(made.row);
f.appendChild(rangeHint(sub));
var h = helpBlock(sub); if(h) f.appendChild(h);
card.appendChild(f);
});
var rm = el('button', 'small danger', 'Remove this inverter');
rm.addEventListener('click', function(){
entries.splice(i, 1); push(); draw(); queue();
});
card.appendChild(rm);
box.appendChild(card);
});
var add = el('button', 'small', 'Add an inverter');
add.addEventListener('click', function(){
var fresh = {};
(field.fields || []).forEach(function(sub){
fresh[sub.key] = sub.type === 'boolean' ? false
: (sub.type === 'integer' || sub.type === 'float') ? ''
: '';
});
entries.push(fresh); push(); draw(); queue();
});
box.appendChild(add);
}
draw();
return box;
}
// --- rendering the whole form ---------------------------------------------
var FIELD_NODES = {};
function render(){
var host = document.getElementById('form');
host.textContent = '';
FIELD_NODES = {};
MODEL.groups.forEach(function(group){
var d = el('details', 'grp');
var s = el('summary');
s.appendChild(el('span', null, group.name || 'Not grouped'));
var pill = el('span', 'pill'); pill.style.display = 'none';
s.appendChild(pill);
d.appendChild(s);
var body = el('div', 'gbody');
group.fields.forEach(function(field){
var f = el('div', 'f');
f.appendChild(el('label', null, field.label));
if(field.type === 'schema'){
f.appendChild(objectList(field, VALS[field.key]));
} else if(field.multiple){
f.appendChild(scalarList(field, VALS[field.key]));
} else {
var made = makeInput(field, VALS[field.key], function(){
VALS[field.key] = readInput(made.input, field.type); queue();
});
f.appendChild(made.row);
}
if(field.type !== 'schema') f.appendChild(rangeHint(field));
var h = helpBlock(field); if(h) f.appendChild(h);
var probs = el('div'); f.appendChild(probs);
FIELD_NODES[field.key] = {probs: probs, group: d, pill: pill};
body.appendChild(f);
});
d.appendChild(body);
host.appendChild(d);
});
}
// --- refusals: printed exactly as the validator wrote them -----------------
function showProblems(payload){
Object.keys(FIELD_NODES).forEach(function(k){
FIELD_NODES[k].probs.textContent = '';
});
document.querySelectorAll('.pill').forEach(function(p){
p.style.display = 'none'; p.textContent = '';
});
var banner = document.getElementById('banner');
banner.textContent = '';
var counts = new Map();
Object.keys(payload.by_key || {}).forEach(function(key){
var node = FIELD_NODES[key];
payload.by_key[key].forEach(function(text){
var box = el('div', 'prob', text);
if(node) node.probs.appendChild(box); else banner.appendChild(box);
});
if(node){
counts.set(node.pill, (counts.get(node.pill) || 0)
+ payload.by_key[key].length);
node.group.open = true;
}
});
counts.forEach(function(n, pill){
pill.textContent = n + (n === 1 ? ' problem' : ' problems');
pill.style.display = '';
});
(payload.unanchored || []).forEach(function(text){
banner.appendChild(el('div', 'prob', text));
});
var total = (payload.problems || []).length;
var save = document.getElementById('save');
save.disabled = total > 0;
if(total){
note(banner, 'bad', 'This configuration would not start.',
'The add-on refuses to run on it and would stop with exit 78. '
+ 'Saving is blocked until these are fixed; nothing has been written. '
+ 'Each message below is the add-on\\u2019s own words, from the same '
+ 'check that runs at startup.');
banner.insertBefore(banner.lastChild, banner.firstChild);
msg(total + (total === 1 ? ' problem' : ' problems')
+ ' - saving is blocked', 'bad');
} else {
msg('this configuration would start', 'ok');
}
if(payload.device_note){
note(banner, '', 'Two range checks could not run.', payload.device_note);
}
}
// ⚠️ THE DEBOUNCE IS WHAT BOUNDS THE READS THIS PAGE MAKES OF HOME ASSISTANT.
// Each validation asks the setpoint device(s) for their own range, and those
// are real Core API reads on the same session the control loop uses. Because
// every keystroke RESETS the timer, sustained typing produces no requests at
// all and one lands 350 ms after the operator stops - which is the property
// that matters, not the number.
function queue(){
msg('checking\\u2026');
document.getElementById('save').disabled = true;
if(timer) clearTimeout(timer);
timer = setTimeout(validate, 350);
}
async function validate(){
try{
const r = await fetch('config/validate', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({options: VALS})});
showProblems(await r.json());
}catch(e){
msg('cannot reach the add-on: ' + e, 'bad');
}
}
function showStaleness(keys, stored){
var host = document.getElementById('staleness');
host.textContent = '';
if(!keys || !keys.length) return;
var d = note(host, '', 'Saved, but not running yet.',
'These settings are stored and differ from what this add-on is running '
+ 'right now. They take effect when it next starts:');
var pre = el('div', 'diff', keys.map(function(k){
return k + ': running ' + JSON.stringify(stored.running[k])
+ ' -> stored ' + JSON.stringify(stored.stored[k]);
}).join('\\n'));
d.appendChild(pre);
}
async function load(){
msg('loading\\u2026');
try{
const r = await fetch('config/model?lang='
+ encodeURIComponent((navigator.language || 'en')
.slice(0, 2)),
{cache: 'no-store'});
const m = await r.json();
MODEL = m; STORED = m.options; FP = m.fingerprint;
VALS = JSON.parse(JSON.stringify(m.options));
render();
showStaleness(m.running_differs, {running: m.running, stored: m.options});
document.getElementById('restart').disabled = false;
await validate();
}catch(e){
msg('cannot load the configuration: ' + e, 'bad');
}
}
async function save(){
msg('saving\\u2026');
document.getElementById('save').disabled = true;
var r, body;
try{
r = await fetch('config/save', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({options: VALS, fingerprint: FP})});
body = await r.json();
}catch(e){
msg('cannot reach the add-on: ' + e, 'bad');
document.getElementById('save').disabled = false;
return;
}
if(r.ok){
FP = body.fingerprint;
msg('saved - restart to apply', 'ok');
var host = document.getElementById('staleness');
host.textContent = '';
note(host, 'ok', 'Saved. Not applied yet.',
'The add-on is still running the settings it started with. Press '
+ '"Restart to apply" when you are ready to lose control of the '
+ 'battery for a few seconds.');
return;
}
if(body.kind === 'stale'){
var host2 = document.getElementById('staleness');
host2.textContent = '';
var d = note(host2, 'bad', 'Nothing was saved: these settings changed '
+ 'somewhere else while you were editing.',
'Somebody - most likely the Supervisor\\u2019s Configuration tab - '
+ 'wrote these settings after this page loaded. Your edits are still on '
+ 'screen and nothing has been overwritten. Read what changed, then '
+ 'press Save again if you still want your version.');
d.appendChild(el('div', 'diff', (body.changed || []).map(function(k){
return k + ': theirs ' + JSON.stringify(body.stored[k])
+ ' -> mine ' + JSON.stringify(VALS[k]);
}).join('\\n') || 'no key that this page also edits'));
FP = body.fingerprint;
msg('not saved - the stored settings had changed', 'bad');
document.getElementById('save').disabled = false;
return;
}
if(body.kind === 'invalid'){
showProblems(body);
msg('not saved - the configuration would not start', 'bad');
return;
}
msg('not saved: ' + (body.error || r.status), 'bad');
document.getElementById('save').disabled = false;
}
document.getElementById('save').addEventListener('click', save);
document.getElementById('reload').addEventListener('click', load);
document.getElementById('restart').addEventListener('click', function(){
document.getElementById('confirm').showModal();
});
document.getElementById('cancel').addEventListener('click', function(){
document.getElementById('confirm').close();
});
document.getElementById('go').addEventListener('click', async function(){
document.getElementById('confirm').close();
msg('restarting\\u2026');
try{
await fetch('config/restart', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({confirm: 'restart'})});
msg('restart requested - this page will stop answering for a moment');
}catch(e){
msg('restart requested - this page will stop answering for a moment');
}
});
load();
</script></body></html>
"""
+315
View File
@@ -0,0 +1,315 @@
"""UX-02: the config surface's MODEL - built from what the Supervisor returns.
WHAT THIS MODULE IS FOR, in one sentence: turn the three things
`GET /addons/self/info` already carries - `schema`, `translations` and
`options` - into the shape a form renders from, WITHOUT this repository owning
a second copy of any of them.
⚠️ THE RULE THIS FILE EXISTS TO OBEY. There is exactly one description of the
option surface (config.yaml's `schema:` block), exactly one set of labels
(translations/*.yaml, UX-01) and exactly one set of cross-field rules
(app/validate.py). A config UI is the classic place where all three get
transcribed a second time - a hand-written field list, a hand-written label, a
hand-written "max_w must not exceed the device" in JavaScript - and the second
copy is what drifts. So:
* the FIELDS come from `info["schema"]`, which is Supervisor's own
`App.schema_ui` == `UiOptions(...)(config.yaml["schema"])`. Nothing here
lists an option key.
* the LABELS come from `info["translations"]`, which is Supervisor's own
`_read_app_translations()` over translations/*.yaml. Nothing here contains
an English or Dutch sentence about an option.
* the RULES are not here at all. `problems_by_field()` takes strings the
SHIPPED validator produced and decides only WHERE ON THE PAGE to put them.
The consequence is the acceptance criterion: a rule added to validate.py, an
option added to config.yaml, or a label improved in translations/en.yaml all
reach the panel with no change to this file, to web.py, or to the page's
JavaScript. test_selfconfig.py part 6 proves the first of those by running the
panel against a MUTATED COPY of validate.py in a subprocess.
⚠️ STDLIB ONLY, AND THAT IS LOAD-BEARING. This module is imported by
tools/supervisor_selfapi_probe.py, which runs inside the Home Assistant
Supervisor release image so that the model can be built from the REAL loader's
output rather than from a transcription of it. That image has no paho-mqtt, so
an import of anything under `app/` that reaches `mqtt.py` would make the probe
unrunnable and the measurement would quietly become a re-reading.
--- WHAT THE SUPERVISOR CANNOT RENDER, AND WHY THAT IS THE POINT -------------
Add-on options are a FLAT LIST. UX-01's own header says it: no section-header
primitive, no units on an input, no per-option grouping. UX-01 answered that by
prefixing every label with `Group · `, which is the best a flat list allows.
This module reads that prefix back off and turns it into a real heading, so the
grouping the operator already has in words becomes grouping they have on the
screen - in whichever language their labels resolved in, because the prefix is
translated too (`Limits` / `Grenzen`).
⚠️ NOTHING IS INVENTED WHEN THE PREFIX IS ABSENT. A key whose label carries no
` · ` is NOT quietly filed under the last heading seen - that would put a new
option in a section nobody chose and it would look deliberate. It goes into the
model's `ungrouped` list, is rendered under a heading that says so, and
test_selfconfig.py asserts the list is EMPTY today, so the day config.yaml
grows a key UX-01 has not grouped, a check says so instead of the page guessing.
"""
import re
__all__ = ["resolve_entry", "label_of", "help_of", "split_group", "unit_of",
"field_model", "problems_by_field", "changed_keys"]
# --------------------------------------------------------------------------- #
# 1. LABELS - the frontend's own resolution order, transcribed once
#
# ⚠️ THIS IS A TRANSCRIPTION OF SOMEBODY ELSE'S CODE AND IT IS LABELLED AS ONE.
# tools/supervisor_shape_probe.py section F reads the resolution out of the
# shipped frontend bundle in the release image this site runs
# (`5680.*.js`, frontend 20250925.1):
#
# computeLabel = (s,_,o) => this._getTranslationEntry(this.hass.language,s,o)?.name
# || this._getTranslationEntry("en",s,o)?.name
# || s.name
#
# i.e. THE FALLBACK IS PER KEY, `en` is the second chance and the raw key is the
# third. The panel resolves labels the same way ON PURPOSE: two config surfaces
# that disagree about what a field is CALLED would be worse than one ugly one.
# If that resolution ever changes upstream, this is a copy that can go stale -
# it is three lines, it is named here, and the probe prints both renderings side
# by side so a divergence is visible rather than argued about.
# --------------------------------------------------------------------------- #
def resolve_entry(translations, lang, key, path=()):
"""Yield `(language, entry)` for `key`, in the per-key `lang -> en` order.
`translations` is `info["translations"]` verbatim: `{lang: {"configuration":
{key: {"name", "description", "fields": {...}}}}}`. `path` names the
enclosing repeatable section for a per-entry key, e.g. `("inverters",)`.
"""
for language in (lang, "en"):
node = ((translations or {}).get(language) or {}).get("configuration")
if not isinstance(node, dict):
continue
for step in path:
node = ((node.get(step) or {}).get("fields")) or {}
entry = node.get(key)
if isinstance(entry, dict):
yield language, entry
def label_of(translations, lang, key, path=()):
"""(label, source-language) with the raw key as the last resort."""
for language, entry in resolve_entry(translations, lang, key, path):
name = str(entry.get("name") or "").strip()
if name:
return name, language
return key, "raw"
def help_of(translations, lang, key, path=()):
"""(description, source-language). "" when nothing was translated."""
for language, entry in resolve_entry(translations, lang, key, path):
text = str(entry.get("description") or "").strip()
if text:
return text, language
return "", "none"
# UX-01's separator, U+00B7 with a space each side. It is not a general
# delimiter guess: it is the exact string that file's generator writes, and the
# audit in tools/translations_lint.py is what keeps every label carrying one.
GROUP_SEP = " · "
def split_group(label):
"""`"Limits · Inverter rating (W)"` -> `("Limits", "Inverter rating (W)")`.
A label with no separator comes back as `("", label)` - see the module
docstring on why that is not filed under the previous heading.
"""
if GROUP_SEP in label:
group, _, rest = label.partition(GROUP_SEP)
return group.strip(), rest.strip()
return "", label
# --------------------------------------------------------------------------- #
# 2. UNITS - presentation, derived from the key's own suffix
#
# ⚠️ THIS IS THE ONE PLACE THIS MODULE PUTS WORDS NEXT TO A FIELD, and it is
# deliberately the weakest possible rule: a suffix map, applied to a key name,
# affecting nothing but what is printed to the right of an input box. It states
# no bound, no default and no relation, so it cannot disagree with config.yaml
# or with validate.py about anything - the worst a wrong entry here can do is
# print "W" beside a number that is in seconds, which is visible to the first
# person who reads the screen.
#
# It is NOT a schema, and it must never become one: a unit string here that
# named a bound - "h (0-23)", say - would be a second copy of a range that
# config.yaml already states and the panel already renders from the schema. One
# such entry was written and removed; test_selfconfig.py part 5 is what removed
# it, by asserting that no unit string contains a digit.
#
# Anything the map does not know about gets no unit rather than a guessed one.
# --------------------------------------------------------------------------- #
_UNITS = (
("_eur_kwh", "€/kWh"),
("_eur", "€"),
("_soc_floor", "%"),
("_soc_target", "%"),
("_soc_ceiling", "%"),
("_days", "days"),
("_min", "min"),
("_hour", "h"),
("_horizon_h", "h"),
("_cycles", "cycles"),
("_phases", "phases"),
("_w", "W"),
("_s", "s"),
)
def unit_of(key):
for suffix, unit in _UNITS:
if key.endswith(suffix):
return unit
return ""
# --------------------------------------------------------------------------- #
# 3. THE MODEL
# --------------------------------------------------------------------------- #
def _one_field(node, translations, lang, path=()):
"""One entry of Supervisor's `schema_ui` -> one field of the model.
⚠️ THE TYPE, THE RANGE AND THE ENUM ARE COPIED, NOT DECIDED. `type`,
`lengthMin`/`lengthMax` (which is what `UiOptions` calls a NUMERIC range,
misleadingly, and measured rather than guessed - see
tools/supervisor_selfapi_probe.py section C) and `options` come straight off
the node. This function chooses no bound of its own, so a range edited in
config.yaml is a range the panel enforces on the very next page load.
"""
key = str(node.get("name") or "")
raw_label, label_lang = label_of(translations, lang, key, path)
group, label = split_group(raw_label)
text, help_lang = help_of(translations, lang, key, path)
field = {
"key": key,
"label": label,
"group": group,
"help": text,
"label_lang": label_lang,
"help_lang": help_lang,
"type": str(node.get("type") or "string"),
"required": bool(node.get("required")),
"multiple": bool(node.get("multiple")),
"unit": unit_of(key),
}
if node.get("lengthMin") is not None:
field["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
field["max"] = node["lengthMax"]
if node.get("options"):
field["options"] = list(node["options"])
if node.get("type") == "schema":
field["fields"] = [_one_field(sub, translations, lang, path + (key,))
for sub in node.get("schema") or ()]
return field
def field_model(schema, translations, lang="en"):
"""`info["schema"]` + `info["translations"]` -> the groups a page renders.
Returns `{"groups": [{"name", "fields": [...]}], "ungrouped": [key, ...],
"keys": [key, ...]}`.
⚠️ ORDER IS THE SCHEMA'S, AND SO IS GROUP ORDER. `App.schema_ui` renders
config.yaml's `schema:` block in file order (measured in
tools/supervisor_shape_probe.py section F), and a group takes its position
from the FIRST field that names it. So the panel's section order is
config.yaml's section order, and moving a block in that file moves it here -
which is the property that stops this model needing an order of its own.
"""
groups, order, ungrouped, keys = {}, [], [], []
for node in schema or ():
field = _one_field(node, translations, lang)
keys.append(field["key"])
name = field["group"]
if not name:
ungrouped.append(field["key"])
if name not in groups:
groups[name] = []
order.append(name)
groups[name].append(field)
return {
"groups": [{"name": name, "fields": groups[name]} for name in order],
"ungrouped": ungrouped,
"keys": keys,
}
# --------------------------------------------------------------------------- #
# 4. WHERE A REFUSAL GOES ON THE PAGE
#
# ⚠️ READ THIS BEFORE ADDING ANYTHING TO IT. The entire value of this card is
# that validate.py's cross-field sentences reach the operator BEFORE a save.
# Those sentences are written by validate.py and are shown BYTE FOR BYTE. This
# function decides one thing and one thing only: which field to print each
# sentence under. It does not shorten, re-word, classify by severity, or map a
# message to a rule number - every one of those would be a second, drifting
# opinion about a rule this module is not allowed to hold.
#
# THE MECHANISM IS NAME OCCURRENCE, and it knows no keys of its own: the caller
# passes the key list from the schema. A message that names `max_w` is filed
# under `max_w`. That is why validate.py's habit of naming the option it is
# refusing - which it already had, for the log - is what makes the anchoring
# work, and why nothing here breaks when a rule is added.
#
# ⚠️ WORD BOUNDARIES, AND THE COLLISION THEY EXIST FOR. `sign_disagree_w` is a
# substring of `unit_sign_disagree_w` and of `site_sign_disagree_w` - three
# distinct options, two of them SAFETY-30's. A plain `in` test files every
# per-unit refusal under the site key as well, which would put a message about
# unit-0 next to a field that has nothing to do with it. The boundary class
# excludes `_` on purpose so that the longer names do not match the shorter key.
#
# ⚠️ AND THE HONEST LIMIT, STATED RATHER THAN DISCOVERED: a refusal whose
# sentence never spells an option key cannot be anchored, and is shown in the
# page's top-level refusal block instead. Nothing is lost - it is the same
# sentence, on the same screen, before the same save - it simply is not beside
# a box. test_selfconfig.py part 4 MEASURES how many of the shipped rules
# anchor and prints the ones that do not, rather than this comment claiming a
# coverage figure.
# --------------------------------------------------------------------------- #
def problems_by_field(problems, keys):
"""(by_key, unanchored) for a list of validator sentences.
A sentence naming several keys is filed under EVERY one of them - a
cross-field rule is about all of its fields, and hiding it from one of them
would be this card's own defect in miniature.
"""
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
+ r"(?![A-Za-z0-9_])")) for key in keys]
by_key, unanchored = {}, []
for problem in problems:
hit = False
for key, pattern in patterns:
if pattern.search(problem):
by_key.setdefault(key, []).append(problem)
hit = True
if not hit:
unanchored.append(problem)
return by_key, unanchored
def changed_keys(left, right):
"""Sorted keys whose values differ between two option blobs.
Used for one thing: saying WHICH settings are stored but not yet running,
and which the other configuration surface moved underneath this one. It
compares values as they are, so a list reordered counts as changed - which
is correct for `inverters`, where order is the roster order.
"""
return sorted(k for k in set(left) | set(right)
if left.get(k) != right.get(k))
+84 -33
View File
@@ -264,36 +264,66 @@ DELIVERY_OPTION_DEFAULTS = {
# such family, so reading its numbers across would have understated this.
#
# Every run was executed a second time at a roster of ONE, on the same
# stimulus, as the control. Worst CONTINUOUS legitimate episode:
# stimulus, as the control.
#
# ⚠️ RE-RUN BY TEST-21 (2026-09-07) AND THE HEADLINE DID NOT REPRODUCE. The
# harness that produced the original table was never committed, which is
# PROC-01's shape; it is committed now, in the OTHER repository, as
# `admin/goodwe:sim/delivery_window.py` (it imports `plantsim` and
# `frame_check`, which that repo owns - restating them here would be CI-08's
# coupling, and CI-08 has broken three times). Re-derive it with:
#
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
# python3 -B delivery_window.py --selftest --campaign --blag
#
# Worst CONTINUOUS legitimate episode. Second block is the re-run:
#
# threshold reversals flapping weighted N=1 control (1890 runs)
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s } 2026-09-06
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s } harness
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s } NOT
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s } COMMITTED
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s } - see
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s } DECISIONS
#
# threshold reversals flapping weighted N=1 control (2640 runs)
# 100 W 120.0 s 0.0 s 165.0 s 0.0 s } TEST-21
# 250 W 65.0 s 0.0 s 100.0 s 0.0 s } 2026-09-07
# * 500 W 30.0 s 0.0 s 35.0 s 0.0 s } committed
# 700 W 25.0 s 0.0 s 30.0 s 0.0 s } harness,
# 1000 W 15.0 s 0.0 s 25.0 s 0.0 s } re-runnable
# 1500 W 0.0 s 0.0 s 20.0 s 0.0 s }
# 2000 W 0.0 s 0.0 s 0.0 s 0.0 s }
#
# * is the shipped threshold, `Tuning.saturation_w`.
#
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
# (a) The N=1 column is ZERO EVERYWHERE, in all 1890 runs at every
# threshold. The compatibility promise is measured, not argued.
# (b) The FLAPPING column is zero everywhere too - and that is the family
# that defeated SAFETY-30's site window (980 s at N=2, 1000 s at N=1, no
# window inside the billing quarter surviving it). It cannot touch this
# check, because a common stimulus makes BOTH units short at once and
# the subset stops being proper.
# (c) THE CURVE IS NOT MONOTONE - 55.0 s at 250 W against 40.0 s at 100 W -
# and that is a property of the reduction, not noise: a lower threshold
# puts MORE units into the short set, which can turn a proper subset
# into the whole roster and BREAK the run. Do not read it as a
# sensitivity curve.
# (a) The N=1 column is ZERO EVERYWHERE, at every threshold, IN BOTH
# CAMPAIGNS. The compatibility promise is measured, not argued, and it
# is the part that reproduced most cleanly.
# (b) The FLAPPING column is zero everywhere too, in both - and that is the
# family that defeated SAFETY-30's site window (980 s at N=2, 1000 s at
# N=1, no window inside the billing quarter surviving it). It cannot
# touch this check, because a common stimulus makes BOTH units short at
# once and the subset stops being proper.
# (c) THE ORIGINAL CURVE WAS NOT MONOTONE - 55.0 s at 250 W against 40.0 s
# at 100 W - and that was read as a property of the reduction: a lower
# threshold puts MORE units into the short set, which can turn a proper
# subset into the whole roster and BREAK the run. ⚠️ THE RE-RUN'S CURVE
# IS MONOTONE. The mechanism is real and is still the reason not to read
# either table as a sensitivity curve, but it is no longer something
# these numbers demonstrate - it is an argument now, not a measurement.
#
# THE BOUNDS THE VALUE ANSWERS TO:
#
# LOWER, from the rig - 30.0 s, the worst continuous legitimate episode at
# the shipped threshold across all 2640 runs.
# LOWER, from the rig - 35.0 s (TEST-21; the original record said 30.0 s
# and it did not reproduce), the worst continuous legitimate episode at
# the shipped threshold across all 2640 runs. ⚠️ THIS IS NOW STRICTLY THE
# BINDING LOWER BOUND: at 30.0 s it coincided with the firmware's veto
# below and a reader could not tell which was doing the work; at 35.0 s it
# is ABOVE it, and it is above the schema's own `int(30,900)` floor too -
# so an operator who sets `delivery_hold_s` to 30 is below a MEASURED
# legitimate episode. That is what "derived, do not change" is for.
#
# LOWER, from the firmware - `inverter.LINK_STALE_S`, 30 s. A unit not
# executing its share for less than the firmware's own veto window is a
@@ -302,26 +332,47 @@ DELIVERY_OPTION_DEFAULTS = {
# second, louder alarm about a state already being handled - the
# SAFETY-15/SAFETY-19 failure exactly.
#
# MARGIN, from INVERTER B NOT EXISTING, AND IT IS MEASURED RATHER THAN
# GUESSED. The campaign's B is A COPIED (`plantsim.INVERTER_B`,
# `measured=False`), so the 30.0 s above is two identical machines.
# Sweeping a DIFFERENT second unit through the weighted family: the worst
# legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 90.0 s at
# four times it. That is the sensitivity the margin has to carry until
# SPIKE-04 measures the real unit.
# MARGIN, from INVERTER B NOT EXISTING. ⚠️ THIS IS A SENSITIVITY STUDY ON
# A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, and the wording here used
# to say "MEASURED rather than GUESSED", which overstated it. B is not
# installed (~2026-09-12). The campaign's B is A COPIED
# (`plantsim.INVERTER_B`, `measured=False`), so the 35.0 s above is TWO
# IDENTICAL MACHINES and every row below is a what-if. NO ROW HERE IS B's
# LAG. Sweeping a DIFFERENT second unit through the weighted family
# (2026-09-06 -> TEST-21 re-run):
#
# B lag / ramp original re-run
# 10.0 s / 800 W/s 30.0 s 35.0 s (= A, both copies)
# 20.0 s / 800 W/s 45.0 s 45.0 s
# 10.0 s / 400 W/s 30.0 s 35.0 s
# 20.0 s / 400 W/s 45.0 s 50.0 s
# 40.0 s / 200 W/s 90.0 s 75.0 s (4x A's lag)
#
# The LAG moves it roughly linearly; the RAMP barely does. "The ramp does
# not move it AT ALL" was too strong - halving it is worth 0 s at A's lag
# and +5 s at twice it - so the ramp is second-order, not absent. That is
# the sensitivity the margin has to carry until SPIKE-04 measures the real
# unit.
#
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
# A site running short of one unit's share must be reported well inside
# one.
#
# 185 s: thirty-seven control cycles. 6.2x the worst legitimate episode this
# rig produces, 2.06x the worst a second unit FOUR TIMES slower than A
# produces, 6.2x the firmware's own veto window, and 4.9x inside the
# quarter. ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
# 185 s: thirty-seven control cycles. Against the TEST-21 re-run: 5.29x the
# worst legitimate episode this rig produces (35.0 s), 2.47x the worst a
# hypothetical second unit FOUR TIMES slower than A produces (75.0 s), 6.2x
# the firmware's own veto window (30 s), and 4.9x inside the quarter
# (900 s). ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
# is a different check on a different quantity, so a reader must not be
# able to think it was carried across - and not 150 or 200.
#
# ⚠️ 185 STANDS AND IS NOT CHANGED. The record claimed 6.2x the measured
# worst; the re-run makes it 5.29x. All four bounds still clear and none of
# them narrowly, and the ordering of the argument is unchanged. A shipped
# safety constant is not moved because a margin went from 6.2x to 5.3x while
# every bound holds - moving it would re-open SAFETY-41's four-attempt
# false-trip regime for nothing. What changed is the CLAIM, not the value.
#
# WHAT IT COSTS, stated rather than hidden: up to 185 s of a site running
# short by one unit's share before anybody is told. Against a fault that ran
# 6 h 25 m unnoticed, and against a guard that TAKES NO ACTION, this window
+30 -1
View File
@@ -528,7 +528,7 @@ def build_roster(opts: dict, store, sign_cfg=None,
soc_entity=opts.get("soc_entity", ""),
batt_entity=opts.get("batt_entity", ""),
batt_invert=bool(opts.get("batt_invert")),
max_w=float(opts.get("max_w", 2000)),
max_w=_flat_max_w(opts),
# 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
@@ -616,6 +616,35 @@ def _entry_bms_floor(entry: dict):
return None
def _flat_max_w(opts: dict) -> float:
"""The single-inverter shape's cap, tolerating what rule 9 tolerates.
⚠️ THIS USED TO BE A BARE `float(opts.get("max_w", 2000))` AND IT
DISAGREED WITH THE VALIDATOR. `validate.py`'s `_number()` reads `""` as
"use the default", so rule 9 looks at an empty `max_w` box, sees 2000 and
is content - while the bare `float("")` here raised ValueError from inside
`Controller.__init__`. Through UX-02's panel that surfaced as HTTP 500 on a
non-JSON body, which the page reported as "cannot reach the add-on": untrue,
and for the most prominent limit field's most likely transient state.
That inverted this module's own docstring rule - "keep the two in step: a
shape this tolerates that rule 9 does not name is a shape that runs" - in
the other direction: a shape rule 9 TOLERATED could not be composed at all.
The roster path never had the bug, because `_entry_max_w` below already
guards; only the flat path did.
Empty or absent is the default, exactly as `_number()` reads it.
Unparseable is 0.0, exactly as `_entry_max_w` reads it - and rule 9 refuses
it by name at line 790, with a sentence, before a control loop exists.
"""
raw = opts.get("max_w", 2000)
if raw is None or raw == "":
return 2000.0
try:
return float(raw)
except (TypeError, ValueError):
return 0.0
def _entry_capacity_kwh(entry: dict):
"""One entry's OWN usable capacity in kWh, or None when it stated none.
+2
View File
@@ -80,6 +80,8 @@ suppression window widens from `site target < 2 * min_w` = 1000 W to
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
hands the wedged unit a big share again and the alarm returns.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
is a disclosed residual, not a fixed thing. Closing it means making
+401
View File
@@ -0,0 +1,401 @@
"""UX-02: reading and writing THIS add-on's own options, through the Supervisor.
WHAT THIS IS. An add-on may read and write its own options. That is not a
guess: `supervisor/api/middleware/security.py` in
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` - the version this
site runs - matches an installed app's own token against
api_bypass = ^(?:|/addons/self/(?!security|update)[^/]+
|/addons/self/options/config|/info|/services.*
|/discovery.*|/auth)$
BEFORE it looks at `access_hassio_api` or at any role, so `GET
/addons/self/info`, `POST /addons/self/options` and `POST /addons/self/restart`
need no `hassio_role` at all. tools/supervisor_selfapi_probe.py RUNS those
compiled patterns against these exact paths rather than re-reading the regex,
because a bypass list is precisely the kind of thing that is read optimistically.
⚠️ AND THE PROBE FOUND ONE THE CARD DID NOT PREDICT, WHICH IS WHY IT WAS RUN.
`/addons/self/options/validate` - Supervisor's OWN options validator - has TWO
path segments after `self`, so `[^/]+` does not match it and it is not in the
list of literals either. An add-on cannot call it. That is not a problem for
this card, because the Supervisor's validator only knows the schema (types and
ranges) and this panel's whole reason to exist is the CROSS-FIELD rules it
structurally cannot express - but it does mean there is exactly one validator
reachable from here, `app/validate.py`, which is the one we want.
--- THE THREE THINGS THIS MODULE REFUSES TO DO ------------------------------
1. ⚠️ IT NEVER WRITES A BLOB THE SHIPPED VALIDATOR REFUSED. Not "warns and
writes"; does not write. The reason is specific to an INGRESS panel and it
is the failure mode that makes this card dangerous: an invalid blob plus a
restart is exit 78, and an add-on that is not running is not serving the
panel either. The operator would be left with no way back except the
Supervisor UI - the surface this card exists because it is hard to read.
So the refusal happens here, before the POST, and the edits stay on screen.
2. ⚠️ IT NEVER OVERWRITES A BLOB THAT MOVED UNDERNEATH IT. The Supervisor's
Configuration tab CANNOT be removed (see DECISIONS/UX-02-two-config-
surfaces.md), so there are two editors of one blob, and `POST
/addons/self/options` REPLACES the stored user options - measured, not
assumed: `App.options`'s setter is `self.persist[ATTR_OPTIONS] = deepcopy(
value)`, and the effective options are `deepmerge(config defaults, persist)`.
A key omitted from the POST therefore falls back to config.yaml's DEFAULT
rather than to its previous value.
This is the same shape of hazard as two writers on one actuator, and it gets
the same discipline. `Controller.write_refusal()` (SAFETY-28) says: A NUMBER
REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS TICK, FOR
THIS UNIT. Here: A BLOB REACHES THE SUPERVISOR ONLY IF IT WAS DERIVED FROM
THE BLOB THAT IS STORED RIGHT NOW. The read hands out a `fingerprint()`, the
save hands it back, and the save re-reads and compares before writing. A
change made in the other tab in the meantime is a REFUSAL with the changed
keys named, not a silent clobber.
⚠️ THE COMPARISON IS RE-READ IMMEDIATELY BEFORE THE WRITE, not taken from
the page load, so the window in which the other surface can slip in is one
HTTP round trip rather than however long the operator spent typing.
3. ⚠️ IT NEVER RESTARTS AS A SIDE EFFECT OF SAVING. Applying options costs a
restart, and `Controller.shutdown()` commands 0 W on every inverter on the
way out (main.py, "shutting down - commanding 0 W on every inverter"). That
is a real, if brief, loss of control of the battery, so it is its own
deliberate action with its own confirmation - `restart()` below requires an
explicit token in the request body, so no stray POST can spend it.
--- WHAT THIS MODULE MAY NOT GROW INTO --------------------------------------
⚠️ NOTHING HERE COMMANDS AN INVERTER, CLEARS A LATCH, OR REACHES A SAFETY
MECHANISM. The panel's configuration half talks to the Supervisor about a JSON
blob and to `validate.py` about whether that blob is startable; the only
actuator-shaped verb in the file is `restart()`, which restarts THIS ADD-ON and
whose 0 W is the existing, documented wind-down. Adding "and also set the
setpoint while we are here" would put a second writer on the actuator through
the config surface, which is the non-goal this add-on is built around.
"""
import asyncio
import hashlib
import json
import logging
import os
import aiohttp
from .configui import changed_keys
from .inverter import build_roster
from .validate import validate_startup_config, validate_unit_ranges
_LOG = logging.getLogger("goodwe.selfconfig")
SUPERVISOR_API = "http://supervisor"
SELF_INFO = f"{SUPERVISOR_API}/addons/self/info"
SELF_OPTIONS = f"{SUPERVISOR_API}/addons/self/options"
SELF_RESTART = f"{SUPERVISOR_API}/addons/self/restart"
# The word the page has to send with a restart request. It is not a password -
# ingress means Home Assistant has already authenticated the caller - it is an
# INTENT token, so that a reload, a retry or a stray fetch cannot spend a
# control interruption on the operator's behalf.
RESTART_CONFIRM = "restart"
# ⚠️ LONGER THAN hass.py's 10 s, AND FOR THE OPPOSITE REASON. `hass.py` would
# rather abandon a write and re-issue it on the next tick than hold the control
# loop. Nothing here is on the control loop - these calls are made by an
# operator standing at a screen - and a Supervisor busy with a backup can take
# a while to answer. Abandoning a `POST /addons/self/options` early would leave
# the panel unsure whether the blob was stored, which is the one state this
# module must never be in.
TIMEOUT_S = 30.0
class SupervisorUnavailable(Exception):
"""The Supervisor could not be reached, or answered with an error."""
class NotConfirmed(Exception):
"""A control interruption was asked for without the intent token.
Its own exception rather than a `SaveRefused`, because it is not a refusal
ABOUT a configuration - nothing was validated and nothing was compared. It
means the request did not say what it was asking for.
"""
class SaveRefused(Exception):
"""The save did not happen, and NOTHING WAS WRITTEN.
`kind` is `"invalid"` (the shipped validator refused the blob) or `"stale"`
(the stored blob changed underneath). `problems` carries the validator's
own sentences, byte for byte; `changed` names the keys that moved.
"""
def __init__(self, kind, problems=(), changed=(), stored=None):
self.kind = kind
self.problems = list(problems)
self.changed = list(changed)
self.stored = stored
super().__init__(f"save refused ({kind})")
def canonical(options) -> str:
"""The one textual form of an options blob, for identity only.
Sorted keys, no insignificant whitespace, `ensure_ascii=False` so an entity
id with a non-ASCII character is one character rather than an escape - the
string is hashed, never displayed, so the only property that matters is
that equal blobs produce equal text and different blobs do not.
"""
return json.dumps(options, sort_keys=True, separators=(",", ":"),
ensure_ascii=False, default=str)
def fingerprint(options) -> str:
"""The compare-and-swap token for one options blob.
⚠️ A DIGEST, NOT A VERSION NUMBER, because there is no version to read. The
Supervisor exposes no revision or ETag for an add-on's options, so the only
thing the panel can hold onto between reading and writing is the CONTENT it
read. That is enough for what this is for: the question is not "how many
times has this changed" but "is what I am about to overwrite still what I
was shown". Identical content is by definition safe to overwrite.
"""
return hashlib.sha256(canonical(options).encode("utf-8")).hexdigest()
class SelfConfig:
"""The add-on's own options, read and written through the Supervisor."""
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
self.session = session
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
@property
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"}
async def _call(self, method, url, payload=None):
try:
async with self.session.request(
method, url, headers=self._headers, json=payload,
timeout=aiohttp.ClientTimeout(total=TIMEOUT_S),
) as resp:
text = await resp.text()
if resp.status >= 400:
# The Supervisor's own message is carried through unedited:
# `{"result": "error", "message": "..."}`. A rewritten
# error is a second opinion about somebody else's failure.
try:
detail = (json.loads(text) or {}).get("message") or text
except ValueError:
detail = text
raise SupervisorUnavailable(
f"{method} {url} -> HTTP {resp.status}: "
f"{str(detail)[:400]}")
try:
body = json.loads(text) if text else {}
except ValueError as err:
raise SupervisorUnavailable(
f"{method} {url} -> unreadable answer: {err}") from None
return body.get("data", body)
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
raise SupervisorUnavailable(f"{method} {url} -> {err}") from None
async def info(self) -> dict:
"""`GET /addons/self/info`.
⚠️ THREE OF THIS PANEL'S FOUR INGREDIENTS COME OUT OF THIS ONE CALL, and
that is the reason the panel owns no copy of any of them. Measured on
2026.08.0 (`supervisor/api/apps.py:info_data`):
`options` - `App.options`, i.e. deepmerge(config.yaml defaults,
the stored user options). Every key is present, which
is what makes a whole-blob read-modify-write safe.
`schema` - `App.schema_ui`, i.e. `UiOptions(...)(config.yaml
["schema"])`. The field list, types, ranges and
enums, in config.yaml's own order.
`translations` - `App.translations`, i.e. Supervisor's own
`_read_app_translations()` over translations/*.yaml,
which is UX-01's work reaching a second surface for
free.
The fourth ingredient is `app/validate.py`, and it is not fetched.
"""
return await self._call("GET", SELF_INFO)
async def write_options(self, options: dict) -> None:
"""`POST /addons/self/options` - REPLACES the stored user options.
⚠️ CALL THIS ONLY THROUGH `save()`. On its own it is the unguarded
write: no validation, no compare-and-swap. It is a separate method so
that the gate above it can be read - and tested - as a gate.
"""
await self._call("POST", SELF_OPTIONS, {"options": options})
async def restart(self, confirm: str) -> None:
"""`POST /addons/self/restart`. Costs a control interruption.
The add-on's own wind-down runs first: `Controller.shutdown()` commands
0 W on every inverter before the process exits. So this is not an
abrupt loss of control, it is a deliberate one - but it IS one, and the
page says so before the operator presses it.
"""
if confirm != RESTART_CONFIRM:
raise NotConfirmed(
"a restart interrupts control and must be confirmed")
_LOG.warning("operator asked for a restart from the ingress panel - "
"the wind-down commands 0 W on every inverter first")
await self._call("POST", SELF_RESTART)
async def problems_for(opts: dict, hass=None) -> list:
"""Every reason the SHIPPED validator would refuse to start on `opts`.
⚠️ THIS IS THE STARTUP GATE'S OWN COMPOSITION, NOT A SUBSET OF IT. The
gate runs in TWO places in main.py and this is the union of both, which is
what an operator meets:
* `amain()` calls `validate_startup_config(opts, dev_min, dev_max)`
UNCONDITIONALLY, before anything starts - so this does too, with the
flat `setpoint_entity`'s device range when that entity was readable and
None when it was not;
* `run_control()` adds `validate_unit_ranges(roster, skip_entity=flat)`
once every unit's own ESPHome has answered - so this does too, with the
same `skip_entity`, so one inverter is not reported twice under two
wordings.
A panel that ran only half the gate would go green on a config that then
exits 78 - which is worse than no panel, because the operator would have
been told it was fine.
⚠️ AND THE DEVICE RANGES ARE READ, NOT INVENTED. `hass.limits()` is a READ
of a Home Assistant state; nothing here writes anything to any device. When
a device does not answer - ESPHome asleep, an entity that does not exist
yet, or `hass=None` in a check - the range is None and the range rules are
SKIPPED, exactly as they are at startup. That is the honest behaviour and it
is also the conservative one: the panel then shows fewer refusals than the
startup gate might, never more, so it cannot bless a config the gate refuses
on a rating it could not see. It is stated on the page rather than hidden.
`build_roster(opts, None)` builds the roster with NO Store, so this reads
no latch and writes no state; it exists to give `validate_unit_ranges()`
the same objects `main.py` gives it, rather than a second transcription of
what counts as a roster entry.
"""
problems, _unread = await problems_and_ranges(opts, hass)
return problems
async def problems_and_ranges(opts: dict, hass=None):
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
OPERATOR CANNOT SEE. Two rules compare a configured ceiling against the
device's own rating, and when the device is silent they are skipped rather
than guessed at - so an unread range means the panel is showing FEWER
refusals than the startup gate might, and it has to say so, naming the
entity that did not answer rather than hinting at one.
It is a separate function from `problems_for()` because the answer is about
the CONFIGURATION UNDER TEST, not about the running roster: an operator who
has just typed a new `setpoint_entity` must be told about THAT entity, and
a route that asked the running controller instead would report on the old
one. That was the first shape of this and it disagreed with itself.
"""
flat = str(opts.get("setpoint_entity", "") or "").strip()
# ⚠️ THE ROSTER IS BUILT BEFORE THE VALIDATOR RUNS, AND IT PARSES NUMBERS.
# `build_roster` reaches `inverter.py`'s `float(opts.get("max_w", 2000))`,
# which raises on a value the validator has a SENTENCE for. Cleared the
# `max_w` box and the panel answered HTTP 500 - not JSON, so the page said
# "cannot reach the add-on", which was untrue - while `validate.py`'s
# "max_w ... is not a number" was unreachable through the panel and reachable
# at startup, where `main.py` validates before it composes. That made this
# surface WEAKER than the startup gate for one field, which is the opposite
# of what this module claims below.
#
# A malformed scalar therefore falls back to validating WITHOUT device
# bounds. Those bounds come from the roster, and there is no roster; the
# validator's own complaint about the unparseable value is the answer the
# operator needs, and it is strictly more informative than a 500.
try:
units = build_roster(opts, None)
except (TypeError, ValueError) as exc:
# ⚠️ AND IT MUST NOT RETURN AN EMPTY LIST. An empty list means "no
# problems", and the page enables Save on it. If the roster could not be
# composed at all and the validator has nothing to say about why, that
# disagreement is itself the finding - so it is reported rather than
# swallowed. Returning `[]` here would turn a crash into a silent
# acceptance, which is worse than the 500 this replaced.
found = list(validate_startup_config(opts))
if not found:
found = [f"the configuration could not be composed ({type(exc).__name__}: "
f"{exc}), and the validator did not name a reason. This is a "
f"disagreement between the two and should be reported."]
return found, []
if hass is not None:
async def _limits(unit):
if unit.setpoint_entity:
unit.dev_min, unit.dev_max, _ = await hass.limits(
unit.setpoint_entity)
await asyncio.gather(*(_limits(unit) for unit in units))
dev_min = dev_max = None
for unit in units:
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
dev_min, dev_max = unit.dev_min, unit.dev_max
break
problems = list(validate_startup_config(opts, dev_min, dev_max))
problems += validate_unit_ranges(units, skip_entity=flat)
unread = [unit.setpoint_entity for unit in units
if unit.setpoint_entity and unit.dev_min is None
and unit.dev_max is None]
return problems, unread
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
hass=None) -> dict:
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
with nothing written otherwise.
THE ORDER IS DELIBERATE AND BOTH HALVES ARE PRE-WRITE GATES:
1. VALIDATE FIRST. It is local, it costs nothing, and its refusal is
about what is on the operator's screen - so it is the answer they can
act on without knowing anything about the other surface.
2. RE-READ AND COMPARE SECOND, immediately before the POST, so the
window in which the Supervisor tab can slip a change past is one round
trip rather than the length of an editing session.
3. Only then write.
⚠️ `expect` IS REQUIRED AND THERE IS NO "FORCE" DOOR. A caller that has not
read the current blob has nothing to compare, and a save that skips the
comparison is the clobber this gate exists to prevent. If the operator
genuinely wants what the other surface wrote replaced, the page reloads,
shows them what changed, and they save again from THAT - which is a
decision made in front of the difference rather than through it.
"""
problems = await problems_for(proposed, hass)
if problems:
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
"nothing written. First: %s", len(problems), problems[0])
raise SaveRefused("invalid", problems=problems)
info = await selfcfg.info()
stored = info.get("options") or {}
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
_LOG.warning("panel REFUSED to save: the stored options changed "
"underneath this page (keys: %s); nothing written",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
await selfcfg.write_options(proposed)
_LOG.info("options saved from the ingress panel; a restart is needed "
"before they take effect")
return {"fingerprint": fingerprint(proposed)}
+186 -3
View File
@@ -8,12 +8,33 @@ perfectly on a developer's laptop.
Design bias: this page must be readable at the end of a long day, on a phone, in
a cellar, by someone who did not write it. Numbers alone are not enough - it
says what is wrong in words.
⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S.
`/` is the status and control surface and is unchanged. `/config` is the
configuration surface: its markup lives in app/config_page.py, its field model
in app/configui.py and its Supervisor conversation in app/selfconfig.py. What
stays HERE is the routing, so that the question a reviewer has to answer - does
anything the panel exposes reach the inverter? - is answered by reading one
short file.
⚠️ THE ANSWER TO THAT QUESTION, STATED SO IT CAN BE CHECKED: the configuration
routes below read `GET /addons/self/info`, run the SHIPPED validator, write
`POST /addons/self/options` and, on an explicitly confirmed request, `POST
/addons/self/restart`. NONE of them writes a setpoint, clears a latch, changes a
mode or reaches `controller.handle_action()`. The set of things an operator can
do to the inverter through this panel is exactly what `/action` offered before
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
actions and comparing them with the shipped set.
"""
import logging
from aiohttp import web
from . import selfconfig
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
_LOG = logging.getLogger("goodwe.web")
PAGE = """<!doctype html>
@@ -49,6 +70,7 @@ PAGE = """<!doctype html>
.muted{color:var(--muted);font-size:13px}
</style></head><body>
<h1>GoodWe RS485 Controller</h1>
<p class="muted"><a href="config">Configuration &rarr;</a></p>
<div id="banner" class="banner b-warn">loading…</div>
<div class="card">
@@ -95,7 +117,33 @@ refresh(); setInterval(refresh, 3000);
"""
def build_app(controller) -> web.Application:
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
# validate.py's rules compare a configured ceiling against the setpoint
# device's OWN reported range, and that range is only knowable by asking Home
# Assistant. When ESPHome is asleep or the entity does not exist yet the
# startup gate SKIPS those two rules rather than guessing - so the panel skips
# them too, and says so, because a green page that is green for a reason the
# operator cannot see is the exact failure this add-on keeps refusing to ship.
DEVICE_NOTE = (
"%s did not report a range, so the two checks that compare a configured "
"ceiling against what the device itself accepts were skipped - here and at "
"startup, identically. Everything else was checked. If the device answers "
"later and disagrees, the add-on refuses to start then, with the same "
"message you would have seen here.")
def build_app(controller, selfcfg=None) -> web.Application:
"""The ingress app. `selfcfg` is injectable so a check can drive it.
⚠️ IT IS BUILT FROM THE CONTROLLER'S OWN SESSION AND TOKEN, not from a
second `ClientSession`. `amain()` owns one session for the process and every
Home Assistant call already goes through it; a second one here would be a
second connection pool whose failures nothing reports.
"""
if selfcfg is None:
selfcfg = selfconfig.SelfConfig(controller.hass.session,
controller.hass.token)
async def index(_request):
return web.Response(text=PAGE, content_type="text/html")
@@ -113,17 +161,152 @@ def build_app(controller) -> web.Application:
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
return web.json_response({"ok": True, "result": result})
# --- UX-02: the configuration surface ---------------------------------
async def config_index(_request):
return web.Response(text=CONFIG_PAGE, content_type="text/html")
async def _proposed(request):
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
options = body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
return body, options
async def config_model(request):
"""Everything the page needs to draw itself, from ONE Supervisor call.
⚠️ `running_differs` IS NOT COSMETIC. `controller.o` is the blob this
PROCESS started with (`/data/options.json`); `info["options"]` is what
the Supervisor has STORED and would apply at the next start. They differ
exactly when somebody has saved and not restarted - here, or in the
Configuration tab. Printing the difference is the only way an operator
finds out that the page they are reading is not describing the running
system, and it is also how an edit made on the other surface becomes
visible on this one.
"""
try:
info = await selfcfg.info()
except selfconfig.SupervisorUnavailable as err:
_LOG.error("cannot read the add-on's own options: %s", err)
return web.json_response({"error": str(err)}, status=502)
options = info.get("options") or {}
lang = str(request.query.get("lang", "en") or "en")[:8]
model = field_model(info.get("schema") or [],
info.get("translations") or {}, lang)
model.update({
"options": options,
"fingerprint": selfconfig.fingerprint(options),
"running": controller.o,
"running_differs": changed_keys(controller.o, options),
"lang": lang,
})
return web.json_response(model)
async def config_validate(request):
"""The SHIPPED validator, run against a proposed blob. Writes nothing.
⚠️ IT READS HOME ASSISTANT AND WRITES NOTHING ANYWHERE. The two
device-range rules need `hass.limits()`, which is a GET of an entity's
state. The page debounces, so a burst of typing is one call after the
typing stops rather than one per keystroke.
⚠️ THE STRINGS ARE PASSED THROUGH UNTOUCHED. Not summarised, not
renumbered, not mapped to a severity. validate.py's sentences are the
product this card is delivering; anything this layer did to them would
be a second opinion about a rule it does not own.
"""
_body, options = await _proposed(request)
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass)
by_key, unanchored = problems_by_field(problems, _keys_of(options))
payload = {"problems": problems, "by_key": by_key,
"unanchored": unanchored}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
return web.json_response(payload)
def _keys_of(options):
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
# sound only because the page posts the WHOLE blob it read - which is
# `App.options`, the merge of config.yaml's defaults with the stored
# ones, so every option key is in it. Nothing here types a key name;
# see configui.problems_by_field() for what is done with them.
return sorted(options)
async def config_save(request):
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
body, options = await _proposed(request)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
hass=controller.hass)
except selfconfig.SaveRefused as refused:
if refused.kind == "stale":
stored = refused.stored or {}
return web.json_response(
{"kind": "stale", "changed": refused.changed,
"stored": stored,
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
# is not a force door: it takes a second, deliberate press
# made in front of what changed.
"fingerprint": selfconfig.fingerprint(stored)},
status=409)
by_key, unanchored = problems_by_field(refused.problems,
_keys_of(options))
return web.json_response(
{"kind": "invalid", "problems": refused.problems,
"by_key": by_key, "unanchored": unanchored}, status=422)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts")
return web.json_response({"ok": True, **result})
async def config_restart(request):
"""Restart the add-on, at the operator's explicit request.
⚠️ THIS IS THE ONE ROUTE ON THIS SURFACE THAT COSTS ANYTHING. The
wind-down commands 0 W on every inverter (`Controller.shutdown()`), so
the request must carry the intent token; a reload, a retry or a stray
POST cannot spend a control interruption. It restarts THIS ADD-ON and
nothing else: no setpoint is written from here, and a latched failsafe
is restored from the Store on the way back up rather than cleared.
"""
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
try:
await selfcfg.restart(str(body.get("confirm") or ""))
except selfconfig.NotConfirmed as err:
return web.json_response({"error": str(err)}, status=400)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("restart request failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("restart requested from the configuration panel")
return web.json_response({"ok": True})
app = web.Application()
app.add_routes([
web.get("/", index),
web.get("/status", status),
web.post("/action", action),
web.get("/config", config_index),
web.get("/config/model", config_model),
web.post("/config/validate", config_validate),
web.post("/config/save", config_save),
web.post("/config/restart", config_restart),
])
return app
async def start(controller, port: int = 8099):
runner = web.AppRunner(build_app(controller))
async def start(controller, port: int = 8099, selfcfg=None):
runner = web.AppRunner(build_app(controller, selfcfg))
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
+25 -11
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.8.0"
version: "0.9.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -560,17 +560,31 @@ options:
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
# whole range this schema accepts - across load reversals, a flapping load and
# ASYMMETRIC SPLITS, plus 1890 runs at a roster of ONE as the control. At the
# shipped threshold the worst CONTINUOUS legitimate episode is 30.0 s (the
# weighted family; 20.0 s on reversals, 0.0 s on flapping, and 0.0 s at every
# threshold in all 1890 one-inverter runs). 185 s is 6.2x that.
# ASYMMETRIC SPLITS, plus a roster-of-ONE arm as the control. At the shipped
# threshold the worst CONTINUOUS legitimate episode is 35.0 s (the weighted
# family; 30.0 s on reversals, 0.0 s on flapping, and 0.0 s at every threshold
# in every one-inverter run). 185 s is 5.29x that.
#
# ⚠️ AND THE MARGIN IS SIZED ON A MEASUREMENT, NOT ON A FEELING, BECAUSE
# INVERTER B DOES NOT PHYSICALLY EXIST. The rig's B is A copied. Sweeping a
# DIFFERENT second unit through that campaign: the worst legitimate episode is
# insensitive to B's ramp (30.0 s at half A's ramp) and scales with its LAG -
# 45.0 s at twice A's measured 10 s lag, 90.0 s at four times it. 185 s is
# 2.06x even the four-times-slower case.
# ⚠️ AND THE CAMPAIGN IS RE-RUNNABLE NOW, WHICH IT WAS NOT WHEN THIS NUMBER
# SHIPPED. TEST-21 (2026-09-07) committed the harness to the OTHER repository,
# `admin/goodwe:sim/delivery_window.py` - it imports plantsim and frame_check,
# which that repo owns. From clean checkouts of both, side by side:
#
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
# python3 -B delivery_window.py --selftest --campaign --blag
#
# ⚠️ THE ORIGINAL RECORD SAID 30.0 s AND IT DID NOT REPRODUCE - the re-run
# says 35.0 s. 185 s still clears all four bounds and is unchanged; what
# changed is the claim. DECISIONS/SAFETY-42-unit-delivery.md §3.3 carries both
# tables and what did and did not reproduce.
#
# ⚠️ AND THE MARGIN IS A SENSITIVITY STUDY ON A HYPOTHESIS, NOT A MEASUREMENT,
# BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). The rig's B is A
# COPIED. Sweeping a DIFFERENT second unit through that campaign: the worst
# legitimate episode barely moves with B's ramp (35.0 s at half A's ramp) and
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 75.0 s at four
# times it. 185 s is 2.47x even the four-times-slower case. NO NUMBER HERE IS
# A MEASUREMENT OF INVERTER B.
#
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
# and not 150 or 200. 37 control cycles.
+372
View File
@@ -0,0 +1,372 @@
"""UX-04 - the defaults pin UX-01 AC 4 promised and never built.
`python3 -B test_defaults_pin.py`
WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
`options:` has exactly one defaults-table entry ... the table changed goes red
... and NO MODULE CARRIES ITS OWN LITERAL FALLBACK FOR A SCHEMA DEFAULT - an AST
check, not a grep."
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
71 keys, so "every schema key absent from options" quantifies over the empty set
and is true of any tree, including one with no defaults at all. Clause two -
"the table changed goes red" - does work, six times over, once per mirror in its
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
literal scan in this repository before this file is
`test_validate.py`'s `re.findall(r'"(maintenance_[a-z_]+)"...')`, which is a
grep, is restricted to eight `maintenance_*` keys and three named files, and
predates UX-01.
⚠️ AND A CHECK THAT CANNOT GO RED IS NOT A CHECK, so this file does not repeat
clause one's mistake. Every part below states the size of the set it quantifies
over and grades that size against a floor derived from the tree, and part 5
plants a literal in a synthetic module and REQUIRES the scanner to catch it, so
the red direction is exercised on every run rather than attested in a commit
message. MUTATION.md habit 4.
--------------------------------------------------------------------------- #
WHAT AN "IN-CODE LITERAL FALLBACK" IS, STRUCTURALLY, so a reader can argue with
the definition rather than guess it. It is a call in `app/` that takes a STRING
LITERAL naming a `schema:` key and, in the next positional slot, a CONSTANT:
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
Two shapes, one rule, found by walking `ast.Call` nodes - not by matching text,
so `opts.get(\n "max_w",\n 2000,\n)` and a renamed `opts` are found too and
a `max_w` inside a comment or a docstring is not.
TWO EXCLUSIONS, both structural and both narrow:
* `getattr(obj, "max_w", 0.0)` reads an ATTRIBUTE off an object, not an option
off the operator's blob. Its third argument is an absent-attribute guard and
has no relationship to config.yaml. Excluded by function name.
* THE EMPTY STRING. `opts.get("static_profile", "")` is Python's "this key is
not stated", and every one of the six sites in `validate.py` immediately
normalises it - `str(opts.get(k, "") or "").strip()`. It cannot be a stale
copy of a NUMBER, which is the whole of the CFG-04 defect class, and
rewriting those six to index a mirror would replace a sentence that reads
"absent" with one that reads "the shipped default" at sites where absent is
what is meant. Excluded by value. `0` is NOT excluded: `slew_w: 0` and
`integrator_max_w: 0` are real shipped defaults with real meanings.
--------------------------------------------------------------------------- #
WHAT THIS FILE DOES *NOT* DO, said before the parts rather than after them.
It does not remove the 27 (module, key) pairs in part 4's ledger. That is
CFG-03's open ticket - option defaults living in four places - and it is a body
of behaviour change across `main.py`, `p1.py`, `inverter.py` and `validate.py`
that no single card should make in passing. What this file does instead is
FREEZE the ledger: the set is graded EXACTLY, so the twenty-eighth pair is red
on the commit that introduces it and the ledger can only ever shrink. A backlog
that cannot grow is a backlog being paid down; one that is merely written in a
comment is the state UX-01 left.
MUTATION.md is the method for the red-direction evidence in part 5, and
`tools/mutation_run.sh` is the harness.
"""
import ast
import io
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
APP = HERE / "app"
FAILED = 0
def check(what, ok):
global FAILED
print((" ok " if ok else " FAIL ") + what)
if not ok:
FAILED += 1
# --------------------------------------------------------------------------- #
# config.yaml, parsed by hand.
#
# ⚠️ NO PyYAML, AND NOT AS A PREFERENCE. The add-on image installs py3-aiohttp
# and py3-paho-mqtt only; `import yaml` raises in the checks container and a
# check that skips on ImportError is not a check. `test_maintenance.py` and
# `test_price.py` each carry their own two-line reader for the same reason, and
# this is the third - which is itself an instance of the defect this file
# grades, one level up. It is written down rather than tidied away because
# tidying it into a shared helper is a change to two suites that are green.
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
_SCHEMA_AT = CFG_TEXT.index("\nschema:")
_OPTS_BLOCK, _SCHEMA_BLOCK = CFG_TEXT[:_SCHEMA_AT], CFG_TEXT[_SCHEMA_AT:]
def _scalar(raw):
"""One `options:` value, as the yaml module this image lacks would read it."""
raw = raw.split("#", 1)[0].strip().strip('"').strip("'")
if raw in ("true", "false"):
return raw == "true"
if raw == "[]":
return []
for cast in (int, float):
try:
return cast(raw)
except ValueError:
pass
return raw
def _block_keys(block):
return [m.group(1) for m in re.finditer(r"^ ([a-z0-9_]+):", block, re.M)]
SHIPPED = {m.group(1): _scalar(m.group(2))
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", _OPTS_BLOCK, re.M)}
SCHEMA = set(_block_keys(_SCHEMA_BLOCK))
print("UX-04 - the defaults pin\n")
print("1. the surface this file quantifies over")
print(f" config.yaml: {len(SHIPPED)} keys under `options:`, {len(SCHEMA)} "
f"under `schema:`")
# The floor, for part 1's own reason: a reader that returned {} would make every
# check below vacuously true. 60 is the count of REQUIRED schema keys and is the
# number `tools/supervisor_shape_probe.py` section F now grades; any floor well
# inside the real size does the job, and this one cannot be reached by accident.
check(f"the hand reader found a real options block ({len(SHIPPED)} keys, floor 60)",
len(SHIPPED) >= 60)
check(f"...and a real schema block ({len(SCHEMA)} keys, floor 60)",
len(SCHEMA) >= 60)
check("every shipped default names a schema key, so a mirror pinned to one is "
"pinned to the other",
sorted(set(SHIPPED) - SCHEMA) == [])
# --------------------------------------------------------------------------- #
print()
print("2. the mirrors, DISCOVERED from the tree rather than listed here")
# ⚠️ WHY DISCOVERED. Six suites already pin their own mirror to config.yaml -
# test_maintenance, test_price, test_watchdog (twice), test_peakguard,
# test_delivery. Every one of those pins names its table by hand, so a SEVENTH
# table added tomorrow is pinned by nobody and nothing says so. This walks
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
# grades whatever it finds, so the seventh is covered on the commit that
# introduces it. That is the property the six hand-written pins cannot have.
MODULES = sorted(p.name for p in APP.glob("*.py"))
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
for name in MODULES}
MIRROR = {} # schema key -> (module, table name, value)
TABLES = []
for name in MODULES:
for node in TREES[name].body:
if not (isinstance(node, ast.Assign) and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id.endswith("OPTION_DEFAULTS")):
continue
table = node.targets[0].id
TABLES.append((name, table))
for k, v in zip(node.value.keys, node.value.values):
# A literal key with a literal value is the only shape a mirror
# has; anything else is not a table this file can grade, and
# saying so out loud beats skipping it silently.
MIRROR[k.value] = (name, table, ast.literal_eval(v))
print(f" {len(TABLES)} table(s): " + ", ".join(f"{m}:{t}" for m, t in TABLES))
print(f" {len(MIRROR)} mirrored key(s) of {len(SCHEMA)} in `schema:`")
check(f"the walk found the mirrors that exist ({len(TABLES)} tables, floor 6)",
len(TABLES) >= 6)
_unschemad = sorted(k for k in MIRROR if k not in SCHEMA)
check(f"every mirrored key is a real schema key {_unschemad or ''}",
_unschemad == [])
_unshipped = sorted(k for k in MIRROR if k not in SHIPPED)
check(f"...and is shipped in the options block {_unshipped or ''}",
_unshipped == [])
_drift = sorted(k for k, (m, t, v) in MIRROR.items()
if k in SHIPPED and v != SHIPPED[k])
check(f"...and reproduces config.yaml exactly, so ANY mirror - including one no "
f"suite names by hand - goes red on drift {_drift or ''}",
_drift == [])
# --------------------------------------------------------------------------- #
print()
print("3. the AST scan: no module carries its own literal fallback for a "
"MIRRORED schema default")
def literal_fallbacks(tree, name):
"""Every `f(..., "<schema key>", <constant>, ...)` in one module.
Yields (module, lineno, callee, key, value). See the header for the two
exclusions and the argument for each.
"""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn = node.func
callee = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", "")
if callee == "getattr":
continue
for i, arg in enumerate(node.args[:-1]):
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)
and arg.value in SCHEMA):
continue
default = node.args[i + 1]
if not isinstance(default, ast.Constant):
continue
if default.value is None or default.value == "":
continue
yield (name, node.lineno, callee, arg.value, default.value)
ALL_SITES = [s for name in MODULES for s in literal_fallbacks(TREES[name], name)]
MIRRORED_SITES = [s for s in ALL_SITES if s[3] in MIRROR]
UNMIRRORED_SITES = [s for s in ALL_SITES if s[3] not in MIRROR]
print(f" scanned {len(MODULES)} modules, {len(ALL_SITES)} literal fallback "
f"site(s): {len(MIRRORED_SITES)} over mirrored keys, "
f"{len(UNMIRRORED_SITES)} over unmirrored ones")
# ⚠️ THE SCANNER'S OWN FLOOR, and it is the line that stops this whole file
# rotting into clause one. A scanner that silently stopped matching - a renamed
# helper, an ast API change, a `SCHEMA` that came back empty - would report ZERO
# sites and every check below would pass. So the total is graded against a floor
# the tree already exceeds by a wide margin, and part 5 additionally feeds the
# scanner a source it MUST flag.
check(f"the scanner is not blind: it found {len(ALL_SITES)} sites (floor 20)",
len(ALL_SITES) >= 20)
for site in sorted(MIRRORED_SITES):
print(f" {site[0]}:{site[1]} {site[2]}({site[3]!r}, {site[4]!r})")
check(f"no literal fallback survives for any of the {len(MIRROR)} keys a mirror "
f"already answers for - those sites must index the mirror",
MIRRORED_SITES == [])
# --------------------------------------------------------------------------- #
print()
print("4. the CFG-03 ledger: the unmirrored keys that still carry a literal, "
"frozen at exactly today's set")
# ⚠️ WHY A FROZEN SET AND NOT A COUNT. A count goes green again the moment
# somebody deletes one site and adds another, which is the same-size drift this
# repository keeps finding. The pair (module, key) is stable under every edit
# that is not the one being guarded against: reformatting, moving the call
# within the file, renaming the local `opts`. It changes only when a module
# starts or stops carrying a literal for a key - which is exactly the event.
#
# LINE NUMBERS ARE DELIBERATELY NOT IN THE KEY. A ledger that goes red when an
# unrelated edit shifts a line is a ledger people learn to re-bless without
# reading, and a ledger re-blessed without reading grades nothing.
LEDGER = {
("inverter.py", "max_w"),
("main.py", "auto_start"),
("main.py", "cycle_floor_s"),
("main.py", "deadband_w"),
("main.py", "estop_fitted"),
("main.py", "gain"),
("main.py", "heartbeat_s"),
("main.py", "log_level"),
("main.py", "meter_freeze_s"),
("main.py", "meter_max_age_s"),
("main.py", "meter_phases"),
("main.py", "peak_cap_w"),
("main.py", "saturation_cycles"),
("main.py", "saturation_w"),
("main.py", "sign_disagree_s"),
("main.py", "sign_disagree_w"),
("main.py", "slew_w"),
("main.py", "stale_input_s"),
("main.py", "step_w"),
("main.py", "target_grid_w"),
("p1.py", "meter_poll_s"),
("p1.py", "meter_source"),
("validate.py", "max_w"),
("validate.py", "meter_max_age_s"),
("validate.py", "meter_phases"),
("validate.py", "sign_disagree_w"),
("validate.py", "slew_w"),
}
_found = {(s[0], s[3]) for s in UNMIRRORED_SITES}
_new = sorted(_found - LEDGER)
_gone = sorted(LEDGER - _found)
print(f" {len(_found)} (module, key) pair(s) carry a literal for a key no "
f"mirror answers for")
check(f"no NEW module/key pair has started carrying a literal fallback {_new or ''}",
_new == [])
check(f"...and none has been paid off without the ledger being shortened - "
f"shrink LEDGER when it has {_gone or ''}",
_gone == [])
# --- and every one of them is graded AGAINST config.yaml, which is the half
# that catches CFG-04's actual failure: two copies of one number, one moves.
DIVERGENT = {
# main.py:290 `target_grid_w=float(opts.get("target_grid_w", 0))`.
# config.yaml ships -10 W. MEASURED here for the first time, 2026-09-07,
# and it is a real divergence, not a sentinel: a dict handed to
# `Controller` WITHOUT the key holds net exchange at 0 W instead of the
# shipped -10 W bias into import. On a Supervisor install the key is always
# present - the deep-merge of `options:` guarantees it - so this cannot
# reach an operator; the paths that CAN hand over a partial dict are
# `sim/inject.py` and this repository's own fixtures. It is recorded rather
# than corrected because changing it is a behaviour change to the control
# loop's target, which belongs to a card that grades the loop.
("main.py", "target_grid_w"): (0, -10),
}
_diverge = {}
for mod, line, callee, key, value in UNMIRRORED_SITES:
want = SHIPPED.get(key)
same = (isinstance(value, bool) == isinstance(want, bool)) and value == want
if not same:
_diverge[(mod, key)] = (value, want)
for pair, (got, want) in sorted(_diverge.items()):
print(f" {pair[0]} {pair[1]}: code {got!r} vs config.yaml {want!r}")
check(f"every remaining literal still AGREES with config.yaml, except the "
f"{len(DIVERGENT)} divergence(s) recorded above it",
_diverge == DIVERGENT)
# --------------------------------------------------------------------------- #
print()
print("5. the red direction, RUN on every invocation")
# ⚠️ A CHECK THAT HAS NEVER BEEN SHOWN FAILING IS NOT A CHECK. Parts 3 and 4
# both pass on a clean tree, which is exactly what UX-01 AC 4's clause one did
# while grading nothing. So the scanner is fed a source that is a deliberate
# violation of each rule and is REQUIRED to flag it - in-process, against the
# same `literal_fallbacks()` the parts above call, so a scanner that stops
# working stops working here too.
_PLANTED = '''
def build(opts):
a = opts.get("maintenance_charge_w", 2000) # mirrored key -> part 3
b = _number(opts, "cycle_floor_s", 15) # unmirrored, main.py's key
c = opts.get("static_profile", "") # the "" sentinel: NOT a hit
d = getattr(unit, "max_w", 0.0) # attribute guard: NOT a hit
e = opts.get("peak_soc_floor", 20) # mirrored key -> part 3
return a, b, c, d, e
'''
_hits = list(literal_fallbacks(ast.parse(_PLANTED, "<planted>"), "<planted>"))
_keys = sorted(h[3] for h in _hits)
for h in _hits:
print(f" caught {h[2]}({h[3]!r}, {h[4]!r}) at line {h[1]}")
check("a planted literal for a MIRRORED key is caught - this is part 3's red "
"direction, exercised",
"maintenance_charge_w" in _keys and "peak_soc_floor" in _keys)
check("a planted literal for an unmirrored key is caught too - part 4's",
"cycle_floor_s" in _keys)
check('...and the two documented exclusions still hold: the "" sentinel and '
"getattr are not reported",
"static_profile" not in _keys and _keys.count("max_w") == 0)
check(f"exactly the three intended sites, no more {_keys}",
_keys == ["cycle_floor_s", "maintenance_charge_w", "peak_soc_floor"])
# ...and the mirror comparison's red direction, likewise executed rather than
# claimed: part 2 goes red when a table and config.yaml disagree by one.
_drifted = dict(MIRROR)
_pick = sorted(MIRROR)[0]
_mod, _tab, _val = _drifted[_pick]
_drifted[_pick] = (_mod, _tab, "###drifted###")
check(f"part 2's comparison rejects a mirror that has drifted (simulated on "
f"{_pick!r})",
any(v != SHIPPED.get(k) for k, (_, _, v) in _drifted.items()))
print()
if FAILED:
print(f"{FAILED} FAILED")
sys.exit(1)
print("all checks passed")
+8 -2
View File
@@ -629,9 +629,11 @@ print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH
# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is
# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W.
#
# ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
#
# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making
# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and
# a design change; it is filed as its own card. What this part does is GRADE THE
# a design change; it is filed as card SAFETY-43. What this part does is GRADE THE
# CURRENT BEHAVIOUR so that the day it changes, a named check moves.
#
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
@@ -747,7 +749,11 @@ check("...and the arithmetic behind it, stated as a window rather than as one "
"fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a "
"fraction f of the site target is invisible while `f * target < min_w`. "
"Equal weights suppress below 1000 W; these weights suppress below "
"6500 W of a 9600 W site. That is the regression, and it is a property "
"6500 W of a 9600 W site. ⚠️ BOTH ARE PROPORTIONAL-REGIME BOUNDS AND "
"NEITHER IS REACHABLE: the healthy pack's own rating binds first, so the "
"alarm returns BELOW 6500 W (swept: 0/200 to a 3000 W load, 41/200 at "
"4900 W, 195/200 at 5000 W) and the equal arm never suppresses at all. "
"That is the regression, and it is a property "
"of the threshold being absolute rather than of any number chosen here",
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0)
+893
View File
@@ -0,0 +1,893 @@
"""Runnable check for the ingress configuration surface. `python3 test_selfconfig.py`
UX-02. The panel edits, validates and saves the add-on's own options. Four of
its properties are the ones that could hurt somebody, and they are what this
file spends its length on:
1. AN INVALID BLOB IS NEVER WRITTEN. Not "warned about": not written. An
invalid blob plus a restart is exit 78, and an add-on that is not running
is not serving the panel either - the operator's way back would be the
Supervisor UI, which is the surface this card exists because it is hard to
read. Part 2 attempts the write and counts what the Supervisor received.
2. A BLOB THAT MOVED UNDERNEATH IS NEVER CLOBBERED. There are two config
surfaces and only one blob. Part 3 simulates the other one writing between
this page's read and its save.
3. EVERY MESSAGE COMES FROM THE SHIPPED VALIDATOR. Part 6 adds a NEW RULE to
a COPY of validate.py, serves the page out of that copy in a subprocess,
and watches the new sentence arrive on the field it names - with every UI
file byte-identical, which is asserted by hashing them.
4. NOTHING HERE COMMANDS AN INVERTER. Part 7 lists what the panel exposes and
compares it with what `/action` already offered.
⚠️ 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. That is TEST-15, and
part 6 below builds a mutated tree on purpose, so it purges its own copy.
"""
import asyncio
import hashlib
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from aiohttp import ClientSession, web as aioweb
from app import config_page, configui, selfconfig, web
from app.validate import validate_startup_config
HERE = os.path.dirname(os.path.abspath(__file__))
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def run(coro):
return asyncio.run(coro)
# --------------------------------------------------------------------------- #
# Fixtures.
#
# ⚠️ `SCHEMA` AND `TRANSLATIONS` ARE FIXTURES OF SOMEBODY ELSE'S RESPONSE SHAPE,
# and they are graded against the real thing somewhere else on purpose.
# `tools/supervisor_selfapi_probe.py` builds the same two structures with
# Supervisor 2026.08.0's OWN `UiOptions` and `_read_app_translations` and runs
# this repository's `field_model()` over them - §C and §E. This file cannot: the
# CI image is Alpine + python3 + aiohttp + paho and has neither the Supervisor
# nor PyYAML. So the shapes below are small, and the claim that they are the
# shapes that arrive is the probe's, not this file's.
# --------------------------------------------------------------------------- #
SCHEMA = [
{"name": "setpoint_entity", "required": True, "type": "string"},
{"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0,
"required": True, "type": "integer"},
{"name": "meter_source", "required": True, "type": "select",
"options": ["off", "ha_dsmr"]},
{"name": "log_level", "required": True, "type": "select",
"options": ["info", "trace"]},
{"name": "batt_invert", "required": True, "type": "boolean"},
{"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"},
{"name": "bms_soc_floor", "lengthMin": 0.0, "lengthMax": 50.0,
"required": True, "type": "integer"}]},
]
TRANSLATIONS = {
"en": {"configuration": {
"setpoint_entity": {"name": "Hardware · Inverter setpoint entity",
"description": "Where the setpoint is written."},
"max_w": {"name": "Limits · Inverter rating (W)",
"description": "The ceiling this site may command."},
"meter_source": {"name": "Meter · Source",
"description": "Where the grid reading comes from."},
"log_level": {"name": "Advanced · Log level",
"description": "How much the add-on writes to its log."},
"batt_invert": {"name": "Hardware · Battery power sign is reversed",
"description": "Only if your sensor is backwards."},
"inverters": {"name": "Hardware · Inverter list",
"description": "Leave empty for one inverter.",
"fields": {
"name": {"name": "Name"},
"setpoint_entity": {"name": "Setpoint entity"},
"max_w": {"name": "Rating (W)"},
"bms_soc_floor": {"name": "Battery cut-out (%)"}}}}},
"nl": {"configuration": {
"max_w": {"name": "Grenzen · Omvormervermogen (W)",
"description": "Het plafond voor deze site."}}},
}
def base(**over):
"""An options blob that STARTS. Everything else is this, spoilt.
The same fixture shape as test_validate.py's `base()`, for the same reason
it gives: a check that is measuring a broken config has to be able to show
that the unbroken one is accepted, or it is measuring nothing.
"""
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "log_level": "info", "batt_invert": False,
"inverters": [],
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
o.update(over)
return o
class FakeSupervisor:
"""The Supervisor's stored options, and a count of what reached them.
⚠️ IT COUNTS WRITES, NOT CALLS. "Nothing was written" is the property parts
2 and 3 are about, and a fake that only recorded that `write_options` was
reached could not tell a refusal from a write that failed afterwards.
"""
def __init__(self, options=None):
self.options = dict(options or base())
self.writes = []
self.restarts = 0
async def info(self):
return {"options": dict(self.options), "schema": SCHEMA,
"translations": TRANSLATIONS, "state": "started"}
async def write_options(self, options):
self.writes.append(dict(options))
self.options = dict(options)
async def restart(self, confirm):
if confirm != selfconfig.RESTART_CONFIRM:
raise selfconfig.NotConfirmed("not confirmed")
self.restarts += 1
class FakeHass:
"""`hass.limits()` and nothing else. It cannot write: that is the point."""
def __init__(self, ranges=None):
self.ranges = ranges or {}
self.reads = []
async def limits(self, entity):
self.reads.append(entity)
return self.ranges.get(entity, (None, None, None))
class FakeController:
def __init__(self, opts=None, hass=None):
self.o = dict(opts or base())
self.hass = hass or FakeHass()
self.inverters = []
self.events = []
def log_event(self, text):
self.events.append(text)
def status_payload(self):
return {"banner": "", "level": "ok", "rows": [], "checks": []}
async def handle_action(self, what):
raise AssertionError("the configuration surface must never reach "
f"handle_action ({what!r})")
# ⚠️ THE ANCHOR LIST IS EVERY OPTION KEY config.yaml DECLARES, read off the
# file with the shipped parser rather than typed here. That is what the running
# panel anchors against too: `GET /addons/self/info` returns the MERGED options,
# so every key is present in the blob the page posts back. A test that anchored
# against a small fixture's keys would report a rule as "unanchored" purely
# because its fixture did not mention the key - which is what the first draft
# of part 4 did, for the price rules.
sys.path.insert(0, os.path.join(HERE, "tools"))
import translations_lint as _tl # noqa: E402
ALL_KEYS, _NESTED = _tl.config_surface(
io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read())
# --------------------------------------------------------------------------- #
print("1. the compare-and-swap token")
_a = base()
_b = dict(reversed(list(base().items()))) # same content, other key order
check("two blobs with the same content have the same fingerprint, whatever "
"order their keys were built in - a token that changed on a dict "
"reordering would refuse every save for no reason",
selfconfig.fingerprint(_a) == selfconfig.fingerprint(_b))
check("one changed value changes it",
selfconfig.fingerprint(base()) != selfconfig.fingerprint(base(max_w=2001)))
check("...and so does a changed ROSTER ORDER, because roster order is meaning",
selfconfig.fingerprint(base(inverters=[{"name": "a"}, {"name": "b"}]))
!= selfconfig.fingerprint(base(inverters=[{"name": "b"}, {"name": "a"}])))
check("it is a hex digest and not the blob - nothing about the configuration "
"leaves through it",
re.fullmatch(r"[0-9a-f]{64}", selfconfig.fingerprint(base())) is not None)
# --------------------------------------------------------------------------- #
print()
print("2. AN INVALID BLOB IS REFUSED, AND NOTHING IS WRITTEN")
sup = FakeSupervisor()
fp = selfconfig.fingerprint(sup.options)
bad = base(setpoint_entity="") # rule 2: a controller with no actuator
refused = None
try:
run(selfconfig.save(sup, bad, fp))
except selfconfig.SaveRefused as err:
refused = err
check("the save raises SaveRefused(invalid)",
refused is not None and refused.kind == "invalid")
check("⚠️ AND THE SUPERVISOR RECEIVED NOTHING. Not a write that was rolled "
"back, not a write of the old blob - no write at all",
sup.writes == [])
check("the stored options are byte-identical to what they were",
selfconfig.fingerprint(sup.options) == fp)
check("...and the refusal carries the validator's OWN sentence, unedited",
refused.problems == validate_startup_config(bad, None, None))
check("...which is the sentence that names the key and says what it costs",
any("setpoint_entity is not set" in p for p in refused.problems))
_good = base()
res = run(selfconfig.save(sup, _good, fp))
check("a config that STARTS is saved - or nothing above means anything",
len(sup.writes) == 1 and sup.writes[0] == _good)
check("...and the caller gets the new token back, so the next save can "
"compare against what it just wrote",
res["fingerprint"] == selfconfig.fingerprint(_good))
# --------------------------------------------------------------------------- #
print()
print("2b. ⚠️ THE TWO HALVES OF THE max_w FIX, EACH PINNED SO IT CANNOT BE UNDONE")
# Review found that BOTH halves reverted with the whole 24-file suite still
# green - including the half that turns a crash into a SILENT ACCEPTANCE, which
# is the more dangerous of the two and was a wrong first attempt at this very
# fix. A fix nothing grades is a fix that comes back. MUTATION.md habit 4.
from app.inverter import _flat_max_w, build_roster # noqa: E402
from app.validate import _number # noqa: E402
# ⚠️ THIS CHECK EXISTS BECAUSE THE OBVIOUS ONE HAS A HOLE. "problems_and_ranges
# does not raise" is ALSO satisfied by the backstop swallowing the exception and
# returning [] - which is the silent-acceptance bug half two exists to prevent.
# Measured: with BOTH halves reverted, every max_w check below reported ok and
# only the forced-boom pins went red. So half one is pinned HERE, on the
# composition itself, where the backstop cannot answer for it.
_raised = None
try:
build_roster(base(max_w=""), None)
except Exception as exc: # noqa: BLE001
_raised = exc
check("⭐ THE COMPOSITION ITSELF TOLERATES A CLEARED max_w - not merely the "
"backstop above it. `validate.py`'s `_number()` reads '' as the default, "
"so rule 9 sees 2000 and is content; a bare `float('')` in the flat path "
"raised instead, inverting inverter.py's own rule that a shape rule 9 "
"tolerates must be composable",
_raised is None)
# HALF ONE: composition tolerates exactly what rule 9 tolerates.
for raw in ("", None, 0, False, True, -1, "2000", " 2000 ", "2e3", 1e18):
o = base(max_w=raw)
expect = _number(o, "max_w", 2000.0)
if expect is not None:
check(f"_flat_max_w agrees with _number() for max_w={raw!r}",
_flat_max_w(o) == expect)
_raised = None
try:
_probs, _ = run(selfconfig.problems_and_ranges(base(max_w=""), None))
except Exception as exc: # noqa: BLE001
_raised, _probs = exc, None
check("⚠️ A CLEARED max_w BOX ANSWERS WITH A VERDICT, NOT AN EXCEPTION. It "
"used to raise ValueError from inside Controller.__init__, which the "
"panel served as HTTP 500 on a non-JSON body and the page rendered as "
"'cannot reach the add-on' - untrue, for the most prominent limit "
"field's most likely transient state",
_raised is None)
check("...and it agrees with the validator, which reads '' as the default",
_probs == list(validate_startup_config(base(max_w=""), None, None)))
_raised = None
try:
_probs, _ = run(selfconfig.problems_and_ranges(base(max_w="abc"), None))
except Exception as exc: # noqa: BLE001
_raised, _probs = exc, None
check("an UNPARSEABLE max_w answers with the validator's own sentence",
_raised is None and any("is not a number" in p for p in (_probs or [])))
# HALF ONE, THE PART THAT MAKES IT SAFE RATHER THAN MERELY NOT-CRASHING:
# clearing the box on the LIVE shape must FAIL CLOSED, not silently halve the
# rail. `unit_caps_indexed` reads the flat rail through the same `_number`, so
# the rail collapses to 2000 in lockstep and rule 10 catches the collapse.
_live = base(max_w=5000, slew_w=400)
_probs, _ = run(selfconfig.problems_and_ranges(dict(_live), None))
check("the live shape (5000 W rail, slew_w 400) starts", _probs == [])
_probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None))
check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the "
"rail collapsing to the 2000 default under an explicit 400 W slew, so "
"the box cannot be emptied into a quietly halved rail",
any("site rail of 2000 W" in p for p in _probs))
# HALF TWO: the backstop cannot report "no problems" for a config it could not
# compose. Returning [] there enables Save - a crash became a green.
_orig_roster = selfconfig.build_roster
def _boom(*_a, **_kw):
raise ValueError("forced boom")
selfconfig.build_roster = _boom
try:
_probs, _ = run(selfconfig.problems_and_ranges(base(), None))
finally:
selfconfig.build_roster = _orig_roster
check("⚠️ THE BACKSTOP NEVER RETURNS AN EMPTY LIST. A roster that cannot be "
"composed while the validator names no reason is itself the finding - "
"returning [] would turn a crash into a silent acceptance and enable "
"Save, which is worse than the 500 it replaced",
_probs != [] and len(_probs) == 1)
check("...and it says so in words, naming the exception",
any("could not be composed" in p and "forced boom" in p
for p in _probs))
check("...and the patch was undone, so nothing below inherits it",
selfconfig.build_roster is _orig_roster)
# --------------------------------------------------------------------------- #
print()
print("3. THE OTHER SURFACE WRITES WHILE THIS PAGE IS OPEN")
# ⚠️ SIMULATED AT THE EXACT MOMENT IT MATTERS. The Supervisor's Configuration
# tab is not removable, so this is not a hypothetical race - it is the normal
# way two people, or one person on two devices, use this add-on. The blob is
# changed AFTER the page's read and BEFORE its save.
# ⚠️ EVERY BLOB IN THIS SECTION MUST BE ONE THAT STARTS. The gate validates
# BEFORE it compares, so a fixture that is also invalid would be refused for
# the wrong reason and this section would be measuring rule 6 instead of the
# compare-and-swap. (It was, in the first draft: `max_w: 1800` beneath the
# fixture's `maintenance_charge_w: 2000` is a genuine refusal.)
sup = FakeSupervisor(base(max_w=2000))
page_fp = selfconfig.fingerprint(sup.options) # what the page loaded
sup.options = base(max_w=2500) # the other tab saves
mine = base(max_w=3000, log_level="trace") # what the operator typed
refused = None
try:
run(selfconfig.save(sup, mine, page_fp))
except selfconfig.SaveRefused as err:
refused = err
check("the save raises SaveRefused(stale)",
refused is not None and refused.kind == "stale")
check("⚠️ AND AGAIN NOTHING WAS WRITTEN - the other surface's value stands, "
"and the operator's edits were not silently thrown at it",
sup.writes == [] and sup.options["max_w"] == 2500)
check("the refusal NAMES the keys that differ, so the page can print them "
"rather than saying 'something changed'",
refused.changed == ["log_level", "max_w"])
check("...and carries what is stored, so the operator decides in front of the "
"difference rather than through it",
refused.stored["max_w"] == 2500)
_after = run(selfconfig.save(sup, mine, selfconfig.fingerprint(sup.options)))
check("saving again against the CURRENT token succeeds - the refusal is a "
"gate, not a lock",
len(sup.writes) == 1 and sup.options["max_w"] == 3000
and _after["fingerprint"] == selfconfig.fingerprint(mine))
# ⚠️ AND THERE IS NO WAY ROUND IT. A caller with no token cannot save.
sup = FakeSupervisor()
try:
run(selfconfig.save(sup, base(max_w=2400), ""))
check("an empty token is refused", False)
except selfconfig.SaveRefused as err:
check("an empty token is refused as stale rather than treated as 'do not "
"check' - there is no force door in this module",
err.kind == "stale" and sup.writes == [])
# --------------------------------------------------------------------------- #
print()
print("4. WHERE A REFUSAL LANDS ON THE PAGE")
# ⚠️ MEASURED, NOT CLAIMED. The anchoring is name occurrence: a sentence is
# printed under every option key it spells. Rather than assert a coverage
# figure, this runs the SHIPPED validator on a spread of broken configs and
# prints what anchored and what did not.
_cases = [
("no actuator", base(setpoint_entity="")),
("max_w over the device", base(max_w=4000)),
("charge rate above the ceiling", base(maintenance_charge_w=3000)),
("sign threshold at the ceiling", base(sign_disagree_w=2000)),
("two writers on one actuator",
base(inverters=[{"name": "a", "setpoint_entity": "number.goodwe_setpoint",
"max_w": 2000, "bms_soc_floor": 10}])),
("a price source that cannot mean what it says",
base(price_provider="manual_csv")),
]
_ranges = {"number.goodwe_setpoint": (-3000.0, 3000.0, 1.0)}
_total = _anchored = 0
_orphans = []
for title, opts in _cases:
probs = run(selfconfig.problems_for(opts, FakeHass(_ranges)))
by_key, un = configui.problems_by_field(probs, ALL_KEYS)
_total += len(probs)
_anchored += len(probs) - len(un)
_orphans += [(title, p[:70]) for p in un]
print(f" {title:34s} {len(probs)} problem(s), "
f"{len(probs) - len(un)} beside a field")
check(f"every one of the {_total} refusals these six broken configs produce "
f"lands beside at least one of the fields it is about. Unanchored: "
f"{_orphans}", _total > 0 and not _orphans)
_probs = run(selfconfig.problems_for(base(max_w=4000), FakeHass(_ranges)))
_by, _un = configui.problems_by_field(_probs, ALL_KEYS)
check("the device-range rule is REACHED from the panel - the setpoint entity's "
"own range is read, so rule 1 fires here exactly as it does at startup",
any("THE DEVICE WINS" in p for p in _probs))
check("...and it lands on `max_w`, which is the field the operator has to "
"change", "max_w" in _by)
# ⚠️ THE COLLISION, CHECKED HERE AND NOT ONLY IN THE PROBE. `sign_disagree_w`
# is a suffix of both `unit_sign_disagree_w` and `site_sign_disagree_w` - three
# real options, two of them SAFETY-30's. A plain substring test would print
# every per-unit refusal next to the SITE field as well, which is a message
# about unit-0 beside a box that has nothing to do with it. CI runs this file
# and not the probe, so the assertion has to live here too.
_collide = [k for k in ALL_KEYS
if any(o != k and o.endswith("_" + k) for o in ALL_KEYS)]
check(f"config.yaml really does declare keys that are suffixes of other keys, "
f"so the boundary matching is load-bearing: {_collide}", bool(_collide))
check("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under "
"`sign_disagree_w`",
sorted(configui.problems_by_field(
["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"],
ALL_KEYS)[0]) == ["max_w", "unit_sign_disagree_w"])
check("a cross-field refusal is filed under EVERY key it names, because a "
"cross-field rule is about all of its fields",
len(configui.problems_by_field(
["max_w is 8000 W but number.x accepts at most 5000 W"],
["max_w", "setpoint_entity"])[0]) == 1)
check("the panel shows no refusal at all for a config that starts",
run(selfconfig.problems_for(base(), FakeHass(_ranges))) == [])
# ⚠️ AND WHEN A DEVICE DOES NOT ANSWER, THE PAGE SAYS WHICH ONE. The two
# device-range rules are then skipped - here and at startup, identically - so a
# green page would be green for a reason the operator cannot see.
_p, _unread = run(selfconfig.problems_and_ranges(base(max_w=4000), FakeHass()))
check("with the setpoint device silent, rule 1 does not fire and the entity "
"that did not answer is named, so the page can say what was skipped",
_p == [] and _unread == ["number.goodwe_setpoint"])
_p2, _unread2 = run(selfconfig.problems_and_ranges(base(max_w=4000),
FakeHass(_ranges)))
check("...and with the same device answering, the same config IS refused - "
"which is what makes the line above a skip rather than a pass",
any("THE DEVICE WINS" in p for p in _p2) and _unread2 == [])
check("the note is about the config UNDER TEST, not about the running roster: "
"a newly typed entity that does not answer is the one named",
run(selfconfig.problems_and_ranges(
base(setpoint_entity="number.just_typed"), FakeHass(_ranges)))[1]
== ["number.just_typed"])
# --------------------------------------------------------------------------- #
print()
print("5. THE UI OWNS NO COPY OF ANY RULE, LABEL OR KEY")
_page = io.open(os.path.join(HERE, "app", "config_page.py"),
encoding="utf-8").read()
_ui_files = {name: io.open(os.path.join(HERE, "app", name),
encoding="utf-8").read()
for name in ("config_page.py", "configui.py", "web.py")}
# The option keys config.yaml declares, read off the file rather than typed
# here - a hand-written list would go stale in the direction that hides a
# copy.
# `name` and `max_w` also occur as ordinary English/JS words or as model field
# names, so the test is on the SCRIPT half of the page and on keys that could
# only be there deliberately.
_script = _page.split("<script>", 1)[1]
_leaked = sorted(k for k in ALL_KEYS
if len(k) > 6 and re.search(r"[^A-Za-z0-9_]" + k + r"[^A-Za-z0-9_]",
_script))
check(f"the page's script names NOT ONE option key - so it cannot be rendering "
f"a field list of its own. Leaked: {_leaked}", not _leaked)
_phrases = ["THE DEVICE WINS", "has no actuator", "two writers",
"setpoint_entity is not set", "would silently never happen"]
_copied = sorted(p for p in _phrases
for f, text in _ui_files.items() if p in text)
check(f"no phrase out of a validate.py refusal appears anywhere in the three "
f"UI files - the sentences are fetched, never written here. Copied: "
f"{_copied}", not _copied)
# ⚠️ THIS ONE HAS ALREADY EARNED ITS KEEP. The suffix map shipped with
# `("_hour", "h (0-23)")` in it - a RANGE, next to a field, in a second place
# from config.yaml's `int(0,23)`. It is exactly the drift this card forbids,
# in the one corner of the panel that is allowed to print anything of its own.
check("no unit string contains a digit, so the unit map cannot be stating a "
"bound, a default or a relation - config.yaml is the only place a range "
"is written and the schema is where the form gets it from",
all(not any(ch.isdigit() for ch in unit)
for _suffix, unit in configui._UNITS))
check("...and no entry of it is a whole option key - it is a suffix map, so it "
"cannot become a per-key opinion by accident",
not [suf for suf, _u in configui._UNITS if suf.lstrip("_") in ALL_KEYS])
# ⚠️ RELATIVE URLS, WHICH IS WHAT INGRESS REQUIRES. An absolute path works
# perfectly on a laptop and 404s in the field, so it cannot be left to review.
_abs = re.findall(r"""(?:fetch\(|href=)['"](/[^'"]*)['"]""",
_page + _ui_files["web.py"])
check(f"no absolute URL in either page - every fetch and every link is "
f"relative, because ingress serves this under a generated path prefix. "
f"Absolute: {_abs}", not _abs)
check("the page declares a viewport, which is the whole of 'readable on the "
"phone you are holding at the inverter'",
'name="viewport"' in _page and "max-width:760px" in _page)
# --------------------------------------------------------------------------- #
print()
print("6. ⚠️ A RULE ADDED TO validate.py APPEARS IN THE UI WITH NO UI CHANGE")
# THE ACCEPTANCE CRITERION, RUN RATHER THAN ARGUED. A copy of this package is
# made, ONE new rule is inserted into validate.py's `validate_startup_config()`,
# and the real HTTP route is served out of that copy in a subprocess. If the
# page carried any list of known rules, the new sentence would not arrive.
SENTINEL = ("SENTINEL-UX02: log_level is set to 'trace', which this rule "
"refuses so that a check can watch it arrive.")
_INSERT = (' if str(opts.get("log_level", "")) == "trace":\n'
f' problems.append({SENTINEL!r})\n')
_probe = r'''
import asyncio, json, os, sys
sys.path.insert(0, os.environ["TREE"])
from aiohttp import ClientSession, web as aioweb
from app import web
class H:
session = None
token = ""
async def limits(self, e): return (None, None, None)
class C:
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
def log_event(self, t): pass
def status_payload(self): return {}
async def handle_action(self, w): raise AssertionError("no")
async def main():
app = web.build_app(C())
runner = aioweb.AppRunner(app); await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0); await site.start()
port = site._server.sockets[0].getsockname()[1]
async with ClientSession() as s:
async with s.post(f"http://127.0.0.1:{port}/config/validate",
json={"options": json.loads(os.environ["OPTS"])}) as r:
print(json.dumps(await r.json()))
await runner.cleanup()
asyncio.run(main())
'''
_tmp = tempfile.mkdtemp(prefix="ux02-mutant-")
try:
_tree = os.path.join(_tmp, "goodwe_controller")
shutil.copytree(os.path.join(HERE, "app"), os.path.join(_tree, "app"))
# ⚠️ PURGE THE COPY'S BYTECODE. MUTATION.md habit: `python3 -B` stops
# bytecode being WRITTEN, never a stale one being READ, and a copytree
# brings any `__pycache__` along with it.
_purged = 0
for root, dirs, files in os.walk(_tree):
for d in list(dirs):
if d == "__pycache__":
shutil.rmtree(os.path.join(root, d))
dirs.remove(d)
_purged += 1
print(f" purged {_purged} __pycache__ director(y|ies) from the copy; "
f"{sum(1 for r, _d, fs in os.walk(_tree) for f in fs if f.endswith('.pyc'))}"
" .pyc left")
_vpath = os.path.join(_tree, "app", "validate.py")
_src = io.open(_vpath, encoding="utf-8").read()
_at = _src.index("def validate_startup_config")
_anchor = " problems: list[str] = []\n"
_cut = _src.index(_anchor, _at) + len(_anchor)
io.open(_vpath, "w", encoding="utf-8").write(
_src[:_cut] + _INSERT + _src[_cut:])
check("the mutated copy differs from the shipped tree in validate.py, and "
"in validate.py ONLY - so anything the page gains is the rule's",
[name for name in sorted(os.listdir(os.path.join(HERE, "app")))
if name.endswith(".py")
and hashlib.sha256(io.open(os.path.join(HERE, "app", name), "rb")
.read()).hexdigest()
!= hashlib.sha256(io.open(os.path.join(_tree, "app", name), "rb")
.read()).hexdigest()] == ["validate.py"])
_env = dict(os.environ, TREE=_tree, OPTS=json.dumps(base(log_level="trace")),
PYTHONDONTWRITEBYTECODE="1")
_out = subprocess.run([sys.executable, "-B", "-c", _probe], env=_env,
capture_output=True, text=True, timeout=120)
if _out.returncode != 0:
print(_out.stdout[-2000:])
print(_out.stderr[-2000:])
check("the page's own /config/validate route answers out of the mutated "
"tree", _out.returncode == 0)
_payload = json.loads(_out.stdout.strip().splitlines()[-1]) if _out.stdout else {}
check("⚠️ THE NEW RULE'S SENTENCE IS ON THE PAGE, and no file under app/ "
"other than validate.py was touched to put it there",
SENTINEL in (_payload.get("problems") or []))
check("...and it is filed under `log_level`, the field it names, by the "
"same anchoring that files every other rule - so a new rule arrives "
"in the right place rather than in a catch-all",
SENTINEL in (_payload.get("by_key") or {}).get("log_level", []))
check("...while the SHIPPED tree does not refuse that config at all, which "
"is what makes the line above evidence about the rule rather than "
"about the fixture",
run(selfconfig.problems_for(base(log_level="trace"), None)) == [])
finally:
shutil.rmtree(_tmp, ignore_errors=True)
# --------------------------------------------------------------------------- #
print()
print("7. THE PANEL'S REACH INTO THE INVERTER IS UNCHANGED")
_app = web.build_app(FakeController(), FakeSupervisor())
_routes = sorted({(r.method, r.get_info().get("path"))
for r in _app.router.routes() if r.method != "HEAD"})
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
check("the configuration surface adds five routes and touches none of the "
"three that were there",
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
("GET", "/status"), ("POST", "/action"),
("POST", "/config/restart"), ("POST", "/config/save"),
("POST", "/config/validate")])
_selfconfig_src = io.open(os.path.join(HERE, "app", "selfconfig.py"),
encoding="utf-8").read()
for forbidden in ("set_number", "write_setpoint", "handle_action",
"command_site_zero", "sign_wd", "reset("):
check(f"neither selfconfig.py nor the page can reach `{forbidden}`",
forbidden not in _selfconfig_src
and forbidden not in _ui_files["config_page.py"])
# ⚠️ ASSERTED ON THE CONSTANTS, NOT ON A GREP OF THE FILE. The module's own
# docstring quotes Supervisor's bypass regex, which names `security`, `update`
# and `options/validate`; a grep would find those and report a reach the code
# does not have. The three constants are what `_call()` can be given.
check("the ONLY Supervisor endpoints this module defines are self/info, "
"self/options and self/restart",
sorted(v for k, v in vars(selfconfig).items()
if k.startswith("SELF_"))
== ["http://supervisor/addons/self/info",
"http://supervisor/addons/self/options",
"http://supervisor/addons/self/restart"])
# --------------------------------------------------------------------------- #
print()
print("8. end to end, over real HTTP, against a fake Supervisor")
async def _serve(app):
runner = aioweb.AppRunner(app)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
return runner, site._server.sockets[0].getsockname()[1]
async def _e2e():
out = {}
sup = FakeSupervisor(base(max_w=2000))
ctrl = FakeController(base(max_w=1500), # running != stored
FakeHass({"number.goodwe_setpoint":
(-3000.0, 3000.0, 1.0)}))
runner, port = await _serve(web.build_app(ctrl, sup))
root = f"http://127.0.0.1:{port}"
try:
async with ClientSession() as s:
async with s.get(f"{root}/config") as r:
out["page"] = (r.status, await r.text())
async with s.get(f"{root}/config/model?lang=nl") as r:
out["model"] = await r.json()
async with s.post(f"{root}/config/validate",
json={"options": base(setpoint_entity="")}) as r:
out["invalid"] = await r.json()
# ⚠️ THE COUNTS ARE SNAPSHOTTED AT EACH STAGE. Asserting
# `sup.writes == []` after the whole sequence would be asserting
# about the LAST request, not this one - the first draft did, and
# went red only because a later, legitimate save had happened.
async with s.post(f"{root}/config/save",
json={"options": base(setpoint_entity=""),
"fingerprint": out["model"]["fingerprint"]}
) as r:
out["save_invalid"] = (r.status, await r.json(),
len(sup.writes))
async with s.post(f"{root}/config/save",
json={"options": base(max_w=2400),
"fingerprint": "not-the-token"}) as r:
out["save_stale"] = (r.status, await r.json(), len(sup.writes))
async with s.post(f"{root}/config/save",
json={"options": base(max_w=2400),
"fingerprint": out["model"]["fingerprint"]}
) as r:
out["save_ok"] = (r.status, await r.json())
async with s.post(f"{root}/config/restart", json={}) as r:
out["restart_bare"] = (r.status, await r.json(), sup.restarts)
async with s.post(f"{root}/config/restart",
json={"confirm": "restart"}) as r:
out["restart_ok"] = (r.status, await r.json())
finally:
await runner.cleanup()
out["sup"] = sup
out["ctrl"] = ctrl
return out
E = run(_e2e())
check("GET config serves the page", E["page"][0] == 200
and "GoodWe - Configuration" in E["page"][1])
_m = E["model"]
check("the model carries the groups, the stored blob and its token",
# `Grenzen`, not `Limits`: this request asked for nl, and nl.yaml
# carries `max_w`. The three groups around it fall back to English per
# key, which is the frontend's own rule and is asserted two checks below.
[g["name"] for g in _m["groups"]] == ["Hardware", "Grenzen", "Meter",
"Advanced"]
and _m["options"]["max_w"] == 2000
and _m["fingerprint"] == selfconfig.fingerprint(_m["options"]))
check("...and it says which stored settings are NOT what this add-on is "
"running - which is how an edit made in the Configuration tab, or a save "
"not yet restarted into, becomes visible here",
_m["running_differs"] == ["max_w"] and _m["running"]["max_w"] == 1500)
check("...and it honours the browser's language per key, falling back to "
"English for the keys nl.yaml does not carry - the frontend's own rule",
_m["lang"] == "nl"
and [f["label"] for g in _m["groups"] for f in g["fields"]
if f["key"] == "max_w"] == ["Omvormervermogen (W)"]
and [f["label_lang"] for g in _m["groups"] for f in g["fields"]
if f["key"] == "meter_source"] == ["en"])
check("POST config/validate returns the validator's sentences, anchored",
E["invalid"]["problems"]
and "setpoint_entity" in E["invalid"]["by_key"])
check("a save of an invalid blob answers 422 and the Supervisor got nothing",
E["save_invalid"][0] == 422
and E["save_invalid"][1]["kind"] == "invalid"
and E["save_invalid"][2] == 0)
check("a save with a stale token answers 409, writes nothing, and hands back "
"the CURRENT token so the operator can save again in front of the "
"difference",
E["save_stale"][0] == 409
and E["save_stale"][1]["kind"] == "stale"
and E["save_stale"][1]["fingerprint"]
== selfconfig.fingerprint(E["save_stale"][1]["stored"])
and E["save_stale"][2] == 0)
check("a valid save with the right token is written, once",
E["save_ok"][0] == 200 and len(E["sup"].writes) == 1
and E["sup"].writes[0]["max_w"] == 2400)
check("...and it does NOT restart anything - applying is a separate, "
"confirmed act, because it costs a control interruption",
E["restart_bare"][0] == 400 and E["restart_bare"][2] == 0)
check("...which the confirmed request performs",
E["restart_ok"][0] == 200)
check("the action log records both, so §15 can say who changed what",
any("configuration saved" in e for e in E["ctrl"].events)
and any("restart requested" in e for e in E["ctrl"].events))
# --------------------------------------------------------------------------- #
print()
print("9. the page says what a save costs BEFORE it is pressed")
for phrase in ("0 W on every inverter", "Saving does not restart anything",
"Restart to apply"):
check(f"the page states {phrase!r} in its own markup, not in a message "
"that only appears afterwards", phrase in _page)
check("the restart confirmation names the cost again at the moment of pressing",
"Yes, restart now" in _page
and _page.count("0 W on every inverter") >= 2)
_flat = " ".join(_page.split())
check("and it says which surface is authoritative when the add-on is down",
"If the add-on is not running, the Configuration tab is the only one of "
"the two that still exists" in _flat)
# --------------------------------------------------------------------------- #
print()
print("10. the Supervisor conversation itself, over real HTTP")
# ⚠️ EVERYTHING ABOVE INJECTED A FAKE `selfcfg`, so none of it exercised the
# one piece that talks to somebody else's server: the headers, the request
# body's shape, the `{"result": "ok", "data": {...}}` envelope and what a
# refusal turns into. A gate whose transport is never run is a gate that has
# only been read.
async def _transport():
seen = []
async def h_info(request):
seen.append(("info", request.headers.get("Authorization"), None))
return aioweb.json_response({"result": "ok",
"data": {"options": {"max_w": 2000}}})
async def h_options(request):
body = await request.json()
seen.append(("options", request.headers.get("Authorization"), body))
return aioweb.json_response({"result": "ok", "data": {}})
async def h_restart(request):
seen.append(("restart", request.headers.get("Authorization"), None))
return aioweb.json_response({"result": "ok", "data": {}})
async def h_denied(_request):
return aioweb.json_response(
{"result": "error", "message": "Missing API permission"},
status=403)
app = aioweb.Application()
app.add_routes([aioweb.get("/addons/self/info", h_info),
aioweb.post("/addons/self/options", h_options),
aioweb.post("/addons/self/restart", h_restart),
aioweb.get("/denied", h_denied)])
runner, port = await _serve(app)
root = f"http://127.0.0.1:{port}"
saved = (selfconfig.SELF_INFO, selfconfig.SELF_OPTIONS,
selfconfig.SELF_RESTART)
selfconfig.SELF_INFO = f"{root}/addons/self/info"
selfconfig.SELF_OPTIONS = f"{root}/addons/self/options"
selfconfig.SELF_RESTART = f"{root}/addons/self/restart"
out = {}
try:
async with ClientSession() as session:
cfg = selfconfig.SelfConfig(session, "TOKEN-123")
out["info"] = await cfg.info()
await cfg.write_options({"max_w": 2400})
await cfg.restart(selfconfig.RESTART_CONFIRM)
try:
await cfg.restart("")
out["unconfirmed"] = None
except selfconfig.NotConfirmed as err:
out["unconfirmed"] = str(err)
try:
await cfg._call("GET", f"{root}/denied")
out["denied"] = None
except selfconfig.SupervisorUnavailable as err:
out["denied"] = str(err)
try:
await cfg._call("GET", f"{root}/nothing-here")
out["missing"] = None
except selfconfig.SupervisorUnavailable as err:
out["missing"] = str(err)
finally:
(selfconfig.SELF_INFO, selfconfig.SELF_OPTIONS,
selfconfig.SELF_RESTART) = saved
await runner.cleanup()
out["seen"] = seen
return out
T = run(_transport())
check("`info()` unwraps the Supervisor's `{result, data}` envelope",
T["info"] == {"options": {"max_w": 2000}})
check("every request carries the add-on's own SUPERVISOR_TOKEN as a bearer",
[auth for _what, auth, _b in T["seen"]] == ["Bearer TOKEN-123"] * 3)
check("the options write goes to /addons/self/options as {'options': blob} - "
"the shape `SCHEMA_OPTIONS` accepts, and the shape that REPLACES the "
"stored user options",
[(w, b) for w, _a, b in T["seen"] if w == "options"]
== [("options", {"options": {"max_w": 2400}})])
check("the restart is a POST to /addons/self/restart and reaches it only when "
"confirmed",
[w for w, _a, _b in T["seen"]] == ["info", "options", "restart"]
and T["unconfirmed"])
check("a Supervisor refusal is carried through with ITS message and its status "
"rather than rewritten - the page shows what the Supervisor said",
"403" in T["denied"] and "Missing API permission" in T["denied"])
check("...and so is a 404, so a path this add-on is not permitted is "
"distinguishable from one that does not exist",
"404" in T["missing"])
# --------------------------------------------------------------------------- #
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
+10 -4
View File
@@ -248,9 +248,15 @@ for what, text in BAD:
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 8 per-entry keys came back out of each (OPT-09 added the "
"eighth, `capacity_kwh`), which is what says the strictness above is not "
"just refusing everything",
# ⚠️ COUNTED, NOT TYPED. UX-02 corrected this sentence's option count
# 70 -> 71 while OPT-09 independently added an eighth per-entry key
# (`capacity_kwh`), and the two edits collided here on the rebase - each
# restating a number the other had moved. The counts now come from the
# same parse the assertion uses, so the sentence cannot drift from it
# again. PROC-01.
f"{len(ORDER)} options and {sum(len(v) for v in NESTED.values())} "
f"per-entry keys came back out of each, which is what says the "
f"strictness above is not just refusing everything",
all(len(TR[lg]["configuration"]) == len(ORDER)
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8
for lg in LANGS))
@@ -356,7 +362,7 @@ print("\n7. no behaviour and no value moved")
# 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",
"halves of config.yaml hold the same 71 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 "
+282
View File
@@ -0,0 +1,282 @@
"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py`
⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy
check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names -
"a check whose DATA cannot reach the failure it names" - which this repository
has recorded twelve times. So every refusal below is DRIVEN: a repository is
built in which the failure is really present, and the check is required to say
so. A clearance is only meaningful next to a refusal from the same harness.
The third property is the one that is easy to lose: a SKIPPED check must not
look like a PASSED one. Omitting `--options` cannot exit 0.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
GATE = os.path.join(HERE, "tools", "predeploy_check.py")
sys.path.insert(0, os.path.join(HERE, "tools"))
fails = []
tmpdirs = []
def check(name, ok):
print(f" {'ok ' if ok else 'FAIL'} {name}")
if not ok:
fails.append(name)
def git(repo, *args):
return subprocess.run(
["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args],
capture_output=True, text=True)
def make_repo(version="0.7.0"):
"""A repo shaped like this one: <root>/goodwe_controller/{config.yaml,app,tools}."""
root = tempfile.mkdtemp(prefix="test_version_gate.")
tmpdirs.append(root)
addon = os.path.join(root, "goodwe_controller")
os.makedirs(os.path.join(addon, "app"))
write(addon, "config.yaml", f'name: t\nversion: "{version}"\n')
write(addon, "app/control.py", "X = 1\n")
write(addon, "CHANGELOG.md", "# Changelog\n")
git(root, "init", "-q", ".")
git(root, "add", "-A")
git(root, "commit", "-q", "-m", f"initial at {version}")
return root, addon
def write(addon, rel, text):
p = os.path.join(addon, rel)
with open(p, "w", encoding="utf-8") as fh:
fh.write(text)
def run_gate(addon, root, deployed, options=None):
"""Run the REAL gate against a throwaway tree.
⚠️ The script is invoked where it SHIPS, not copied into the fixture. It
imports the validator from its own sibling `app/`, which is the behaviour
that matters: the gate must check a config against the validator it ships
beside, not against whatever happens to sit next to a copy. Copying it into
the fixture made every validator assertion silently SKIP - the first draft
of this file did exactly that and reported four green skips as passes.
"""
cmd = [sys.executable, GATE,
"--deployed-version", deployed, "--tree", addon, "--repo", root]
if options:
cmd += ["--options", options]
return subprocess.run(cmd, capture_output=True, text=True)
VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6,
"inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"}
def opts_file(d):
fd, p = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(d, fh)
return p
print("classification - what forces a rebuild and what does not")
from predeploy_check import is_runtime, shipped_version # noqa: E402
for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml",
"goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"):
check(f"runtime: {p}", is_runtime(p))
for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md",
"goodwe_controller/test_control.py", "goodwe_controller/tools/x.py",
"goodwe_controller/DECISIONS/a.md", "README.md"):
check(f"NOT runtime: {p}", not is_runtime(p))
print("\nTHE REFUSAL - a runtime change behind an unchanged version")
root, addon = make_repo("0.7.0")
write(addon, "app/control.py", "X = 2\n")
git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing")
r = run_gate(addon, root, "0.7.0", opts_file(VALID))
check("refuses (exit 1)", r.returncode == 1)
check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout)
check("...and names the offending commit", "SAFETY-99" in r.stdout)
check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout)
print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change")
root2, addon2 = make_repo("0.7.0")
write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n")
git(root2, "commit", "-qam", "PKG-99: documentation only")
r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID))
check("does not refuse a docs-only tree", r2.returncode == 0)
check("...and says there is nothing to deploy",
"nothing to deploy" in r2.stdout)
print("\nTHE CLEARANCE - a bumped version with a valid config")
root3, addon3 = make_repo("0.7.0")
write(addon3, "app/control.py", "X = 3\n")
write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n')
git(root3, "commit", "-qam", "PKG-99: change and bump together")
r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID))
check("clears (exit 0)", r3.returncode == 0)
check("...and says so", "CLEARED for deploy" in r3.stdout)
print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE")
r4 = run_gate(addon3, root3, "0.7.0") # no --options
check("omitting --options cannot exit 0", r4.returncode != 0)
check("...says SKIPPED", "SKIPPED" in r4.stdout)
check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout)
check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout)
print("\nTHE OTHER REFUSAL - a config this version's validator rejects")
bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W
r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
check("refuses a config the validator rejects", r5.returncode == 1)
check("...and warns it is exit 78", "78" in r5.stdout)
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
print()
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
# ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS
# INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the
# original `git log -S 'version: "'` looked correct - the pickaxe finds commits
# that change a string's COUNT, and introducing the line changes it from 0 to 1.
# A real repository edits the line IN PLACE, the count never moves again, and
# the lookup silently returns the commit that first added it.
#
# MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d`
# (2026-08-23, the original packaging commit) as "the last bump", so a correctly
# bumped tree was reported as 87 commits behind an unbumped version - with
# PKG-06's and PKG-07's own bump commits listed among the offenders. The tool
# cried wolf on every run, and none of the fixtures above could see it, because
# a test that builds its own one-bump history is a test asserting against a
# structure it built itself. MUTATION.md habit 4, in the file written to
# prevent a recurrence.
from predeploy_check import last_version_change # noqa: E402
root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.")
tmpdirs.append(root4)
addon4 = os.path.join(root4, "goodwe_controller")
os.makedirs(os.path.join(addon4, "app"))
write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n')
write(addon4, "app/control.py", "X = 1\n")
git(root4, "init", "-q", ".")
git(root4, "add", "-A")
git(root4, "commit", "-q", "-m", "initial at 0.5.0")
_first = git(root4, "rev-parse", "HEAD").stdout.strip()
write(addon4, "app/control.py", "X = 2\n")
git(root4, "commit", "-qam", "runtime change between bumps")
write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n')
git(root4, "commit", "-qam", "bump to 0.6.0")
write(addon4, "app/control.py", "X = 3\n")
git(root4, "commit", "-qam", "another runtime change")
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n')
git(root4, "commit", "-qam", "bump to 0.7.0")
_last_bump = git(root4, "rev-parse", "HEAD").stdout.strip()
check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the "
"version line does not change how many times the string occurs, so a "
"pickaxe search returns the commit that introduced it and every bump "
"after that is invisible",
last_version_change(root4) == _last_bump)
check("...and it is emphatically not the commit that introduced the line",
last_version_change(root4) != _first)
r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("a tree bumped with nothing after it clears, even with bumps in history",
r6.returncode == 0 and "CLEARED for deploy" in r6.stdout)
write(addon4, "app/control.py", "X = 4\n")
git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump")
r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("and one runtime commit after that last bump is refused", r7.returncode == 1)
check("...naming only the commit since the LAST bump, not since the first",
"SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout)
# ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART.
# Review swapped `last_version_change` for "-G's newest raw hit, skip the value
# comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section
# above - because a linear history of real bumps never produces a commit that
# touches the version LINE without changing the version VALUE. So the docstring's
# central claim ("the VALUE is compared, and that is the only thing that answers
# it") was not pinned by anything, and `-G` alone would have shipped as if it
# were the fix. That is the third distinct shape of habit 4 in this one file.
#
# A cosmetic touch of the version line is the discriminator: `-G '^version: "'`
# matches it, and the value comparison does not.
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n')
git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value")
_cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip()
check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION "
"IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; "
"only comparing the VALUE against the parent's answers the question the "
"tool is actually asking",
last_version_change(root4) == _last_bump)
check("...and it is emphatically not the cosmetic commit",
last_version_change(root4) != _cosmetic)
r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("...and because config.yaml IS a runtime path, that cosmetic commit is "
"still counted as something unshipped - conservative, and correct: the "
"Supervisor rebuilds on the version string alone, so a changed file "
"behind an unchanged version is a no-op whatever changed in it",
r8.returncode == 1 and "COSMETIC" in r8.stdout)
print()
print("⭐ A GIT LOOKUP THAT GENUINELY FAILS - THE SHAPE NO FIXTURE COULD REACH")
# ⚠️ EVERY FIXTURE ABOVE CREATES ITS OWN REPOSITORY AS ITS OWN UID, so none of
# them could ever meet the failure that actually happened: the documented
# invocation runs the container as uid 0 against a checkout owned by the
# operator, git refuses with "detected dubious ownership", `_git` returns
# non-zero, and the no-op half reports SKIPPED. It degraded honestly - exit 2,
# never a false clearance - but the check this file exists for had never once
# run in its documented form, and nothing here could see that.
#
# `_git` now sets `safe.directory`, so ownership no longer breaks it. This
# section grades the REMAINING failure mode: git answering not at all.
root5 = tempfile.mkdtemp(prefix="test_version_gate.nogit.")
tmpdirs.append(root5)
addon5 = os.path.join(root5, "goodwe_controller")
os.makedirs(addon5)
write(addon5, "config.yaml", 'name: t\nversion: "0.7.0"\n') # NOT a git repo
check("a repository git cannot answer for reports no last-bump commit",
last_version_change(root5) is None)
r9 = run_gate(addon5, root5, "0.7.0", opts_file(VALID))
check("⭐ AND THE GATE DOES NOT CLEAR ON IT. An unanswerable git lookup is "
"INCONCLUSIVE, not clean - the one thing it must never do is call a "
"tree it could not inspect deployable",
r9.returncode != 0 and "CLEARED for deploy" not in r9.stdout)
check("...it says INCONCLUSIVE in those words", "INCONCLUSIVE" in r9.stdout)
# ⚠️ AND THE EXIT CODES ARE PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0`
# is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
# earlier draft could not tell apart. 1 and 2 mean different things to an
# operator: 1 says "this deploy is wrong", 2 says "nobody checked".
check("NOT CLEARED is exactly 2, not merely non-zero", r9.returncode == 2)
r10 = run_gate(addon3, root3, "0.7.0", opts_file(dict(VALID, slew_w=1000)))
check("REFUSED is exactly 1, not merely non-zero",
r10.returncode == 1 and "REFUSED" in r10.stdout)
print("\nthe shipped tree's own version is readable")
check("shipped_version() finds a version", bool(shipped_version(HERE)))
for d in tmpdirs:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
+306
View File
@@ -0,0 +1,306 @@
"""PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject.
python3 tools/predeploy_check.py --deployed-version 0.7.0 --options live-options.json
⚠️ RUN IT WHERE THE ADD-ON'S DEPENDENCIES EXIST. `app/validate.py` imports its
siblings and those need aiohttp, so on a bare host the validator half SKIPS -
loudly, and exit 2, never a silent pass. To get both halves:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.7.0 --options /w/live-options.json
EXIT CODES. 0 cleared; 1 REFUSED, do not deploy; 2 NOT CLEARED - a check did not
run, and that is not the same as passing.
TWO FAILURES THIS ANSWERS, BOTH OF WHICH HAVE HAPPENED.
1. THE SILENT NO-OP. The Supervisor keys the built image by `version:` in
config.yaml. `ha apps update` against an unchanged version does nothing and
REPORTS SUCCESS. PKG-06 recorded this on 2026-09-05 with SIX merges behind
an unbumped version - its own commit message says so, and this line said
"four" through two review rounds; PKG-07 recorded it again on 2026-09-06 with six. Both
times a human noticed. Nothing else was ever going to.
2. THE REFUSED CONFIG. A config the shipped validator rejects is
`SystemExit(78)` - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES
WITH IT, because the add-on serving it is not running, so recovery means the
Supervisor UI. SAFETY-39's rule 10 made this reachable by a config that
started fine on the previous version: it refuses an explicit `slew_w` above
SLEW_RAIL_FRAC x the site rail, and 0.7.0's own shipped default of 1000
exceeds the 555.6 W ceiling on a 5000 W rail.
⚠️ WHY THIS IS NOT A CI CHECK. The obvious version gate - "a pull request that
touches runtime code must bump the version" - is the WRONG shape for this
project, which deliberately batches many merges into one deploy. It would demand
a bump per pull request, put every branch in conflict over one line of
config.yaml, and still not prevent the actual failure, which happens at DEPLOY
and not at merge. The check belongs where the mistake is made.
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
reachable only through the Supervisor API, which needs a supervisor token, so
the operator exports it and passes `--options`. Without that flag the validator
half is SKIPPED and says so - a skipped check must never look like a passed one.
"""
import argparse
import json
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ADDON = os.path.dirname(HERE)
sys.path.insert(0, ADDON)
# Files that end up in the built image and can change what it DOES. A change to
# any of these needs a new `version:` or the Supervisor will not rebuild.
# CHANGELOG.md, DOCS.md, MUTATION.md, DECISIONS/, test_*.py, tools/ and
# scenarios/ are deliberately absent: they ship in the repo but cannot change
# runtime behaviour, and demanding a bump for a typo fix trains people to bump
# without reading, which is the habit this file exists to prevent.
RUNTIME = (
"goodwe_controller/app/",
"goodwe_controller/translations/",
"goodwe_controller/config.yaml",
"goodwe_controller/Dockerfile",
"goodwe_controller/run.sh",
"goodwe_controller/requirements.txt",
"goodwe_controller/build.yaml",
)
CONFIG_PATH = "goodwe_controller/config.yaml"
_VER = re.compile(r'^version:\s*"([^"]+)"', re.M)
def shipped_version(tree=ADDON):
with open(os.path.join(tree, "config.yaml"), encoding="utf-8") as fh:
m = _VER.search(fh.read())
return m.group(1) if m else None
def is_runtime(path):
return any(path == p or path.startswith(p) for p in RUNTIME)
def _git(*args, repo=None):
"""git, run so it answers regardless of who owns the checkout.
⚠️ WITHOUT `safe.directory` THIS TOOL SILENTLY DID HALF ITS JOB. The
invocation this file's own docstring documents runs the container as uid 0
against a checkout owned by the operator, and git refuses that:
fatal: detected dubious ownership in repository at '/w/goodwe-addon'
`_git` then returned non-zero, `last_version_change()` returned None, and
the no-op check reported SKIPPED - so **the silent-no-op half, which is the
reason this file exists, had never once run in its documented form.**
It degraded honestly: exit 2, INCONCLUSIVE, never a false clearance. But a
check that cannot fire is not a check, and the two documented environments
were mutually exclusive - in the container the validator ran and git
skipped; on a bare host git ran and the validator skipped on aiohttp.
Neither documented command ran both halves.
⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults
the git result only when `shipped == deployed`; every real run so far
compared 0.7.0 against 0.9.0 and printed its verdict without needing git.
`test_version_gate.py` could not see it either: it invokes the gate with
`sys.executable` against fixtures it creates as its own uid, so no fixture
ever met the ownership mismatch. MUTATION.md habit 4, in a fourth shape,
inside the file written to prevent it.
`safe.directory` is the right fix rather than `--user`: it makes the tool
correct however it is invoked, instead of making every future caller
remember a flag. The read is read-only, on a repository the operator is
about to deploy from.
"""
where = repo or os.path.dirname(ADDON)
return subprocess.run(
["git", "-c", f"safe.directory={where}", "-C", where, *args],
capture_output=True, text=True)
def _version_at(sha, repo=None, path=CONFIG_PATH):
"""`version:` as of one commit, or None if the file or the key is absent."""
r = _git("show", f"{sha}:{path}", repo=repo)
if r.returncode != 0:
return None
m = _VER.search(r.stdout)
return m.group(1) if m else None
def last_version_change(repo=None):
"""The newest commit whose `version:` VALUE differs from its parent's.
⚠️ THIS USED TO BE `git log -S 'version: "'` AND IT WAS WRONG ON EVERY REAL
REPOSITORY. `-S` is the pickaxe: it finds commits that change the COUNT of
a literal string. A bump edits the line in place - `version: "0.7.0"` ->
`version: "0.8.0"` - so the substring still occurs exactly once before and
after, and `-S` never sees it. It found only the commit that first
INTRODUCED the line.
Measured on this repository: `-S` returned `0a1e61d` (2026-08-23, the
original packaging commit) as "the last bump", so the script reported the
correctly-bumped tree as 87 commits behind an unbumped version - listing
PKG-06's and PKG-07's own bump commits among the offenders. A tool whose
entire purpose is to be trusted before a deploy cried wolf on every run.
⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'`
matches a commit whose diff touches a line with that shape - which includes
a reformat, a quote change, or a comment moved onto the line, none of which
are bumps. So `-G` narrows the candidates and then the VALUE is compared
against the parent's. The question is "did the version change", and that is
the only thing that answers it.
⚠️ TWO GIT SHAPES THIS STILL GETS WRONG, NEITHER OF WHICH HAS EVER OCCURRED
IN THIS REPOSITORY - every bump here has been a clean, linear, forward-only
edit. Both were constructed in review rather than found:
1. A BUMP RESOLVED INSIDE A MERGE COMMIT IS INVISIBLE. `git log -G -- path`
does not diff merge commits at all without `-m`, so a version line whose
final value is decided by a conflict resolution is never a candidate.
The lookup then names the last ordinary bump, and the merge itself counts
as a runtime change since it - a FALSE REFUSAL of a tree that is in fact
correctly bumped.
2. REVERTING A BUMP WHILE KEEPING THE WORK MASKS THE WORK. Bump, change
`app/`, then revert only `config.yaml`: the version is back where it
started, the runtime change is still in the tree, and this reports
"nothing to deploy" - a FALSE CLEARANCE, which is the dangerous
direction.
Card PKG-11 carries both. They are recorded here rather than fixed because
a lookup that handles them costs more than the failure they represent, and
a caveat a reader can check beats a guarantee that is not true.
"""
r = _git("log", "--format=%H", "-G", r'^version: "', "--", CONFIG_PATH,
repo=repo)
if r.returncode != 0:
return None
shas = r.stdout.split()
for sha in shas: # newest first
here = _version_at(sha, repo)
there = _version_at(sha + "^", repo)
if here is not None and here != there:
return sha
return shas[-1] if shas else None
def runtime_commits_since_version_change(repo=None):
"""Commits touching RUNTIME since `version:` last changed. ([], None) if unknown.
Returns (commits, last_bump_sha). An empty list with a sha means the tree is
clean; an empty list with None means git could not answer and the caller
must NOT read that as a pass.
"""
last = last_version_change(repo)
if not last:
return [], None
r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo)
if r.returncode != 0:
return [], None
out = []
for line in r.stdout.splitlines():
sha, _, subject = line.partition(" ")
f = _git("show", "--name-only", "--format=", sha, repo=repo)
if any(is_runtime(p) for p in f.stdout.split()):
out.append((sha[:10], subject))
return out, last[:10]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--deployed-version", required=True,
help="the version currently running ON THE BOX")
ap.add_argument("--options", help="path to the live options blob, exported "
"from the Supervisor. Omitted = that half is SKIPPED.")
ap.add_argument("--tree", default=ADDON)
ap.add_argument("--repo", default=None)
a = ap.parse_args()
problems, skipped = [], []
shipped = shipped_version(a.tree)
print(f" deployed on the box : {a.deployed_version}")
print(f" shipped in this tree: {shipped}")
commits, last = runtime_commits_since_version_change(a.repo)
if shipped is None:
problems.append("could not read `version:` out of config.yaml")
elif shipped == a.deployed_version:
if last is None:
skipped.append("could not ask git what changed since the last bump; "
"the no-op check is INCONCLUSIVE, not clean")
if commits:
problems.append(
f"THE DEPLOY WOULD BE A NO-OP. Version is {shipped} on both sides, and "
f"{len(commits)} commit(s) touching runtime files have landed since the "
f"version last changed ({last}). `ha apps update` will report success "
f"and install NOTHING. Bump `version:` first.")
for sha, subj in commits[:12]:
print(f" {sha} {subj[:76]}")
elif last is not None:
print(" no runtime change since the last bump - nothing to deploy")
if a.options:
# ⚠️ A TOOL RUN IN A HURRY BEFORE A DEPLOY MUST NOT ANSWER WITH A
# TRACEBACK. A missing or malformed export is the likeliest operator
# error here, and it is a REFUSAL, not a skip - the config was not
# checked and the script must not be re-run past it.
opts = None
try:
with open(a.options, encoding="utf-8") as fh:
opts = json.load(fh)
except FileNotFoundError:
problems.append(f"--options file not found: {a.options}")
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
problems.append(f"--options file could not be read as JSON "
f"({type(exc).__name__}: {exc}); the config was NOT checked")
if a.options and opts is not None:
try:
from app.validate import validate_startup_config
except Exception as exc: # pragma: no cover
skipped.append(
f"could not import the shipped validator ({exc}); the config check "
f"did NOT run. `app/validate.py` imports its siblings, and those need "
f"the add-on's runtime dependencies - so THIS SCRIPT ONLY CHECKS A "
f"CONFIG WHERE THOSE DEPENDENCIES EXIST: inside the add-on container, "
f"or in the CI image. Re-run it as:\n"
f" docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \\\n"
f" python3 /w/goodwe_controller/tools/predeploy_check.py \\\n"
f" --deployed-version <ver> --options /w/<options.json>")
else:
found = validate_startup_config(opts)
if found:
problems.append(
f"THE LIVE CONFIG IS REFUSED BY THIS VERSION'S VALIDATOR "
f"({len(found)} problem(s)). Deploying it is SystemExit(78): the "
f"add-on does not start, and the ingress panel goes with it because "
f"the add-on serving it is not running. Recovery is the Supervisor UI.")
for p in found:
print(f" - {p}")
else:
print(" live options pass this version's validator")
else:
skipped.append("--options was not given, so the live config was NEVER CHECKED "
"against this version's validator. A config refused at startup is "
"exit 78. Export the options and re-run before deploying.")
print()
for s in skipped:
print(f" ⚠️ SKIPPED: {s}")
if problems:
for p in problems:
print(f" ❌ {p}")
print("\n REFUSED - do not deploy.")
return 1
if skipped:
print(" NOT CLEARED - checks above did not run. Deploying now is a decision "
"made without them.")
return 2
print(" CLEARED for deploy.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,334 @@
"""UX-02: can this add-on read and write its OWN options, and what comes back?
⚠️ WHY THIS SCRIPT EXISTS. The UX-02 card asserts that `GET /addons/self/info`,
`POST /addons/self/options` and `POST /addons/self/restart` are permitted to an
add-on with `hassio_api: true` and no special `hassio_role`, and it says - in
its own words - "confirm this again in the PR by calling it, rather than citing
this card". A permission read off a regex in somebody else's repository is a
hypothesis; the whole configuration surface rests on it, so it is RUN here,
against the compiled patterns out of the release image this site runs.
It also answers the question the panel's design depends on next: WHAT the info
response carries. app/configui.py builds the entire form from `schema` and
`translations` and owns no copy of either, which is only sound if those two
really are in the response and really are the loader's own output. Both are
constructed here, out of the image, and this repository's own model is built
from them.
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
2026.08.0 is what 192.168.2.158 ran when UX-02 landed, and it is the same image
tools/supervisor_shape_probe.py pins):
docker run --rm \
-v "$PWD:/w:ro" -v "$PWD/app:/app:ro" -v "$PWD/tools:/probe:ro" \
--entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/probe/supervisor_selfapi_probe.py
⚠️ IT TOUCHES NOTHING, AND IT TOUCHES THE LIVE SITE LEAST OF ALL. It reads
config.yaml and translations/, imports Supervisor's own modules and runs them in
a throwaway container. It opens no socket, contacts no Supervisor, POSTs no
options and restarts nothing. The add-on is live in a real house; the only
honest way to measure this is against a copy of the software, not against it.
⚠️ WHAT IT CANNOT MEASURE, SAID OUT LOUD. It does not execute an HTTP request
against a running Supervisor, because doing that would mean pointing it at the
live one. So §A grades the ACCESS DECISION (the compiled patterns, run against
the exact paths) and §B grades the RESPONSE KEYS (the constants `info_data`
builds its dict from), rather than a captured response body. What is NOT
verified here is the wiring between the two - that the handler which builds that
dict is the one mounted at that path. That is read, not run, and this is the
sentence saying so.
"""
import copy
import json
import sys
sys.path.insert(0, "/usr/src/supervisor")
sys.path.insert(0, "/") # app/ is mounted at /app
import voluptuous as vol # noqa: E402
from pathlib import Path # noqa: E402
from supervisor.utils.yaml import read_yaml_file # noqa: E402
from supervisor.apps.validate import SCHEMA_APP_CONFIG # noqa: E402
from supervisor.apps.options import AppOptions, UiOptions # noqa: E402
from supervisor.apps.app import _OPTIONS_MERGER # noqa: E402
from supervisor.store.data import _read_app_translations # noqa: E402
from supervisor.const import (ATTR_OPTIONS, ATTR_SCHEMA, # noqa: E402
ATTR_TRANSLATIONS)
from supervisor.api.middleware.security import _V1_PATTERNS # noqa: E402
from app import configui # noqa: E402
FAILED = []
def show(title, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {title}")
if detail:
print(" " + str(detail)[:400])
if not ok:
FAILED.append(title)
# --------------------------------------------------------------------------- #
print("== A. THE PERMISSION, RUN RATHER THAN READ ==")
# ⚠️ THE PATTERNS ARE SUPERVISOR'S OWN COMPILED OBJECTS, imported, not retyped.
# `token_validation()` tests `api_bypass` against `request.path` for an
# installed app BEFORE it consults `access_hassio_api` or any role, so a match
# here is the whole permission question for these paths.
for path, want in (("/addons/self/info", True),
("/addons/self/options", True),
("/addons/self/restart", True),
("/addons/self/options/config", True),
# The two the pattern explicitly excludes, asserted so that
# a future widening of it does not go unnoticed here.
("/addons/self/security", False),
("/addons/self/update", False)):
got = bool(_V1_PATTERNS.api_bypass.match(path))
show(f"{path:32s} bypass={got}", got == want)
# ⚠️ THE ONE THE CARD DID NOT PREDICT, AND IT IS A NEGATIVE RESULT WORTH HAVING.
# Supervisor has its OWN options validator at `/addons/{app}/options/validate`.
# It is two path segments after `self`, so `[^/]+` cannot match it, and it is
# not one of the listed literals either. An add-on cannot call it. That does
# not hurt this card - the Supervisor validator knows only types and ranges, and
# the whole reason this panel exists is the cross-field rules it structurally
# cannot express - but "there is exactly one validator reachable from inside the
# add-on, and it is ours" is a fact the design should rest on knowingly.
_val = bool(_V1_PATTERNS.api_bypass.match("/addons/self/options/validate"))
show("/addons/self/options/validate is NOT reachable from an add-on - so the "
"ONLY validator this panel can call is app/validate.py, which is the one "
"with the cross-field rules", not _val, f"bypass={_val}")
# ⚠️ AND WHICH OF THE TWO MECHANISMS IS CARRYING EACH PATH - MEASURED, BECAUSE
# THE FIRST DRAFT OF THIS SECTION GOT IT WRONG. It asserted that the `default`
# role admits none of the three, and that FAILED: the default role's pattern is
# `^(?:|/.+/info)$`, which matches `/addons/self/info` perfectly well. So the
# split is:
#
# /addons/self/info - admitted by BOTH the bypass and the default role
# /addons/self/options - the bypass ONLY
# /addons/self/restart - the bypass ONLY
#
# i.e. the two WRITING paths rest entirely on the bypass list, and if it were
# narrowed this panel could still read its own options and could no longer save
# them. That is a more useful thing to know than the tidier claim that was
# asserted first, and it is only known because the claim was run.
_default = _V1_PATTERNS.role_access["default"]
show("`/addons/self/info` is admitted by the DEFAULT role as well as by the "
"bypass (`/.+/info`) - so reading has two independent licences",
bool(_default.match("/addons/self/info")))
show("...while `/addons/self/options` and `/addons/self/restart` are admitted "
"by the BYPASS ONLY. The two writing paths rest on that list alone",
not _default.match("/addons/self/options")
and not _default.match("/addons/self/restart"))
# --------------------------------------------------------------------------- #
print("\n== B. WHAT `GET /addons/self/info` IS BUILT FROM ==")
# `AppsAPI.info_data()` builds its dict with these constants as keys. Their
# VALUES are what the panel reads off the JSON, so they are asserted here
# rather than typed into app/selfconfig.py as a guess.
show("the response keys the panel reads are named `options`, `schema` and "
"`translations`",
(ATTR_OPTIONS, ATTR_SCHEMA, ATTR_TRANSLATIONS)
== ("options", "schema", "translations"),
f"{ATTR_OPTIONS} / {ATTR_SCHEMA} / {ATTR_TRANSLATIONS}")
# --------------------------------------------------------------------------- #
print("\n== C. `schema` - what App.schema_ui actually is, for THIS config.yaml ==")
raw = read_yaml_file(Path("/w/config.yaml"))
cfg = SCHEMA_APP_CONFIG(raw)
schema, defaults = cfg["schema"], cfg["options"]
ui = UiOptions(None)(schema)
show(f"UiOptions renders every key config.yaml declares ({len(ui)})",
len(ui) == len(schema), f"schema keys {len(schema)}, ui nodes {len(ui)}")
# ⚠️ THE NAME IS THE MEASUREMENT. A NUMERIC range comes back as `lengthMin` /
# `lengthMax` - which reads like a string length and is not - and app/configui.py
# copies those two names straight through into the form's `min`/`max`. Guessing
# `valueMin` would have produced a form with no bounds at all and no error
# anywhere.
_maxw = [n for n in ui if n["name"] == "max_w"][0]
show("a numeric range arrives as lengthMin/lengthMax (NOT valueMin/valueMax), "
"which is why app/configui.py reads those two names",
_maxw.get("lengthMin") == 100 and _maxw.get("lengthMax") == 5000,
json.dumps(_maxw))
_sel = [n for n in ui if n["name"] == "meter_source"][0]
show("an enum arrives as type `select` with its `options` listed",
_sel.get("type") == "select" and "homewizard_local" in _sel.get("options", []),
json.dumps(_sel))
_inv = [n for n in ui if n["name"] == "inverters"][0]
show("the roster arrives as type `schema` + `multiple`, carrying its per-entry "
"fields - which is what the panel's roster editor draws cards from",
_inv.get("type") == "schema" and _inv.get("multiple") is True
and [s["name"] for s in _inv["schema"]][:3] == ["name", "setpoint_entity",
"max_w"],
json.dumps([s["name"] for s in _inv["schema"]]))
# --------------------------------------------------------------------------- #
print("\n== D. `options` - and what a POST to /addons/self/options REPLACES ==")
# ⚠️ THE HAZARD THE COMPARE-AND-SWAP EXISTS FOR, DEMONSTRATED. `App.options` is
# `deepmerge(config.yaml defaults, the STORED user options)` and the setter is
# `persist[options] = deepcopy(value)` - a REPLACEMENT. So a key left out of a
# POST does not keep its previous value; it falls back to the config.yaml
# DEFAULT. Both halves are run below on real objects.
_persist_before = {"max_w": 4200, "gain": 0.42}
_effective_before = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
copy.deepcopy(_persist_before))
show("stored user options merge OVER the config.yaml defaults, so every key is "
"present in what an add-on reads back",
_effective_before["max_w"] == 4200
and set(_effective_before) == set(defaults),
f"max_w={_effective_before['max_w']} gain={_effective_before['gain']}")
_persist_after = {"gain": 0.42} # a POST that simply omits max_w
_effective_after = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
copy.deepcopy(_persist_after))
show("...and a POST that OMITS a key does not preserve it - the key reverts to "
"config.yaml's default. That is why this panel posts the WHOLE blob it "
"read, and why it refuses to post one built on a stale read",
_effective_after["max_w"] == defaults["max_w"] != 4200,
f"omitted max_w -> {_effective_after['max_w']} "
f"(default {defaults['max_w']}, was 4200)")
# The blob the panel would actually POST: the full merged options, unchanged.
# If Supervisor refused that shape the read-modify-write would be unusable.
_v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"],
"local_goodwe_controller")))
try:
_validated = _v(copy.deepcopy(_effective_before))
show("the WHOLE merged blob - what this panel sends back - is accepted by "
"Supervisor's own options validator", True)
except vol.Invalid as err:
_validated = None
show("the WHOLE merged blob is accepted", False, str(err))
# ⚠️ AND WHETHER IT COMES BACK BYTE-IDENTICAL, because the panel tells the
# operator which settings are "stored but not running" by comparing the stored
# blob with `/data/options.json`. Supervisor writes that file through
# `schema.validate(self.options)`, so any COERCION in that step would show up
# as a spurious difference on every page load.
if _validated is not None:
_drift = sorted(k for k in set(_validated) | set(_effective_before)
if _validated.get(k) != _effective_before.get(k))
show("...and validation does not COERCE it, so `stored vs running` compares "
"like with like and shows a difference only when there is one. "
f"Keys changed by validation: {_drift}", not _drift)
# --------------------------------------------------------------------------- #
print("\n== E. `translations` - UX-01's labels, reaching a SECOND surface ==")
translations = _read_app_translations(Path("/w"))
show("Supervisor's own loader accepts both files, so `info['translations']` "
"carries them", set(translations) == {"en", "nl"}, sorted(translations))
for lang, groups_expected in (("en", 7), ("nl", 7)):
model = configui.field_model(ui, translations, lang)
names = [g["name"] for g in model["groups"]]
show(f"[{lang}] this repository's own field_model() turns those into "
f"{len(names)} real section headings out of UX-01's `Group · ` prefix "
f"- which the Supervisor's flat list cannot render at all",
len(names) == groups_expected and not model["ungrouped"],
f"{names} ungrouped={model['ungrouped']}")
_raw = [f["key"] for g in model["groups"] for f in g["fields"]
if f["label_lang"] == "raw"]
show(f"[{lang}] ...and not one field falls back to its raw key name. "
f"Raw: {_raw}", not _raw)
_nohelp = [f["key"] for g in model["groups"] for f in g["fields"]
if not f["help"]]
show(f"[{lang}] ...and not one is left without the sentence that says "
f"whether to touch it. Missing: {_nohelp}", not _nohelp)
# ⚠️ THE ONE PLACE THIS PANEL IS STRICTLY BETTER THAN THE CONFIGURATION TAB ON
# THE SAME DATA, and shape_probe §F is the measurement that makes it a claim
# rather than a boast: on frontend 20250925.1 the roster's per-entry keys go to
# `ha-selector-object`, which labels them `fields[name].label || name`, and the
# converter sets no label - so they render RAW there, however well UX-01
# translated them. Here they are read out of the same `fields:` block and shown.
_model_en = configui.field_model(ui, translations, "en")
_roster = [f for g in _model_en["groups"] for f in g["fields"]
if f["key"] == "inverters"][0]
_sub = [(s["key"], s["label"], s["label_lang"]) for s in _roster["fields"]]
show("the roster's PER-ENTRY keys resolve to their UX-01 labels here, which is "
"exactly what the Supervisor's Configuration tab renders raw on frontend "
"20250925.1 (shape_probe §F)",
all(lang == "en" for _, _, lang in _sub) and len(_sub) == 7,
json.dumps([(k, l) for k, l, _ in _sub]))
# --------------------------------------------------------------------------- #
print("\n== F. the anchor list is the SCHEMA's keys, and collisions are real ==")
# app/configui.py files each validator sentence under every option key it
# NAMES, with word boundaries. Three of this schema's keys are suffixes of one
# another, so a naive `in` test would file every per-unit refusal under the site
# key as well. Run against the real key list rather than a hand-made one.
_keys = sorted(defaults)
_collide = [k for k in _keys if any(o != k and o.endswith("_" + k)
for o in _keys)]
show(f"config.yaml really does contain keys that are suffixes of other keys, "
f"so the boundary matching is load-bearing: {_collide}", bool(_collide))
_by, _un = configui.problems_by_field(
["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"], _keys)
show("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under "
"`sign_disagree_w`",
sorted(_by) == ["max_w", "unit_sign_disagree_w"] and not _un,
json.dumps(sorted(_by)))
# --------------------------------------------------------------------------- #
print("\n== G. COULD THE SUPERVISOR'S CONFIGURATION TAB BE REMOVED? ==")
# ⚠️ THE CARD SAYS IT CANNOT BE. THAT TURNS OUT TO BE THE RIGHT DECISION FOR
# THE WRONG REASON, AND THE DIFFERENCE MATTERS - a constraint you believe is
# imposed is one nobody re-examines, while a choice is something a decision
# record has to justify. So it was RUN.
#
# The tab renders `App.schema_ui`, which is `UiOptions(...)(data["schema"])`.
# Delete `schema:` from config.yaml and there is nothing to render. Supervisor
# ACCEPTS that manifest - the key is optional and defaults to `{}` - so the tab
# really would go away.
#
# What goes away with it is the add-on. `AppOptions` DROPS every option that
# the schema does not declare (one "Option 'x' does not exist in the schema"
# warning per key, and nothing else), so `App.options` validates down to the
# EMPTY DICT, `write_options()` writes `{}` to /data/options.json, and
# `load_options()` hands the controller nothing. The startup gate then refuses
# it for having no actuator - exit 78 - and the ingress panel this card builds
# is not running either, because the add-on serving it is not.
#
# So: removable, at the cost of every setting on the site and the add-on with
# them. That is the measured reason DECISIONS/UX-02-two-config-surfaces.md
# keeps the tab, and it is a much better reason than "the platform will not
# let us".
_noschema = {k: v for k, v in raw.items() if k != "schema"}
try:
_cfg2 = SCHEMA_APP_CONFIG(_noschema)
_accepted, _detail = True, repr(_cfg2.get("schema"))
except vol.Invalid as err:
_cfg2, _accepted, _detail = None, False, str(err)
show("a manifest with NO `schema:` block is ACCEPTED by Supervisor, and its "
"`schema_ui` is empty - so the Configuration tab is removable after all",
_accepted and _cfg2.get("schema") == {}
and UiOptions(None)(_cfg2["schema"]) == [], _detail)
if _accepted:
_v2 = vol.Schema(vol.All(dict, AppOptions(None, _cfg2["schema"],
_cfg2["name"], "local_x")))
try:
_out2 = _v2(copy.deepcopy(_cfg2["options"]))
_kept = len(_out2)
except vol.Invalid as err:
_kept = f"refused: {err}"
show("⚠️ ...and EVERY OPTION IS DROPPED WITH IT. Options not declared in "
"the schema are discarded with one log warning each, so the add-on "
"would start on an empty configuration, be refused by its own startup "
"gate for having no actuator, and take the ingress panel down with "
"it. That is why the tab stays",
_kept == 0, f"options surviving an empty schema: {_kept} "
f"of {len(defaults)}")
# --------------------------------------------------------------------------- #
print()
if FAILED:
print(f"== {len(FAILED)} CASE(S) FAILED ==")
for title in FAILED:
print(" - " + title)
sys.exit(1)
print("== all cases passed ==")
@@ -315,6 +315,114 @@ 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)")
# --------------------------------------------------------------------------- #
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
#
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
# HA's "show unused optional configuration options" toggle. The line above has
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
# displaying the number that contradicted the criterion it was evidence for.
# That is the defect, not the count.
#
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
# this section and replayed rather than paraphrased:
#
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
# || e.required)
#
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
# stored blob after `options:` has been deep-merged into it - so a key is
# hideable only when it is BOTH absent from `options:` AND not required.
#
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
#
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
# `required` on every non-`?` key, and the filter above shows a required
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
# is the required count below. It is nowhere near 15. No arrangement of
# `options:` can reach the target; the criterion was never satisfiable.
#
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
# absent from `options:` has nothing for the Supervisor to merge into an
# existing box's stored blob. That is exactly the `delivery_hold_s`
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
# cannot contain resolves to its `options:` default instead of failing
# validation, and moving it out of `options:` is precisely how you get the
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
# ledgers into the LOAD-BEARING source of those defaults on a live install
# - which is the CFG-03 defect the same card's AC 4 exists to police.
#
# So: four fewer clicks, bought with the upgrade path and with the defaults
# pin. The decision is written up in
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
# and the three assertions below are what keeps it a decision rather than an
# omission - if anyone empties `options:` to shorten the form, this goes red.
_hideable = sorted(set(schema) - set(defaults))
_required = [n["name"] for n in ui if n.get("required")]
def _filtered(opts):
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
return [n for n in ui if n["name"] in opts or n.get("required")]
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
f"is not partially met, it is unstarted. Hideable: {_hideable}",
not _hideable and len(schema) == len(defaults))
show(f"...so replaying the frontend's own _filteredSchema on the served options "
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
len(_filtered(defaults)) == len(ui))
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
# the required count to 20 would have passed while the sentence beside it stayed
# wrong. That is the same shape as the count this repo already fixed in
# `test_translations.py`: a number displayed but not graded.
#
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
# `UiOptions` marks optional because it is a list - and the assertion is an
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
_OPTIONAL = {k for k, v in schema.items()
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
_EXPECT_REQ == len(_required))
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
# deliberately NOT enough on its own: both sides read the same `?` convention,
# so marking a key optional moves BOTH and it stays green. That is correct - it
# makes the printed number impossible to leave stale - but it grades Supervisor
# agreeing with us, not the thing AC 3 was withdrawn for.
#
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
# enough keys ever became optional that the floor approached 15, the withdrawal
# stops being true and this file should say so rather than keep printing a
# sentence that has quietly become wrong.
_AC3_ASKED = 15
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
f"goes red the withdrawal needs re-arguing, not re-asserting",
_EXPECT_REQ > 2 * _AC3_ASKED)
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
# flag rather than a count typed here. `options:` is emptied and NOTHING else
# varies, so the answer cannot be about anything but the merge.
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
f"expressible - the tab still renders {len(_filtered({}))} required "
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
f"withdrawn as unsatisfiable, not deferred.",
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
def _entry(lang, name, path=()):
node = TRANSLATIONS.get(lang, {}).get("configuration")
+3 -3
View File
@@ -3,8 +3,8 @@
# ⚠️ 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
# The frontend keeps a field when `e.name in options || e.required`, and all 71
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
# rendered, always, in `schema:` order, with no description. This file is what
# turns that into sentences.
#
@@ -239,7 +239,7 @@ configuration:
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."
delivery_hold_s:
name: "Safety · Not-delivering report after (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus 1890 runs at one inverter as the control, which report nothing at any threshold): the worst legitimate episode those runs produce is 30.0 s, the floor of 30 s is the firmware's own link veto below which a unit is already being dropped from the split, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 6.2 times that, and the margin is sized on a measurement rather than a feeling: the second inverter in those tests is a copy of the first, and a second unit four times slower produces 90.0 s. Do not change it without re-deriving it. A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
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."
+1 -1
View File
@@ -219,7 +219,7 @@ configuration:
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."
delivery_hold_s:
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus 1890 tests met een omvormer als controlegroep, die bij geen enkele drempel iets melden): de langste terechte episode in die tests duurt 30.0 s, de ondergrens van 30 s is het eigen veto van de firmware waaronder een omvormer al uit de verdeling wordt gehaald, en de bovengrens is het factuurkwartier van 900 s. 185 s is 6,2 keer zoveel, en die marge is op een meting gebaseerd en niet op een gevoel: de tweede omvormer in die tests is een kopie van de eerste, en een tweede eenheid die vier keer trager is levert 90,0 s op. Verander dit niet zonder de afleiding opnieuw te maken. Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
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."