Compare commits

..
11 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 bacb700235 TEST-21: two of the argmax's four axes are on an edge, and the doc did not say so
checks / unit (pull_request) Failing after 2s
§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 19:54:37 +02:00
adminandClaude Opus 5 3625b44677 TEST-21: delivery_hold_s's floor re-derived, and 30.0 s did not reproduce
checks / unit (pull_request) Failing after 2s
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 19:07:09 +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
9 changed files with 1108 additions and 85 deletions
+122
View File
@@ -1,5 +1,86 @@
# 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.**
@@ -14,6 +95,47 @@ 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_ | 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
@@ -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
+43
View File
@@ -1572,3 +1572,46 @@ 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.
+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
+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.
+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())
+1 -1
View File
@@ -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."