- not delivering: the body said control keeps running and then "start
control"; clearing it takes stop then start, and it now says so.
- peak reserve: the "reserve not set" branch is really soc_floor None,
i.e. an inverter's cut-out level missing; it now names that. The title
is true on all three branches ("Battery stopped limiting the peak").
- no semicolon run-ons in bodies (STYLE.md + test); loop-bound, circulating,
meter-frozen and multi-unit over-rating bodies rewritten to match.
- allowlist cap is now exact, so a shrink cannot leave room to regrow.
- DOCS names the settings behind "peak reserve" and "cut-out level";
DECISIONS no longer states the HA anchor behaviour as fact.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every HA notification the add-on raises now has a title of at most 5
everyday words and a one-sentence body; a red alarm may use three so no
action is lost. The texts live in app/messages.py, the rule in STYLE.md,
and the detail that moved out in DOCS.md "Notifications", which each body
links to. No notification_id, trigger or cadence changed.
test_messages.py enforces the rule on every notification, fails any
notification worded outside app/messages.py, and ratchets the kinds not yet
rewritten (rows, banners, refusals, logs, options) through a shrinking
allowlist of 200. Six tests re-pinned from prose to meaning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
A later anti-windup notice could replace a write-path BUG SIGNAL notice
under the shared goodwe_output_clamp id (escalation ignores the budget).
The WARNING now uses goodwe_integrator_bound and points to DOCS; the
cadence sentence in DOCS is corrected. Tests pin both ids (red on fd14569).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The clamp alarm gave `i_clamped` (normal anti-windup) the over-rating BUG
SIGNAL text, printed `Decision.i_w` as "the loop asked for", and printed
`self.target` (after slew/freeze) as "was clamped to".
- clamp.loop_clamp_note(): i_clamped says "the loop's integrator reached its
N W anti-windup bound" with this tick's command; clamped keeps BUG SIGNAL
with the requested command and the clamp's own result.
- Decision carries i_bound_w / requested_w / clamped_w, set at the clamp.
- i_clamped still counts and notifies (same id, cadence, SAFETY-41
escalation) but as a WARNING, amber row, plain title. A bug has its own
notification budget and its text is not overwritten within an episode.
- test_clamp.py part 9: red on the old code per defect; 5 mutations killed.
- DOCS.md alarm section (and its stale 30 s cadence), DECISIONS/SAFETY-40.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
A Core-only restart wipes persistent_notification and does NOT restart the
add-on (measured on TEST-23: the add-on's SAFETY-31 card was gone 4 min after
`docker restart test23-ha`, add-on StartedAt unchanged). The latched SAFETY-31
and SAFETY-42 cards were re-raised only on add-on start, so they stayed gone.
They are now re-asserted every REANNOUNCE_S = 300 s while tripped (same
notification_id, so it replaces). test_watchdog part 6 and test_delivery part
8b grade it with the same process across the "restart"; red on the old code.
The docstrings that said the add-on restarts with HA are corrected.
Record: goodwe docs/DECISIONS.md, SAFETY-34 D4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
A missing or comment-only .gitea/infra-pin now ends on the workflow's own
FAIL line instead of git's `fatal: path ... does not exist` (still red). Every
x-access-token remote URL becomes an env-set Authorization header, so no
token lands in .git/config or argv; the pin workflows' temp dir is removed
by an EXIT trap, also on failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The long-lived token no longer lands in $WS/goodwe/.git/config (mounted into
the tests' container, left behind by a failed job) or in git's argv. A missing
secret (fork PR) fails with its own message. README: addon-pin is not re-run
after a bump.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
- checks.yml's infra checkout and infra-pin.yml's admin/goodwe ls-remote use
the INFRA_READ_TOKEN secret; the self fetch keeps GITHUB_TOKEN.
- infra-pin.yml compares against admin/goodwe refs/heads/release/1.0; STALE
message, comments and README follow.
- test_control.py / test_alloc.py: drop the claim that CI cannot reach
admin/goodwe.
- .gitea/infra-pin is DELIBERATELY one release/1.0 merge behind (d84ac01) for
the "infra-pin / fresh goes red" proof; the next commit sets it to HEAD.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- .gitea/infra-pin: the one place the admin/goodwe commit is pinned.
- checks.yml: checks out admin/goodwe at the pin as the sibling test_freeze.py
looks for; any failure fails the job. Tests run with GOODWE_REQUIRE_HWSIM=1,
and `CI: ` lines are echoed on PASS so the log shows which transport ran.
- test_freeze.py: GOODWE_REQUIRE_HWSIM refuses the stub; a local stub run
prints a STUB USED line.
- infra-pin.yml: a separate, non-required check that goes red naming both
SHAs when admin/goodwe master has moved past the pin.
- README: when and how to bump the pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive
for consumption and negative for production. `pv_invert_entities` lists the
ones to flip; the flip is applied per member inside pv.total(), before the sum,
so the MQTT `pv` row and the status page carry the corrected figure. Default []
is byte-identical to 0.12.0.
Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0):
a parallel list validates the 0.12.0 blob unchanged and renders as its own
translated repeatable field; dicts on pv_entities refuse 0.12.0's stored
strings; a prefix is invisible syntax; a global bool cannot describe a mixed
roster. Status page marks flipped entities and cautions per entity below
-50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged.
Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Since SAFETY-61 a dead control loop writes 0 W and exits 70. The Supervisor
restarts a stopped add-on only when the per-install Watchdog toggle is on,
and it defaults to off. One step in DOCS.md "Before you start", with a
pointer to the DECISIONS file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Checks and prints that indexed an empty list, exhausted next(), or formatted
None killed the rest of their file (FCST-06). Read them through the new
named_absence.py (nth/first/field/given/present) and conjoin a presence term.
- every site in the brief, plus the family the new scan and a sweep found
- test_named_absence.py: scans every test_*.py for unguarded filtered-list
indexing, next() without a default and numeric formats on maybe-None
names; proven to bite; prints what it cannot see, counted
- test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the
two assemble() calls' arguments
- test_control.py: coverage table re-measured on runs graded to the end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
A done-callback on run_control()'s task logs the traceback at the death and
sets `stop`; amain() runs the ordinary wind-down (shutdown() ->
command_site_zero()) and main() exits 70 (EX_SOFTWARE). Normal stops and
run_control()'s refusal path are ignored by the callback (still 0 / 78).
hass.py: a malformed JSON body, a JSON non-object, and an undecodable error
body are an absence / a failed write, not an exception into the loop.
checks() gains a first "control loop" row, red past FIRMWARE_INGEST_VETO_S.
Why exit rather than restart in-process, and what the Supervisor actually
does on exit (restarts only with the per-install Watchdog toggle on, default
off): DECISIONS/SAFETY-61-control-task-death.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
predeploy_check.py and rehearse_roster.py now assert a positive property of
their input (a JSON object, most of whose top-level keys are known option or
store keys) and refuse anything else with the shape named. The `ha apps info
--raw-json` wrapper, a truncated or list-wrapped state file and a wrapped
config no longer produce "CLEARED" / "NO FINDINGS" with exit 0.
Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py
exits 1 when split-invariance FAILS; decode_bus_capture.py takes several
paths and exits 1 on a file that is mostly not frames.
Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
- DOCS.md / config.yaml: the watchdog's P1 age entity is
sensor.goodwe_p1_sample_age (device "GoodWe" + "P1 sample age", as
app/mqtt.py announces and the live install recorded), not
sensor.p1_sample_age_s.
- DOCS.md: the inverters: example and per-entry table carry the required
bms_soc_floor (and capacity_kwh); config.yaml's schema comment says four
required keys, not three.
- Field guide: marked v0.1.0/outdated; slew_w row no longer recommends the
1000 W that SAFETY-39 now refuses. HTML regenerated; the PDF is NOT
rebuilt (no Chrome here). README/DOCS pointers fixed.
- test_docs.py: the documented roster must pass app/validate.py and the
schema's per-entry keys; the documented entity id must equal the id
composed from app/mqtt.py; the wrong id may appear in no shipped doc.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
pv_entity becomes a roster: pv_entities beside it, replacing it, with the SITE total over the roster. One absent member makes the TOTAL absent - never the partial sum of the ones that answered, because a smaller number indistinguishable from a real one reads a week later as 'there was less sun than there was', which is the conclusion OPT-10 exists to test rather than to manufacture. A member honestly reporting 0 W is still summed.
Reviewed on PR 116. Re-run independently: supervisor_shape_probe.py SSG (exit 0, transcript reproduces byte for byte), the owner's real 75-key options blob through Supervisor 2026.08.0's own AppOptions (ACCEPTED, zero drift on any stored value, pv_entities defaulted to []), tel11_pv_trace.py --compare across both trees (every hash reproduces, control 846a10a5b89e unmoved on unset and one), the rejected-design mutant (trace REFUSED 5/5 with the partial sums named), the planted leak N8 (52/26/10 red cells), and eight mutations on fresh purged copies including N5 sum->max at +0 bytes - no survivors. Habit 24's central claim measured: TEL-11's own --compare refuses on every honest run now that release/1.0 is 0.11.0.
Green traced to this PR's own run: 829/1954, attempt 1, event pull_request, path checks.yml@refs/pull/116/head, payload naming PR 116 with base release/1.0 @ cccb2fa, 31 suites PASS in 7m53s - a real execution, not a laundered skip.
Not deployable as merged: config.yaml stays 0.11.0 and the CHANGELOG entry is PKG's call.
0.11.0 shipped `pv_entity` as a single string. The reference site's owner
reported within minutes of it going live that they have several PV inverters
and could configure one - which is ordinary for a multi-string roof, not a
property of one house. `pv_entities` is the roster and the row carries the
SITE total.
⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`,
and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL
ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no
irradiance" conclusion the channel exists to TEST. So `pv.total()` answers
`None` the moment any configured entity is absent: the topic carries HA's own
`None`, the entity goes to `unknown`, the recorder records a GAP. The
alternative (the partial sum beside a contributor count) was implemented and
run as mutation N1b; it loses because the MQTT row is read COLD a week later by
an attribution that would believe it, while the qualifying count would live in a
second entity with its own history. The partial information is not thrown away -
the STATUS PAGE names every entity that reported, with its watts, and every one
that did not, with its reason, because that surface is read LIVE by a human who
wants the diagnostic.
⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is
`is None`, never falsiness - a roster that blanked the site on a zero would
report an absence on every clear evening.
THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list:
tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an
0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and
deepmerge's list strategy is `override` so a `[]` default does not rescue it -
the live add-on would not start. `match(.*)?` does accept both shapes and is
rejected for a different, measured reason: it validates nothing (a string, a
list and the integer 417 all pass) and renders as a plain text box. So
`pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for
`inverters:`, with a status caution saying which key won - a union would
double-count the inverter the old key still names.
STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than
replaced and now grades five claims over three document sets. The control hash
is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the
roster control document is byte-identical between the two trees. 2/5 is a claim
TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly
as 0.11.0 does" is a real cross-tree identity over control AND telemetry.
⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit
24: a discriminator defined as "the baseline lacks this feature" dies on the
release that ships the feature, and dies as a false refusal.
Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B
(`sum` -> `max`, the same three characters) and was run on a fresh copy with
`cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO
contributions in two units with absence driven at EVERY position - a member
contributing 0 W could go absent without moving the sum of the rest, which is
habit 23 with a coinciding value in the arithmetic rather than in the plant.
31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0,
so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG
entry is wanted and deliberately not written - 11b forbids `## Unreleased` and
the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the
material it needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Bumps config.yaml 0.10.1 -> 0.11.0 and adds the `## 0.11.0` section, so the Supervisor offers the update that carries SAFETY-56's wording fix and TEL-11's `pv_entity`.
Reviewed at head b11bea7. Green traced to this PR's own run (827, attempt 1, payload names PR 114 base release/1.0, 31/31 real). Version gate 11a-11d, SAFETY-56's behaviour hash and TEL-11's four trace rows all re-measured on this tree and matching. slug unchanged; option surface 75 -> 76 with no validator rule added.
Two non-blocking findings recorded on the PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Self-review. The note added to `### SAFETY-57` said the reading rule it is
qualifying is "two paragraphs up"; it is in the paragraph directly above - the
same one the note follows. A changelog that misdirects a reader to a paragraph
that is not there is the small end of exactly the defect this cut is fixing
three sentences of elsewhere, so it is corrected rather than left.
Text only, one line pair, still two files in the branch's diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Two files, and no third. config.yaml 0.10.1 -> 0.11.0; CHANGELOG.md gains
`## 0.11.0` and three corrections to sections below it.
A MINOR BUMP, NOT A PATCH. TEL-11 adds an option - `pv_entity`, 75 keys -> 76 in
`options:` and `schema:` together - which is the same test 0.8.0, 0.9.0 and
0.10.0 were cut on. Nothing here can refuse a stored blob: the key is `str?`
with an `options:` default of "", validate.py does not mention it, no default
moved and no enum widened, so a blob that starts the add-on on 0.10.1 starts it
on 0.11.0.
THE TABLE HAS TWO ROWS AND THE WINDOW HAD FOUR MERGES. CI-9's follow-up
(9f6b9f1, `.gitea/workflows/checks.yml` only) and SEC-2 (239e120,
`docs/RELEASE.md` + README.md) are excluded under PKG-15's rule - AUDIENCE, NOT
IMPORTANCE. Neither file is under `goodwe_controller/`, neither is in the built
image, neither is in predeploy_check.py's RUNTIME tuple, and there is no version
of this add-on on which an operator can observe either. SEC-2 is the closer call
and the section argues it in full rather than asserting it: it is the more
consequential ticket of the two - it is why this release is a pull request
rather than a push to master - and it is still a procedure for whoever cuts a
release, not something the person deciding whether to press Update can act on.
Both are named in the section with pointers to PR #102 / checks.yml and PR #108
/ docs/RELEASE.md.
WHAT THE ENTRIES LET AN OPERATOR DECIDE. SAFETY-56: behaviour byte-identical
(6030175ed2ab..., reproduced in the CI image over 2390 capture reads, a 66-cell
grid stepped 120 times and six driven worlds), three surfaces named with the old
and new wording side by side - the STALE_READING line quoted onto the action log
and status page, the SAFETY-37 ERROR log line, and the readiness row that
asserted a false fact about a named entity. TEL-11: unset is byte-identical in
BOTH the control and telemetry documents, set is byte-identical in the control
document only; the three TEL-11 rows of that table were re-measured in the CI
image on this tree (control 846a10a5... twice, telemetry a443a6ce... / 4e6d1cb2...).
It is stated plainly that pv_entity is not a control input and does not change
what the loop does, and that absence publishes `unknown` rather than 0.
THREE CORRECTIONS BELOW THE NEW SECTION, WITHOUT REWRITING HISTORY. The 0.10.1
preamble said SAFETY-56 was open; the SAFETY-57 entry said its accusatory
diagnosis half was "deliberately untouched"; the 0.10.0 preamble left SAFETY-56
with no pointer while SAFETY-57 had one. All three quotations are what the
0.10.1 image actually prints and are left standing - a reader on 0.10.1 has to
recognise them - each now scoped to 0.10.1 and pointed at `## 0.11.0`.
VERIFIED. test_version_gate.py part 11 all four: 11a one contents table and the
slicer closed (302 lines under '0.11.0'), 11b 0.11.0 == config.yaml, 11c all
three RETIRED_PROVIDERS ids named - scoped to a pre-0.10.0 crossing and pointing
at `## 0.10.0`, which is where the evidence is - 11d still exactly one
`## Unreleased` below, PKG-08's, untouched. 31/31 in the CI image, checks.yml's
exact command, purged tree, `:ro`.
Not deployed and not installed. Shipping is a separate pull request from
release/1.0 into master, per docs/RELEASE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU