Compare commits

...
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 6028e21298 SAFETY-34: re-assert latched alarms, because a Core restart keeps the add-on
checks / unit (pull_request) Successful in 8m55s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-09-26 12:37:47 +02:00
admin fe132515fe Merge pull request 'CI: bump infra pin to a1d0618 (admin/goodwe#57, TOOL-9)' (#127) from pin-bump-a1d0618 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 20:52:59 +02:00
glenn schrooyenandClaude Opus 5.5 00e9a7b4e1 CI: bump infra pin to a1d0618 (admin/goodwe#57, TOOL-9)
checks / unit (pull_request) Successful in 8m46s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:42:44 +02:00
admin 82f032e8cf Merge pull request 'TOOL-9: pin checks print FAIL under pipefail; job tokens via header' (#126) from TOOL-9 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 20:39:47 +02:00
glenn schrooyenandClaude Opus 5.5 b963153de6 Restore .gitea/infra-pin (revert the TOOL-9 proof throwaway)
checks / unit (pull_request) Successful in 8m45s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:20:19 +02:00
glenn schrooyenandClaude Opus 5.5 a2c571b20e THROWAWAY (TOOL-9 proof, reverted next): remove .gitea/infra-pin
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:08:22 +02:00
glenn schrooyenandClaude Opus 5.5 550bd6fa52 TOOL-9: pin checks print their FAIL line under pipefail; job tokens via header
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-09-25 19:07:34 +02:00
admin 6a8a5ffa27 Merge pull request 'CI-12: CI grades test_freeze.py against the real hwsim at a pinned admin/goodwe' (#125) from CI-12 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 18:19:43 +02:00
glenn schrooyenandClaude Opus 5.5 8ff42ea85d CI-12: .gitea/infra-pin to admin/goodwe release/1.0 HEAD (2d026df, goodwe#56 merged)
checks / unit (pull_request) Successful in 8m42s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-25 18:10:28 +02:00
glenn schrooyenandClaude Opus 5.5 841424463a CI-12 review: INFRA_READ_TOKEN via an env-set header, not a remote URL
checks / unit (pull_request) Successful in 8m42s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-09-25 18:01:15 +02:00
glenn schrooyenandClaude Opus 5.5 c63eda0b0f CI-12: restore .gitea/infra-pin to admin/goodwe release/1.0 HEAD (56a933e)
checks / unit (pull_request) Successful in 8m43s
infra-pin / fresh (pull_request) Successful in 1s
This reverts commit 54cc5263fb (the proof-2 throwaway).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:45:36 +02:00
glenn schrooyenandClaude Opus 5.5 54cc5263fb THROWAWAY (CI-12 proof 2): pin one release/1.0 merge behind (d84ac01); reverted next
checks / unit (pull_request) Successful in 8m43s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:36:36 +02:00
glenn schrooyenandClaude Opus 5.5 c493a6d67a CI-12: test_tariff.py no longer says CI cannot see the infra repo
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:27:06 +02:00
glenn schrooyenandClaude Opus 5.5 d0891a6bca CI-12: .gitea/infra-pin to admin/goodwe release/1.0 HEAD (56a933e), restoring the proof pin
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:10:47 +02:00
glenn schrooyenandClaude Opus 5.5 fe80047235 CI-12 round 2: INFRA_READ_TOKEN for admin/goodwe; the pin tracks release/1.0
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
- 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>
2026-09-25 16:08:55 +02:00
adminandClaude Opus 5.5 6e3399322a CI-12: CI grades test_freeze.py against the real hwsim at a pinned admin/goodwe
infra-pin / fresh (pull_request) Failing after 1s
checks / unit (pull_request) Failing after 2s
- .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>
2026-09-25 01:57:04 +02:00
admin 16c796fa30 Merge pull request 'TEL-14: PV sign declared per entity (pv_invert_entities)' (#124) from TEL-14-pv-sign into release/1.0 2026-09-25 01:30:33 +02:00
admin 20260e86ec Merge pull request 'DOC-5: tell installers to turn on the Supervisor Watchdog' (#123) from DOC-5-watchdog into release/1.0 2026-09-25 01:17:35 +02:00
adminandClaude Opus 5.5 7fa045af85 TEL-14 N1: trim DECISIONS (drop probe transcript, §1 to the reason), move the mutation ledger there with M2/M5
checks / unit (pull_request) Successful in 8m44s
test_pv.py part 11 now prints a one-line pointer instead of the 12-row table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 01:11:40 +02:00
adminandClaude Opus 5.5 93f349a6af TEL-14 B1: pin stray caution on the reported case and whitespace normalisation end to end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 01:10:13 +02:00
glenn schrooyenandClaude Opus 5.5 d71f7dbd35 Merge release/1.0 (43b2740) into DOC-5-watchdog to sync base
checks / unit (pull_request) Successful in 8m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:08:15 +02:00
admin 43b274035f Merge pull request 'TEST-26: a missing value in a check is a named FAIL, not a traceback' (#122) from TEST-26 into release/1.0 2026-09-25 00:57:20 +02:00
glenn schrooyenandClaude Opus 5.5 6dbe06e32e DOC-5: add the Watchdog step to README's Install section
checks / unit (pull_request) Successful in 8m31s
One line after the install step, linking to the DOCS.md paragraph that
gives the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:30:44 +02:00
glenn schrooyenandClaude Opus 5.5 7559d21eda TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
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
2026-09-25 00:25:24 +02:00
admin 3e38bafcee Merge pull request 'SAFETY-62/SAFETY-60: operator tools refuse input they cannot read' (#120) from SAFETY-62-refuse-unread-input into release/1.0 2026-09-25 00:25:22 +02:00
glenn schrooyenandClaude Opus 5.5 02a822af98 DOC-5: tell installers to turn on the Supervisor Watchdog
checks / unit (pull_request) Successful in 8m41s
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
2026-09-25 00:20:52 +02:00
admin 6e6527224a Merge pull request 'SAFETY-61: the control task's death ends the process, after the wind-down' (#121) from safety-61-control-task-death into release/1.0 2026-09-25 00:07:41 +02:00
glenn schrooyenandClaude Opus 5.5 770988c902 TEST-26: a missing value in a check is a named FAIL, not a traceback
checks / unit (pull_request) Successful in 8m44s
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
2026-09-25 00:06:58 +02:00
glenn schrooyenandClaude Opus 5.5 b06f1fad5f SAFETY-62 review: DOCS exit 3 at the command block (R1); recognise the auto store key (R2)
checks / unit (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:02:33 +02:00
glenn schrooyenandClaude Opus 5.5 71d2d4a37a SAFETY-61: the control task's death ends the process, after the wind-down
checks / unit (pull_request) Successful in 8m22s
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
2026-09-24 23:52:42 +02:00
admin fa3d237ce1 Merge pull request 'DOC-1: operator docs name the real P1 entity and a roster that starts' (#119) from doc-1-operator-docs into release/1.0 2026-09-24 23:42:13 +02:00
glenn schrooyenandClaude Opus 5.5 f4f72211a9 SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s
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
2026-09-24 23:27:50 +02:00
adminandClaude Opus 5.5 29ca9b5157 DOC-1: operator docs name the real P1 entity and a roster that starts
checks / unit (pull_request) Successful in 8m8s
- 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
2026-09-24 23:24:50 +02:00
adminandClaude Opus 5.5 a45ed06c05 PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0 (#117)
checks / unit (pull_request) Successful in 8m0s
Reviewed at head 1ad4d3a. CI run 830 (attempt 1, pull_request, PR 117 -> release/1.0), 31/31 PASS. Review: #issuecomment-1588, #issuecomment-1589.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:09:28 +02:00
glenn schrooyenandClaude Opus 5.5 1ad4d3a5bb PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0
checks / unit (pull_request) Successful in 8m5s
One runtime merge since 0.11.0 (cccb2fa): TEL-12 (#116), a new optional
list option `pv_entities` beside `pv_entity`. Option surface 76 -> 77, so
a minor bump. config.yaml version and a closed `## 0.12.0` section only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:59:44 +02:00
admin f863ecaa8c TEL-12: several PV inverters, and one absent member is an absent TOTAL (#116)
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.
2026-09-24 22:41:19 +02:00
adminandClaude Opus 5 0ef9565071 TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
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
2026-09-24 22:13:25 +02:00
adminandClaude Opus 5 cccb2fa07b PKG-17: cut 0.11.0, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#114)
checks / unit (pull_request) Successful in 8m1s
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
2026-09-24 19:20:06 +02:00
adminandClaude Opus 5 b11bea70d3 PKG-17: the SAFETY-57 correction pointed at the wrong paragraph
checks / unit (pull_request) Successful in 8m13s
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
2026-09-24 19:01:35 +02:00
adminandClaude Opus 5 cfafdffaed PKG-17: cut 0.11.0, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1
checks / unit (pull_request) Successful in 8m0s
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
2026-09-24 18:50:13 +02:00
admin 94da37fc72 TEL-11: PV production stops being invisible, and stays out of the control law (#113)
Reviewed on the PR. Green traced to run 825 (PRNUM 113, BASEREF release/1.0, attempt 1, real 8m6s execution). Control-path identity independently reproduced on both trees and counter-tested with a planted leak (red on 26/41 cells); all three of the trace tool substituted refusals fire. M7 genuinely red. Three publication states distinct on the wire. 31/31 in the CI image.

Not deployable as merged: config.yaml stays at 0.10.1, so predeploy_check.py reports a NO-OP until PKG cuts a version.
2026-09-24 15:55:29 +02:00
glenn schrooyenandClaude Opus 5 07ba3433ed TEL-11: PV production stops being invisible, and stays out of the control law
checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.

AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.

POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.

ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.

THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.

GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.

IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:

    control   release/1.0 unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      set    846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    telemetry release/1.0 unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      set    4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f

UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.

⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.

Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.

⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.

No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading
that is not config.yaml's `version:`, and opening one would mean taking that
same packaging decision. The operator account is in DOCS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 15:35:07 +02:00
admin 9f6b9f14cd CI-9 follow-up: the summary said "mechanically", the blind spots say otherwise (#102)
Corrects the preamble's claim that the `guard the trigger` step keeps checks.yml filter-free "mechanically". It does not, for blind spots 1 and 3: the guard lives inside the workflow a filter makes Gitea skip, so for those the defence is the comment.

Names the filter that would actually get written - `branches: [release/1.0]`, not `[main]` - because it reads as tightening onto the protected branch, leaves every day-to-day pull request green, and switches off the `release/1.0` -> `master` release pull request that SEC-2 made the mandated path to an installed system. Points the forge fix at both protected branches, not just `release/1.0`.

Reviewed on the pull request. Verified from the branch's bytes: the guard's awk is unchanged and still refuses `branches:`, `paths:`, `paths-ignore:`, `branches-ignore:`, `tags-ignore:`, `types:` and the inline-flow form injected into the `on:` block of the edited file; comment-only at 0 non-comment changed lines, non-comment lines identical 139 == 139; the merged tree differs from c9aaf7f in this one file only. Green traced per CI-10 to run 824 / job 1949, payload PRNUM 102 / BASEREF release/1.0, base c9aaf7f, 30 PASS 0 FAIL.
2026-09-24 14:37:15 +02:00
glenn schrooyenandClaude Opus 5 8f9c2cad57 CI-9 follow-up: the skip direction, written the right way round
checks / unit (pull_request) Successful in 8m37s
`every subsequent pull request matching it is skipped too` is the inverse of
the behaviour. A pull request that MATCHES a `branches:` filter still RUNS;
the one the filter EXCLUDES is what Gitea sits out, reports `skipped`, and the
rollup launders into `success`.

The error arrived with the generalisation in the previous commit: `33e17db`
said "every subsequent pull request is skipped too", which was unqualified and
true for the `[main]` case it described. Naming the wider hazard made a
qualifier necessary, and the wrong one went in - contradicting the paragraph
directly below it, which describes `[release/1.0]` correctly as leaving every
day-to-day PR running and green.

Corrected, and the direction is now stated explicitly with the failed draft
recorded, because this file already carries one instance of the same shape:
"That was wrong, it contradicted the checkout step's own note a few lines
below, and the error was not cosmetic."

Guard re-verified on the edited bytes: passes here, still refuses
`branches: [release/1.0]` added to the `on:` block. Comment-only, 0
non-comment lines changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 14:27:19 +02:00
admin c9aaf7f04d SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can (#112)
Text only, no behaviour change. Three operator-facing sentences stop asserting a dead register where the gate cannot distinguish a dead register from a pack the BMS is refusing.

Reviewed on the PR: behaviour byte-identical (trace tool sha256 6030175ed2ab reproduced on both trees, plus AST identity with string constants normalised); five mutations re-applied on purged trees, all matching claimed named-red counts; SoC of the live episode independently measured at 11 percent; CI green traced to PRNUM 112 / BASEREF release/1.0.
2026-09-23 21:27:40 +02:00
adminandClaude Opus 5 8a6acd9fa2 SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can
checks / unit (pull_request) Successful in 8m0s
Text only. No behaviour change - same conjunction, same limits, same MISSING,
same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390
capture reads, a 66-cell swept grid, and six driven controller worlds produce
the byte-identical document `6030175e...` on origin/release/1.0 and on this
branch, while the sentences differ.

THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the
register has stopped reporting reality, or the SOURCE cannot comply with the
command - and nothing `freshness.py` is handed separates them. Three sentences
asserted the first. On the live 2026-09-21 episode it was the second: the pack
read 11 % for the whole 850 s hold, every command in that hold was a discharge,
and the register answered the first CHARGE command three seconds later. The
readiness row was worse still, because it asserted a false fact about a NAMED
entity that was present, numeric and updating - so the operator opened
Developer Tools, found it working, and had every reason to conclude the
diagnostics were broken.

THE FIX. Each message states what was OBSERVED and names BOTH explanations.
Three wordings, no shared helper: two are a log line about a behaviour, the
third is a banner about an entity, and a helper serving both would drop
whichever half the other caller does not need. SoC stays STATED, NOT WIRED -
part 11b drives the two worlds at 50 % and 11 % and the sentence does not move
one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()`
already rides `liveness_notes()` a few rows below, so the discriminator is "is
that row there?", and part 11d asserts it is present in one world and absent in
the other.

THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground
truth written down rather than derived from the published stream, asserts they
are really two (40 of 40 reads differ) while the published stream is
bit-identical, and only then grades the sentences. Eleven mutations, all red:
each of the three sentences reverted (3, 2 and 2 named reds), the two
explanations transposed at ZERO bytes (1), the second explanation deleted (1),
the admission of ambiguity replaced by a ranking (1), all three reverted at
once (5), and two aimed at this card's own check - collapsing the world pair
(2) and emptying the accusation list, which a positive control against the
three sentences as 0.10.1 shipped them catches (1).

AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE.
Transposing `label` and `entity` in the row made it unfindable and the run died
on an IndexError - a red with no name and every later check ungraded, FCST-06's
shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3
after. And the first invariance run compared a tree with itself, because it
archived a branch whose edits were not committed yet; the tool now REFUSES to
report "behaviour unchanged" unless the sentences halves differ, and both of
its refusals are driven.

SAFETY-57's part 10h check was the handoff and is inverted rather than deleted:
it required the accusation to still be present so that removing it had to be a
deliberate act. This is that act. Its escape clause is untouched.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 21:02:40 +02:00
glenn schrooyenandClaude Opus 5 628279a68a CI-9 follow-up: name the filter that actually gets written, not the one that looks wrong
checks / unit (pull_request) Successful in 7m58s
Three sentences in the blind-spot clause went stale between writing and review.

`no pull request in 99 has ever targeted master` is no longer true: SEC-2 made
`release/1.0` -> `master` the mandated release path and protected `master`
with the same required context, and a PR against that base has run for real.
The census it came from also erases its own evidence - PR #102 was itself
opened against `master` before retargeting, which the /pulls listing no longer
shows, exactly as the caveat 25 lines above this clause warns.

The clause also named `branches: [main]` as the hazard. That is the version
that looks wrong. The one someone would actually write is
`branches: [release/1.0]` - it reads as tightening onto the protected branch,
every day-to-day PR keeps running green, and the only thing it switches off is
the release PR whose content reaches an installed system.

And the prescribed fix named only `release/1.0`'s status_check_contexts. Since
SEC-2 `master` carries the same rule, so a reader following it literally would
half-close the hole and believe it shut.

Guard re-verified against the edited bytes: passes on this file (whose new
prose quotes `branches: [release/1.0]` in a comment) and still refuses a real
filter added to the `on:` block. Comment-only: 0 non-comment lines changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 20:18:50 +02:00
admin 239e120bf6 SEC-2: write down the release path, now that master refuses a direct push (#108)
Reviewed on the PR (issuecomments 1480, 1485, 1488, 1489). Documentation only: docs/RELEASE.md (new, 116 lines) plus a 2-line pointer in README.md's file map. No code, no workflow change.

Verified against the forge rather than accepted from the document:
- Both branch_protections rules re-read field-for-field identical (enable_push false, enable_force_push false, status check `checks / unit (pull_request)`, required_approvals 0, block_admin_merge_override true, no whitelists, no file patterns); master's rule created 2026-09-23T03:09:21; default_branch still master. Section 2's After table is exact. No protection was changed by this review - both rules re-read with their original updated_at.
- The empty-PR 405 trap reproduced independently on my own control (throwaway PR #111 between two same-SHA unprotected branches): empty PR -> 405 "Please try again later"; same PR after one commit -> 409 "head out of date". Section 1's "read the message, not the code" is correct and both messages are quoted accurately. PR #111 closed, branches deleted.
- release/1.0 (b0b24e5) is an ancestor of master (0f14c2a), so a literal release PR is empty right now - PR #109 has 0 commits, 0 files, and Gitea opened it anyway. Section 1's closing note is right.
- A master-base PR gets a genuine CI run, not a laundered skip: PR #107 head cb7e45b3 shows Waiting -> In progress -> success, "Successful in 7m56s", run 814. This PR's own head 9d593429 likewise, run 818.
- All five required steps are present and correctly ordered: work merges to release/1.0 with a version bump in goodwe_controller/config.yaml; release via PR release/1.0 -> master; direct push refused; green check before merge; `ha store reload` after, without which the Supervisor keeps serving the cached version. required_approvals: 0 is stated twice, so no reader can believe the dev/review split is enforced.

One non-blocking defect left open (issuecomment 1485): section 1 calls 0f14c2a "the only commit on master that carries no (#NN) merge suffix". 274 of master's 297 commits carry no suffix. The true statement is stronger - no release has ever reached master through a pull request, and 6f5e8e3 (0.10.0) was promoted by the same direct push one release earlier, so 0.10.1 was the second, not a one-off. Background, not procedure; wants a follow-up line change.

A third comment of mine (issuecomment 1488, merge style) is withdrawn in issuecomment 1489 - it rested on a premise I had not checked and which is false.
2026-09-23 04:08:18 +02:00
glenn schrooyenandClaude Opus 5 9d593429d3 SEC-2: write down the release path, now that master refuses a direct push
checks / unit (pull_request) Successful in 7m56s
master is the default branch, which is what the Home Assistant Supervisor
clones - so whatever sits on it runs on a real 5 kW inverter. Until today it
had no branch protection at all, while every gate this repo built sat on
release/1.0, the branch that does not ship. 0.10.1 went straight onto master
on 2026-09-22 with no PR, no CI and no reviewer, and it succeeded.

master now carries the same rule as release/1.0 - enable_push false,
required context `checks / unit (pull_request)`, block_admin_merge_override,
required_approvals 0. That is a forge setting and cannot live in a commit, so
the part that can is this: the procedure it implies. Without it the next
release is attempted the old way and fails confusingly at the push.

docs/RELEASE.md records the path (merge to release/1.0, bump version, PR
release/1.0 -> master, wait for green, merge, then `ha store reload` before
the Supervisor offers anything), the before/after protection state of both
branches, and how the gate was proven to bite rather than merely badge green.

Documentation only - no code, no workflow change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 03:43:37 +02:00
admin b0b24e5d41 PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0 (#105)
checks / unit (pull_request) Successful in 7m57s
Reviewed on the PR (issuecomments 1455-1462): identity verified unchanged (slug goodwe_controller, no image: key, installed slug 68285ac7_goodwe_controller corroborated from HA backup metadata); version-gate 11a-11d re-implemented and verified independently; the 16-of-93-at-790s pairing re-derived from the committed 16-day capture (>=710: 17, >=790: 16, >=815: 15); the SAFETY-57 entry verified as a faithful move carrying only the three declared #104 documentation fixes; corrected 0.10.0 preamble corroborated to the day against backup metadata; suite 30/30 reproduced with checks.yml's exact command. Two files, no control code. The 710 s/790 s split between test_freshness.py and this file is a known, stated gap and takes its own card.

NOTE: master (the Supervisor's default branch) is still 0.10.0 and still lacks SAFETY-57. This merge does not deliver; bringing master to release/1.0 is a fast-forward and is the remaining step.
2026-09-22 15:52:25 +02:00
glenn schrooyenandClaude Opus 5 33e17dba0e CI-9 follow-up: the summary said "mechanically", the blind spots say otherwise
checks / unit (pull_request) Successful in 7m57s
The residual-risk paragraph at the top of checks.yml claims the `guard the
trigger` step "keeps it that way mechanically rather than by asking the next
editor to read this comment". For blind spots 1 and 3 that is not true: the
guard lives inside the workflow a filter makes Gitea skip, so for those cases
the defence IS the comment.

That paragraph is the preamble someone reads at the exact moment they are
editing the trigger, so a summary running ahead of its detail is worse here
than elsewhere. It now points at the blind spots and states spot 3 plainly:
a filter naming a branch nothing targets is itself skipped, lands green and
untested, and then skips every subsequent pull request - the gate off for
everything, silently.

Flagged by the reviewer on PR #95 (issuecomment-1424) as non-blocking.

This PR is also the first real test of CI-9's fix on release/1.0 itself. Run
806 was a workflow_dispatch and posts no commit status at all, so it verified
content but never the gate. This one has to post `checks / unit
(pull_request)` as success rather than skipped.

Verified before push: the guard passes against this edited file (whose new
prose quotes `branches: [main]` and `master`) and still refuses a genuine
filter added to the `on:` block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 15:53:01 +02:00
59 changed files with 7878 additions and 392 deletions
+2
View File
@@ -0,0 +1,2 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
a1d0618eb339b1d4841cd31d6399b67a22b9dfee
+64 -13
View File
@@ -110,6 +110,37 @@ name: checks
# trigger` step below then keeps it that way mechanically rather than by asking
# the next editor to read this comment.
#
# ⚠️ READ THE BLIND SPOTS ON THAT STEP BEFORE TRUSTING THE SENTENCE ABOVE.
# "Mechanically" holds only for a reintroduced filter that still names the
# PR's own base. For blind spots 1 and 3 the defence IS this comment, because
# the guard lives inside the workflow that a filter makes Gitea skip. Blind
# spot 3 is the one that matters: a filter naming a branch THIS pull request
# does not target is itself skipped, lands green and untested, and then every
# subsequent pull request the filter DOES NOT NAME is skipped too. That is the
# gate off, silently.
#
# ⚠️ THAT DIRECTION IS THE WHOLE POINT AND IT IS EASY TO WRITE BACKWARDS. A
# pull request that MATCHES the filter still runs; it is the one the filter
# excludes that Gitea sits out, reports `skipped`, and the rollup launders
# into `success`. An earlier draft of this very clause said "matching it is
# skipped", which is the inverse of the measurement and contradicted the
# paragraph directly below.
#
# ⚠️ AND THE DANGEROUS ONE IS NOT `branches: [main]`, WHICH AT LEAST LOOKS
# WRONG. It is `branches: [release/1.0]`, which looks like tightening onto the
# protected branch and is what a careful person would write. Every day-to-day
# pull request still runs and still goes green, so nothing appears broken -
# and the only thing switched off is the `release/1.0` -> `master` release
# PR, i.e. the one whose content reaches an installed system. SEC-2 made that
# PR the mandated release path (docs/RELEASE.md), so this is now a live route
# rather than a hypothetical one.
#
# Closing it needs a SECOND permanently unfiltered workflow posting its own
# context, with that context added to the `status_check_contexts` of BOTH
# `release/1.0` AND `master` - both carry the rule since SEC-2, and adding it
# to one alone leaves the hole open on the other. A forge change, with the
# owner.
#
# THE MEASUREMENT, because the obvious guess is wrong and was nearly written
# here as fact. A skip-ci keyword in a commit message (`[skip ci]`, `[ci skip]`,
# `[no ci]`, `[skip actions]`, `[actions skip]`) does NOT behave like a filter
@@ -255,8 +286,9 @@ jobs:
run: |
set -eu
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
git init -q .
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
git init -q . && git remote add origin "$URL"
# Token as an env-set header: not in argv or .git/config (TOOL-9).
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN"
if [ -n "$PRNUM" ]; then
# Two fetches, not one, so that a failure to reach either ref fails
# HERE with the ref in the message, rather than inside the merge
@@ -457,6 +489,31 @@ jobs:
fi
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
# CI-12: the infra repo at the ONE pinned commit in .gitea/infra-pin (read
# from the merged tree), as the sibling test_freeze.py looks for. Any
# failure here fails the job; the tests step below refuses the stub.
- name: check out admin/goodwe at the pin in .gitea/infra-pin
env:
URL: ${{ github.server_url }}/admin/goodwe.git
# Read access to admin/goodwe only; this job's own token cannot see it.
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
run: |
set -eu
# `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9).
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
git init -q .
# The token goes in a header via env: not in argv, and not in .git/config,
# which is mounted into the tests' container and outlives a failed job.
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
git fetch -q "$URL" "$PIN" || { echo "FAIL: could not fetch admin/goodwe at $PIN."; exit 1; }
git -c advice.detachedHead=false checkout -qf FETCH_HEAD
test "$(git rev-parse HEAD)" = "$PIN"
test -f sim/hwsim.py || { echo "FAIL: admin/goodwe@$PIN has no sim/hwsim.py."; exit 1; }
echo "infra: admin/goodwe @ $(git log --oneline -1)"
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
run: |
set -eu
@@ -477,16 +534,9 @@ jobs:
# shorter timeout - a 60 s limit was tried during CI-01 and reported
# exactly the false hang the docstring predicts.
#
# ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would
# otherwise over-claim. test_freeze.py's third section wants the infra
# repo's `sim/hwsim.py` to drive a real HTTP meter through the real
# transport. admin/goodwe is PRIVATE (checked via the API) and this
# workflow's GITHUB_TOKEN is minted for THIS repository only, so hwsim is
# absent here and `find_hwsim()` returns None. The rest of the file runs
# and passes. It does so SILENTLY - the file prints nothing to say the
# hwsim section was skipped - so "test_freeze.py: PASS" below is a weaker
# claim than it looks. That belongs to whoever owns test_freeze.py next;
# naming it here is the least this gate can do about it.
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
# ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE
# HOLE. TEST-15 measured this runner's actual exposure and found none: the
# checkout below is bind mounted `:ro`, so the interpreter physically
@@ -502,7 +552,7 @@ jobs:
- name: the add-on's own tests
run: |
set -eu
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
cd /w/goodwe-addon/goodwe_controller
set -- test_*.py
if [ "$1" = "test_*.py" ]; then
@@ -515,6 +565,7 @@ jobs:
start=$(date +%s)
if python3 "$f" > /tmp/out 2>&1; then
echo "PASS $f ($(( $(date +%s) - start ))s)"
grep "^CI: " /tmp/out || true
else
echo "FAIL $f ($(( $(date +%s) - start ))s)"
tail -40 /tmp/out
+46
View File
@@ -0,0 +1,46 @@
# CI-12: goes red when admin/goodwe release/1.0 has moved past .gitea/infra-pin.
# It runs on add-on activity only; admin/goodwe's own `addon-pin` workflow
# covers a move of release/1.0 there. Deliberately NOT a required check: it
# reports; `checks / unit` stays the only gate. How to bump: README.md,
# "CI and the infra pin".
name: infra-pin
on:
pull_request:
push:
branches: [release/1.0]
jobs:
fresh:
runs-on: [self-hosted, goodwe-rig]
timeout-minutes: 30 # seconds of work; the rest is queueing for the shared slot
steps:
- name: compare .gitea/infra-pin with admin/goodwe release/1.0
env:
SELF: ${{ github.server_url }}/${{ github.repository }}.git
INFRA: ${{ github.server_url }}/admin/goodwe.git
TOKEN: ${{ secrets.GITHUB_TOKEN }}
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
run: |
set -eu
D=$(mktemp -d) && trap 'cd / && rm -rf "$D"' EXIT && cd "$D"
git init -q .
# The pin of the commit this event is about (the PR head, or the push).
# Token as an env-set header: not in argv or .git/config (TOOL-9).
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
git fetch -q --depth 1 "$SELF" "$GITHUB_SHA" || { echo "FAIL: could not fetch $GITHUB_SHA here."; exit 1; }
# `|| true`: under the runner's pipefail a missing or comment-only pin
# would stop here on git's error instead of the FAIL line below (TOOL-9).
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]') || true
test -n "$PIN" || { echo "FAIL: .gitea/infra-pin is missing at $GITHUB_SHA, or holds only comments."; exit 1; }
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
# Header via env, as in checks.yml: the token stays out of argv.
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1) || true
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
if [ "$PIN" != "$HEAD" ]; then
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
echo " Test against $HEAD and bump the pin (README.md, \"CI and the infra pin\")."
exit 1
fi
echo "OK: .gitea/infra-pin $PIN is admin/goodwe release/1.0 HEAD."
+6 -1
View File
@@ -4,6 +4,10 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py`
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins.
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
add-on install and configuration, how to read the Web UI, the six commissioning
gates, the e-stop (and the statement a site without one must sign), proving a
@@ -18,7 +22,8 @@ copy cannot drift from the PDF someone is holding in a cellar.
python3 docs/build_guide.py # regenerates field-guide.html and the PDF
```
Requires Chrome or Chromium (used headless for print-to-PDF). Screenshots live
Requires Chrome or Chromium (used headless for print-to-PDF); `CHROME` in the
script is a Windows path, so edit it on any other machine. Screenshots live
in `docs/img/` and are base64-embedded, so `field-guide.html` is standalone.
⚠️ **Every section must stay under the A4 printable height** (1039 px at 96 dpi
+14 -3
View File
@@ -30,9 +30,11 @@ goodwe_controller/ the add-on
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is
protected, releases go via PR from `release/1.0`
```
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
@@ -48,6 +50,9 @@ https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
then install **GoodWe RS485 Controller**.
Then turn on **Watchdog** on the add-on's Info tab — see
[Before you start](goodwe_controller/DOCS.md#before-you-start).
⚠️ **The repository must be PUBLIC for this to work.** Home Assistant clones it
anonymously; against a private repo the store shows nothing and gives no useful
error. If it must stay private, install locally instead: copy
@@ -77,8 +82,8 @@ machine itself dying:
2. **Wind-down before firmware updates** — 0 W written before the update starts.
3. **RS485 e-stop (optional)** — writes 0 W after 30 s of total bus silence.
Sites sold without the e-stop must have the acknowledgement in `FIELD-GUIDE.md`
§10 signed.
Sites sold without the e-stop must have the client acknowledgement in the field
guide (Step 7b) signed.
## Development
@@ -100,6 +105,12 @@ has the mechanism, the measurement, and what the flags do not do. They are not u
each assertion corresponds to a rule whose absence produced an observed failure
on real hardware, and the comments in `control.py` say which.
### CI and the infra pin
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py`, with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
## Compatibility
**Check the serial number, not the model name.** Characters 6-8 of the serial are
+116
View File
@@ -0,0 +1,116 @@
# Releasing — how a change reaches the battery
`master` is the **default branch**, and the default branch is what the Home
Assistant Supervisor clones when it installs or updates this add-on. Whatever
sits on `master` is what runs on a real 5 kW inverter. Treat a push to `master`
as a deployment, because that is what it is.
---
## 1. `master` is protected — releases go through a pull request
Until 2026-09-23 `master` had **no protection of any kind**. Every gate this
repo built (CI-01, CI-9) sat on `release/1.0`, the branch that does *not* ship.
On 2026-09-22 version 0.10.1 went straight onto `master` — no pull request, no
CI run, no reviewer — and it succeeded (commit `0f14c2a`, the only commit on
`master` that carries no `(#NN)` merge suffix). SEC-2 closed that.
**The release path is now:**
1. Merge the work into `release/1.0` as usual, via a pull request that goes
green. Bump `version:` in `goodwe_controller/config.yaml` on that branch —
the Supervisor keys the built image by `version:`, so without a bump nothing
is offered and nothing changes.
2. Open a pull request **`release/1.0` → `master`**. Do not push to `master`;
it will be refused by the pre-receive hook:
```
remote: error: Not allowed to push to protected branch master
! [remote rejected] ... (pre-receive hook declined)
```
3. Wait for `checks / unit (pull_request)` to go **green** on that pull request.
The workflow in `.gitea/workflows/checks.yml` carries **no `branches:`
filter**, so a `master` base matches and a real run happens — verified on
throwaway PR #107 (base `master`), which queued a genuine run.
⚠️ Green means green. A `skipped` status is laundered into a `success`
rollup by this Gitea and the merge gate accepts it — see the long note at
the top of `.gitea/workflows/checks.yml`. Never add an event filter there.
4. Merge the pull request. `required_approvals` is **0**, so a green check is
the whole gate; there is no second pair of eyes unless someone asks for one.
5. **Reload the store.** Merging to `master` does not, by itself, offer the
update. The Supervisor only sees the new version after:
```bash
ssh root@192.168.2.158 "ha store reload && ha addons update local_goodwe_controller"
```
Until `ha store reload` runs, the add-on page still shows the old version
and the release looks like it did nothing.
Note that `release/1.0` is normally an *ancestor* of `master` between releases,
so a `release/1.0 → master` pull request is empty until new commits land on
`release/1.0`. That is expected; it means there is nothing to release yet.
---
## 2. Branch protection — before and after SEC-2
Forge is Gitea at `192.168.2.147:3000`, repo `admin/goodwe-addon`. Read it back
with `GET /api/v1/repos/admin/goodwe-addon/branch_protections`.
**Before (2026-09-23):**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | **no rule at all** | — | — | — |
| `release/1.0` | yes | yes | `checks / unit (pull_request)` | 0 |
**After:**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | yes | yes | `checks / unit (pull_request)` | 0 |
| `release/1.0` | yes (unchanged) | yes | `checks / unit (pull_request)` | 0 |
Both rules are otherwise identical: `enable_push: false`,
`enable_force_push: false`, `block_admin_merge_override: true`, no whitelists,
no file patterns. `required_approvals` stays at **0** on purpose — raising it
would block the review agent from merging, and nobody asked for it.
The default branch was **not** changed. Changing it would change what the
Supervisor clones, which is a different decision from this one.
### How the gate was proven to actually bite
A protection rule on this forge can *appear* satisfied while having checked
nothing, so the rule was tested rather than trusted. The discriminator is a
merge `POST` carrying a deliberately wrong `head_commit_id`: Gitea evaluates
branch protection *before* it checks the head, so
* `409 head out of date` → the request **cleared** protection;
* `405 Not all required status checks successful` → protection **refused** it.
Read the *message*, not just the code: `405` is also returned as
`Please try again later` for a pull request with **no commits**, which has
nothing to do with protection. An empty pull request on an unprotected base
gives that same `405` (verified, throwaway PR #110), so an empty
`release/1.0 → master` reports it too.
Validated against a control on an unprotected base (PR #106), which answered
`409` while its check was still pending — i.e. protection was no obstacle
there. On `master` (PR #107) the same probe answered `405` while pending, and
`409` once the check was genuinely green. A direct `git push` to `master` was
rejected by the pre-receive hook with `master`'s SHA unmoved.
---
## 3. Rolling back
There is no force-push escape hatch: `enable_force_push` is `false` on both
branches. To undo a bad release, land a revert on `release/1.0`, bump the
version again, and take it through §1. The `/addons/` rollback for the
reference install is in `HANDOVER.md`.
+2 -2
View File
@@ -95,7 +95,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<small>Installer copy</small>
</div>
@@ -503,7 +503,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
+2 -2
View File
@@ -60,7 +60,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<small>Installer copy</small>
</div>
@@ -468,7 +468,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
+456 -3
View File
@@ -1,5 +1,439 @@
# Changelog
## 0.12.0
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
more than one PV inverter.** The option surface moves **76 keys -> 77**,
`options:` and `schema:` in step. 0.11.0's `pv_entity` takes one entity; a roof
with two or three inverters, each publishing its own power sensor into Home
Assistant, could only record one of them. `pv_entities` is a list, and the
Configuration tab renders it as a repeatable field, so you add your second and
third inverter there rather than in YAML.
⚠️ **UNSET, NOTHING CHANGES.** `pv_entities` ships as `[]`, which is what every
install that presses *Update* lands on, and empty means "use `pv_entity`" - so
an install with no PV entity still has no PV channel, and an install with one
`pv_entity` keeps exactly the single-entity channel 0.11.0 gave it. **No rule was
added to `validate.py`**, which mentions neither key, so **a stored options blob
that starts the add-on on 0.11.0 starts it on 0.12.0**. There is no
`SystemExit(78)` to plan around on this hop.
⚠️ **IT IS INSTRUMENTATION ONLY, NOT A CONTROL INPUT.** `app/control.py`,
`allocator.py`, `arbiter.py`, `peakguard.py` and the watchdogs are not in this
release's diff; the runtime files that move are `app/pv.py`, `app/main.py`,
`app/mqtt.py`, `config.yaml` and `translations/`. `tools/tel11_pv_trace.py`,
extended for this card, hashes the controller's decisions on 0.11.0 and on this
tree: the control document is **`846a10a5b89e…` with PV unset and with one
entity, on both trees** - the same hash TEL-11 recorded - and with a
three-entity roster it equals each grid world's unset document, including every
cell where one inverter is dead. Neither the total nor any partial total reaches
a decision. With PV unset or with one entity, the **telemetry** is byte-identical
to 0.11.0's as well.
Contents of this release, in merge order since 0.11.0 (`cccb2fa`):
| | |
|---|---|
| `f863eca` | TEL-12 - several PV inverters, and one absent member is an absent TOTAL **⚠️ NEW OPTIONAL OPTION, EMPTY ON EVERY EXISTING INSTALL** |
### TEL-12 — `pv_entities`: the PV channel for a roof with several inverters
**WHAT TO DO ON A SITE WITH MORE THAN ONE PV INVERTER.** List every inverter's
power sensor under `pv_entities`:
```yaml
pv_entities:
- sensor.pv_east_power
- sensor.pv_west_power
- sensor.pv_garage_power
```
`sensor.goodwe_pv_power` then carries the **site total**. Each entity's own
`unit_of_measurement` is read, so a kW inverter beside a W one sums correctly;
a sensor with no unit or a non-power unit is refused by name on the status
page, as in 0.11.0.
⚠️ **`pv_entities` REPLACES `pv_entity` - IT DOES NOT ADD TO IT.** When the list
is set, `pv_entity` is not read at all. **If you are upgrading from 0.11.0 with
`pv_entity` already set, the inverter it names must also be in the list**, or it
drops out of the total. Put it in the list and it is **counted once**, not
twice - that is why the rule is replacement: under a union, the natural action
(put all your inverters in the new field) would double-count the one the old
key already names. While both keys are set the status page shows a caution
saying which one won - *"`pv_entities` is set, so `pv_entity` is IGNORED - the N
entities in the list are what is summed. Clear `pv_entity` to remove this
caution"*. It is a caution, never a refusal to start and never a NOT READY row.
A repeated id inside the list is also counted once.
⚠️ **IF ANY CONFIGURED PV ENTITY IS ABSENT, THE PUBLISHED TOTAL IS `unknown` -
NOT THE SUM OF THE ONES THAT ANSWERED.** Three inverters configured and one
`unavailable`: `sensor.goodwe_pv_power` goes to `unknown` and the recorder
stores a **gap**. The sum of the other two would be a smaller number
indistinguishable from a real one, and a week later it would read as *"there
was less sun than there was"* - the conclusion this channel exists to test
rather than to manufacture. **A gap in the history, never an understated
number.** The status page, read live, still gives you the diagnostic: `PV —`
plus a caution that says there is **no site total**, names the missing entity
and its reason, and lists what the others reported. An inverter honestly
reporting **0 W** is summed, not treated as absent - shading and nightfall are
real zeros.
| what happened | `sensor.goodwe_pv_power` | the status page |
|---|---|---|
| neither key set | the entity does not exist | no row |
| all three answer, 1200 W + 2.1 kW + 450 W | `3750.0` | `PV 3 750 W` |
| all three answer, one of them `0 W` | the sum of the two others | the sum |
| one (or more) `unavailable` / missing / bad unit | `unknown` - a recorder gap | `PV —` + a caution naming what is missing |
⚠️ **THE FIX FOR A MISSING INVERTER IS NOT A TEMPLATE SENSOR THAT SUMS THEM.**
You can still point `pv_entity` at one if you prefer, but a template sensor
summing an `unavailable` source yields the partial total silently - the same
defect one layer down, where this add-on cannot see it or report it.
**WHY A SECOND KEY, RATHER THAN MAKING `pv_entity` A LIST.** Because an 0.11.0
install that has `pv_entity` set would not start. Measured against Supervisor
**2026.08.0** by `tools/supervisor_shape_probe.py` §G: with `pv_entity` declared
as a list, the string an 0.11.0 install has persisted is refused -
`Invalid list for option 'pv_entity'` - and a shipped `[]` default does not
rescue it, because the Supervisor's merge keeps your stored string. The one
single-key spelling that accepts both shapes, `match(.*)?`, validates nothing
(it accepts the integer `417`) and renders as a plain text box, so you could not
add a second inverter in the UI. So `pv_entity` stays a string, and the same
probe shows an 0.11.0 options blob - `pv_entity` set, no `pv_entities` key at
all - validating against this version untouched. ⚠️ **Rolling back to 0.11.0 is
also safe**: a schema without `pv_entities` drops the key with a warning and
leaves `pv_entity` standing.
**Why it matters now:** [[OPT-10]] needs a week of PV history to attribute the
surplus that left the reference site while the pack had room, and that site has
several PV inverters. The week starts when the site total is recorded.
**Full account: `DECISIONS/TEL-12-several-pv-inverters.md`, with the operator's
version under "Several PV inverters" in `DOCS.md`.**
### If you are crossing from a version older than 0.10.0
⚠️ **THIS SECTION IS NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.**
Updating installs 0.12.0 in one step, so every validator rule added in
0.8.0, 0.9.0 and 0.10.0 arrives at the same moment. Two of them can refuse a
config nobody has edited: [[SAFETY-39]]'s **rule 10** (added in 0.8.0) against
0.7.0's shipped default `slew_w: 1000` - the ceiling is **555.6 W** on a 5000 W
rail - and [[TARIFF-07]]'s three withdrawn `price_provider` ids,
**`engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy`**, each still
refused by name at startup on 0.12.0. Neither is new here, and a site already on
0.10.0 or later has already met both. **The full account of both, including
what to set instead of each withdrawn id, is in `## 0.10.0`**, which withdrew
them and is not superseded by this section.
`tools/predeploy_check.py` checks your live options against this version's
validator before you press *Update*, and on this tree reports a real version
change from 0.11.0. ⚠️ **Pass it the bare options dict - the `data.options`
object - NOT the whole `ha apps info --raw-json` document.** Handed the wrapped
export it currently prints CLEARED while having checked nothing ([[SAFETY-60]],
open). Extract the options first, e.g. `jq '.data.options'`.
## 0.11.0
**A MINOR BUMP, AND ONE NEW OPTION IS THE WHOLE REASON FOR THE SECOND DIGIT:
`pv_entity` ([[TEL-11]]).** The option surface moves - **75 keys -> 76**,
`options:` and `schema:` in step - which is the same test 0.8.0, 0.9.0 and
0.10.0 were each cut on. ⚠️ **AND UNLIKE 0.10.0, NOTHING HERE CAN REFUSE A
STORED OPTIONS BLOB.** No key was removed, no default moved, no enum widened,
and **no rule was added to `validate.py`** - the validator does not mention
`pv_entity` at all, because nothing else reads it and it cannot conflict with
anything. The new key is `str?` - **note the trailing `?`, it is OPTIONAL** -
with an `options:` default of `""`, so a blob written on 0.10.0 or 0.10.1 does
not carry it, the Supervisor's merge supplies the empty string, and the empty
string is *off*. ⚠️ **A stored options blob that starts the add-on on 0.10.1
starts it on 0.11.0. There is no `SystemExit(78)` to plan around on this hop**,
and a site that presses *Update* and changes nothing else gets an add-on that
behaves exactly as its 0.10.1 did.
⚠️ **WHAT YOU ARE ACTUALLY DECIDING, IN ONE LINE EACH. TWO TICKETS HERE ARE
OBSERVABLE ON AN INSTALLED ADD-ON, AND NEITHER CHANGES A WATT.**
* [[SAFETY-56]] - **three messages stop naming your hardware as the fault** in a
state where the add-on cannot tell a dead register from a battery that is
simply empty at its BMS floor. **Text only; the behaviour is measured
byte-identical.** If you have ever opened Developer Tools because the add-on
told you a sensor had failed, and found the sensor working, this is that
defect. On the reference install the state it misdiagnoses arrives on most
nights and on every cloudy morning after a deep discharge.
* [[TEL-11]] - **one new optional key, `pv_entity`, and nothing whatever happens
until you fill it in.** Set it and the add-on records what your solar is
producing beside what the meter and the battery are doing. ⚠️ **It is not a
control input and it does not change what the loop does.**
⚠️ **THERE IS NO CONTROL-LAW CHANGE IN THIS RELEASE, WHICH IS WORTH SAYING
BECAUSE 0.10.0 HAD ONE AND NOTHING IN ITS PREAMBLE COULD WARN YOU.**
`app/control.py` is not in this release's diff at all, and neither are
`allocator.py`, `arbiter.py`, `arbitrage.py`, `clamp.py`, `peakguard.py`,
`maintenance.py` or `watchdog.py`. The runtime files that move are
`app/freshness.py` and `app/main.py` (SAFETY-56's three sentences), and
`app/pv.py` - new - with `app/main.py`, `app/mqtt.py`, `config.yaml` and
`translations/` (TEL-11). Contrast [[SAFETY-48]] in `## 0.10.0`, which altered
what the inverter is asked to do while touching no option, no default and no
validator rule, so that nothing a config check could see would mention it. This
release has nothing of that shape in it.
⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS
STILL NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons
update` does not walk the versions; it installs 0.11.0 in one step, so every
validator rule added in 0.8.0, 0.9.0 and 0.10.0 arrives at the same moment as
this section's nothing. Two of them can refuse a config that nobody has edited:
> [[SAFETY-39]]'s **rule 10**, added in 0.8.0, against 0.7.0's own shipped
> default `slew_w: 1000` - the ceiling is 2/18 of the site rail, which is
> **555.6 W** on a 5000 W rail.
>
> [[TARIFF-07]]'s three withdrawn `price_provider` ids - **`engie_dynamic`,
> `totalenergies_dynamic` and `eneco_eddy`** - each of which is **still refused
> by name at startup on 0.11.0**, with the same message and for the same
> measured reasons: a 404 from a gateway with no route, a TLS handshake against
> a hostname the certificate does not cover, and a 404 with an empty body.
⚠️ **NEITHER OF THOSE IS NEW IN 0.11.0, AND NEITHER IS NEWS TO A SITE ALREADY ON
0.10.0 OR 0.10.1 - IT HAS ALREADY MET THEM.** They are restated here only
because this is now the newest section, and a reader who opens the newest section
and stops would otherwise never meet the one class of thing that can stop the
add-on starting. **The full account of both is in `## 0.10.0` below**: what to
set instead of each withdrawn id, which tariff a site left on one of them has
really been running, why the refusal *is* the migration, and why all three ids
stay in `config.yaml`'s `price_provider` enum even though `PROVIDERS` is down to
three. **That is the section that withdrew them, it is where the evidence is,
and it is not superseded by this one.** ⚠️ The reference install is on
`price_provider: off` and at `slew_w: 400`, so neither applies to it.
`tools/predeploy_check.py` checks both against your live options blob before you
press *Update*, and on this tree it reports a real version change rather than
0.10.1's no-op.
Contents of this release, in merge order since 0.10.1 (`b0b24e5`):
| | |
|---|---|
| `c9aaf7f` | SAFETY-56 - the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can **⚠️ TEXT ONLY, BEHAVIOUR BYTE-IDENTICAL** |
| `94da37f` | TEL-11 - PV production stops being invisible, and stays out of the control law **⚠️ NEW OPTIONAL OPTION, UNSET ON EVERY EXISTING INSTALL** |
⚠️ **TWO MORE MERGES LANDED IN THIS WINDOW AND ARE DELIBERATELY NOT IN THE TABLE
ABOVE: [[CI-9]]'S FOLLOW-UP (`9f6b9f1`) AND [[SEC-2]] (`239e120`). THE RULE IS
[[PKG-15]]'S - AUDIENCE, NOT IMPORTANCE - AND BOTH FAIL IT THE SAME WAY.** This
file is the **add-on's** release note: the text the Supervisor renders beside the
version number, read by somebody deciding whether to press *Update* on a battery
that is currently regulating. A row here has to be something that reader can act
on.
* **CI-9's follow-up changes one file, `.gitea/workflows/checks.yml`**, exactly
as CI-9 itself did, and it is comment-only within that file. It is not under
`goodwe_controller/`, it is not in `predeploy_check.py`'s `RUNTIME` tuple, it
is not in the built image, and **there is no version of this add-on on which an
operator can observe it.** `## 0.10.0` excluded CI-9 on exactly that reasoning;
excluding the follow-up on any other reasoning would make the rule mean
nothing.
* **SEC-2 adds `docs/RELEASE.md` and two lines to `README.md`, and this one is
the closer call, so here is the whole of it.** SEC-2 is by some distance the
more consequential of the two: it is the ticket that made `master` refuse a
direct push, and it is *why this release reaches you as a pull request into
`release/1.0` and then into `master`, rather than as a push straight to the
branch the Supervisor clones* - which is how 0.10.1 got there. It is also
**purely a procedure for whoever cuts a release**; both files sit outside
`goodwe_controller/`, neither is in the image, and no install can tell whether
that document exists or what it says. An operator reading it learns nothing
about 0.11.0 that helps them decide whether to take it. So it is excluded, and
it is excluded under the same sentence as CI-9: ⚠️ **importance to this
repository is not the test. Observability on an installed add-on is.**
⚠️ **AND BOTH ARE WRITTEN DOWN, JUST NOT HERE.** CI-9's follow-up is PR #102 plus
the comment block at the top of `.gitea/workflows/checks.yml`, which carries the
measurements in full; SEC-2 is PR #108 plus `docs/RELEASE.md` itself, which is
now the canonical account of the release path and is the procedure this release
followed. ⚠️ **If a later CI or packaging ticket changes what the shipped image
contains, it belongs in this table.** Neither of these does.
### SAFETY-56 — three messages stop blaming healthy hardware
⚠️ **NOTHING THE ADD-ON DOES CHANGES. THREE THINGS IT SAYS DO.** Same
conjunction, same limits, same `MISSING`, same 0 W command, same NOT READY row,
same [[SAFETY-57]] release at 32 s. `tools/safety56_behaviour_trace.py` is what
says so rather than a commit message: it hashes a document with no sentence
anywhere in it, built from **2390 one-second reads replayed out of the committed
2026-09-21 bus capture**, a **66-cell command × register grid stepped 120 times
each**, and **six driven controller worlds**. That document is
`6030175ed2ab9a41…` on `release/1.0` and on this tree, byte for byte, while the
sentences differ. ⚠️ **And the tool REFUSES to report "behaviour unchanged" when
the sentence halves agree**, so an identical pair is a result rather than one
tree compared with itself.
**THE DEFECT, AND IT IS NOT A TYPO.** [[SAFETY-37]]'s freshness gate fires when a
battery-power register holds one value while the standing command moves. That
conjunction is satisfied by **two disjoint worlds**:
1. the register has stopped reporting reality - which is what the gate was built
to catch; or
2. **reality is not moving**, because the source cannot comply with the command.
A BMS refusing a discharge at its floor holds the pack still under any
setpoint, and a register reporting that honestly publishes the same unchanged
number a dead one does.
**Nothing the gate is handed separates them, and three sentences asserted the
first.** ⚠️ **On the one episode this repository has measured, it was the
second.** On 2026-09-21 the pack read **11 %** for the whole 850 s hold, **every
command inside that hold was a discharge**, and the register **answered the first
CHARGE command three seconds later**. All three numbers are read off the
committed capture by `test_freshness.py` part 11a rather than typed there. The
register was healthy and truthful throughout, and the add-on said it had failed.
**THE THREE SURFACES, so you can recognise what moved:**
| where you read it | what 0.10.1 said | what 0.11.0 says |
|---|---|---|
| the **action log** and the **status page** — both quote `freshness.py`'s `STALE_READING:` line verbatim, through `_read_unit()` and `stale_note()` | `… the reading is not following, so it is reported as MISSING` | `… TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART: the register may have stopped reporting reality, or the source may be unable to comply with the command …` |
| the **add-on log**, at ERROR, from `main.py` | `SAFETY-37: <unit> battery-power register is not following its own command` | `SAFETY-37: <unit> battery-power reading did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER` |
| the **readiness row** on the diagnostics page | `battery power: <entity> is missing or not numeric` | `battery power: no usable value this cycle from <entity> - and this row cannot tell which of two things that is … Confirm the entity in Developer Tools before replacing it` |
⚠️ **THE THIRD ROW IS THE ONE THAT COST AN OPERATOR AN EVENING, AND IT IS THE
WORST THING A DIAGNOSTIC CAN DO: IT ASSERTED A FALSE FACT ABOUT A NAMED
ENTITY.** It read *"is missing or not numeric"* for **any** `None`, and one of
the ways that value becomes `None` is the freshness gate withholding a reading
that is present, numeric and updating - `Freshness.reading()` returns `None`
there **by design**. So the operator opened Developer Tools on the entity the row
named, found it working, and had every reason to conclude the diagnostics were
broken. **It is fixed by saying less, not by looking harder:** the row does not
branch on the gate's state, because the gate already has a surface of its own -
`stale_note()` rides `liveness_notes()` a few rows below with the whole
`STALE_READING` sentence in it - so your discriminator is *"is that row there?"*,
and this row's only job is to stop pre-empting the answer.
⚠️ **WHAT IT DOES NOT GIVE YOU IS WHICH OF THE TWO IT WAS.** Naming which would
need the SoC and the pack floor, and those are deliberately not `freshness.py`'s
inputs - they are [[TEL-08]]'s and [[SAFETY-32]]'s numbers, on their own rows.
**SoC is STATED in these sentences and never WIRED into them**: part 11b drives
the same two worlds at 50 % and at 11 % and the sentence does not move one byte.
What changed is that the add-on now tells you it cannot tell, instead of guessing
and blaming your hardware. **The reading rule is unchanged from
`### SAFETY-57` below**: if the stale/withdrawn pair keeps repeating, the unit
really does want looking at; if it happens once at a low SoC and stops, the pack
was empty and the site has resumed.
⚠️ **AND THE THREE WORDINGS SHARE NO HELPER, ON PURPOSE.** Two of them are log
lines about a **behaviour** and the third is a banner about an **entity**; they
answer different questions ("what did the register do" against "can I trust this
entity"), and one helper serving both would have to drop whichever half the other
caller does not need - and the half it dropped would be the operator's next step.
**Full account: `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`.**
### TEL-11 — `pv_entity`: PV production gets recorded, and stays out of the control law
⚠️ **UNSET, THIS IS NOT A CHANGE AT ALL - AND THAT IS MEASURED RATHER THAN
ASSERTED.** `pv_entity` ships empty, which is what **every** install that
upgrades into this version lands on. Empty means the add-on **reads no entity,
announces no entity, publishes no topic, shows no status row and adds no page
row.** `tools/tel11_pv_trace.py` drives five grid behaviours × eight
PV-sensor behaviours through 24 cycles of the **shipped** `Controller`, on
`release/1.0` and on this tree, and hashes two separate documents - one of
**control** decisions and one of **telemetry**:
| document | tree | `pv_entity` | sha256 |
|---|---|---|---|
| control | `release/1.0` | unset | `846a10a5b89e3210…` |
| control | TEL-11 | unset | `846a10a5b89e3210…` |
| control | TEL-11 | **set** | `846a10a5b89e3210…` |
| telemetry | `release/1.0` | unset | `a443a6ceaffe072b…` |
| telemetry | TEL-11 | unset | `a443a6ceaffe072b…` |
| telemetry | TEL-11 | **set** | `4e6d1cb289a95722…` |
**Read the two `unset` rows first.** They are byte-identical in **both**
documents, which is a stronger claim than "the control path is untouched": an
install that does not set the option sees no change of any kind, announcements
and status rows included. **Then read the `set` row of the control document** -
identical again, while telemetry moves. ⚠️ The tool **REFUSES** unless the two
trees' `app/` fingerprints differ and unless exactly one side reacts to the
option, so a matching pair cannot be two runs of the same tree.
⚠️ **IT IS NOT A CONTROL INPUT, AND IT DOES NOT CHANGE WHAT THE LOOP DOES.** It
is instrumentation and nothing else. It is not read by the control law, the
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog; it
is **not part of `inputs_ok`**, so a PV integration that breaks cannot stop your
battery being regulated; and its status row is a **caution** (`warn`), never a
`bad` row, so a telemetry channel can never put a battery site into NOT READY.
`test_pv.py` part 6 drives 40 cycles of the real `cycle()` and asserts the
commands are bit-identical across unset / no reading / 0 W / 8 kW, with a
**computed** census of all 33 modules under `app/` showing the material is
mentioned in exactly three of them: `pv.py`, `main.py`, `mqtt.py`. Wiring
production into the control law would be a behaviour change on a system driving a
real battery - and it would destroy the measurement, because the loop would then
be reacting to the thing being measured.
**WHAT YOU GET IF YOU DO SET IT.** One new MQTT-discovery sensor,
`sensor.goodwe_pv_power`, `device_class: power` and ⚠️ **`state_class:
measurement`** - which is the point of the row, because that is what puts the
number into the recorder's long-term statistics, and a week of statistics is what
the attribution needs. **Positive is producing.** The unit is **read off your
entity's own `unit_of_measurement`** - `mW`, `W`, `kW`, `MW`, `GW`, `TW`, Home
Assistant's own power units, every factor an exact power of ten. A sensor with
**no** unit, or with a unit that is not power, is **refused by name, and the
status page says which entity and which unit.** There is deliberately no
magnitude heuristic: nothing can separate 30 W of dusk from 30 kW of noon, and a
silent factor of 1000 in the one channel that exists to be believed a week later
is worse than a refusal you can read.
⚠️ **ABSENCE IS PUBLISHED AS `unknown`, NOT AS 0 AND NOT SKIPPED. THIS IS THE
DESIGN DECISION WORTH YOUR ATTENTION.**
| what happened | `sensor.goodwe_pv_power` | the status page |
|---|---|---|
| `pv_entity` not set | the entity does not exist | no row at all |
| set, sensor answered 4100 W | `4100.0` | `PV 4 100 W` |
| set, sensor answered 0 W | `0.0` | `PV 0 W` |
| set, sensor unavailable / missing / bad unit | `unknown` | `PV —`, plus a caution naming the reason |
The last two rows are why the key is shaped this way. `publish()` normally
**drops** a `None`, which is right for `circulation` - there the last figure
standing beats a zero that reads as health - and **exactly wrong here**: a
dropped row leaves Home Assistant holding the last number it was told, for ever,
and the recorder then draws a **flat line through the outage**, which is
indistinguishable from a measurement. Anyone reading that week back would
conclude the sun was not shining, ⚠️ **which is precisely the conclusion this
channel exists to TEST rather than to manufacture.** So the absence is published,
using Home Assistant's own no-value payload (`mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it *before* the numeric
branch), the entity's state becomes **`unknown`**, and the recorder stores a
**gap**. A failed read also **clears** the previous good one: the value never
outlives its own sensor.
⚠️ **WHY IT IS A HOME ASSISTANT ENTITY AND NOT A REGISTER.** The RS485 bus this
add-on speaks carries the **battery** inverter it drives. On nearly every site the
PV inverter is a different box from a different vendor on its own connection,
reporting into Home Assistant through its own integration - so this takes
whatever `sensor.*` your PV integration already provides, **any brand Home
Assistant can read**, and nothing anywhere assumes it is on the bus.
**THERE IS NO `pv_invert`, AND THAT IS NOT AN OVERSIGHT.** `meter_invert` and
`batt_invert` exist because those two quantities are **bipolar and are read by
the control path**, where a reversed sensor hands the site a plausible number it
acts on. Production is unipolar and is read by **nothing**, so a reversed sensor
cannot mislead a decision, and it announces itself as **−4 kW at noon** in the
very history this key exists to create. A flag would only let an installer hide
it. There is no negative-value alarm either: a PV inverter's own standby draw
makes a small negative reading at night ordinary, so any threshold would be a
number nobody has measured.
⚠️ **WHAT THIS RELEASE DOES NOT DO, AND IT IS THE REASON THE CARD EXISTS.** It
does not answer [[OPT-10]]'s question. That card measured this site over sixteen
days: the pack reached 90 % SoC on **two** of them, and **20.5 kWh of surplus
left the site while the pack still had room** - 12.6 kWh of it with the battery
not charging at all - and it could not say **why**, because PV production was
recorded **nowhere**. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which is
the only software defect), and guessing between them is how an optimiser gets
tuned to solve a cloud problem. **0.11.0 adds the channel. The attribution needs
a week of history through it, and is OPT-10's own card.** So the reason to set
`pv_entity` on the day you take this update is that the clock on that week starts
when you do.
**Full account: `DECISIONS/TEL-11-pv-production.md`, with the operator's version
under "PV production" in `DOCS.md`.**
## 0.10.1
**A PATCH RELEASE, AND ONE TICKET IS THE WHOLE OF IT: [[SAFETY-57]].** The last
@@ -50,8 +484,13 @@ operator can observe on an installed add-on - and are excluded for the reason
field history found are NOT in this release**: [[SAFETY-56]] (the stale-reading
message says *"is not following its own command"* when the true cause is an
empty pack, and the NOT READY row that goes with it) and [[SAFETY-58]] (a trip
on the first read after a rest). Both are open, both are wording or bound
changes rather than deadlocks, and neither was allowed to delay this one.
on the first read after a rest). Both were open when this section was cut, both
are wording or bound changes rather than deadlocks, and neither was allowed to
delay this one. ⚠️ **[[SAFETY-56]] HAS SINCE BEEN FIXED AND IS IN `## 0.11.0`
ABOVE.** The wording quoted in this paragraph is what **0.10.1 actually ships**
and is left standing for that reason - if the box in front of you reports
`0.10.1`, that is the sentence it will print at you. It is not what 0.11.0 says.
[[SAFETY-58]] is still open.
### SAFETY-57 — the stale-reading verdict gets an exit
@@ -168,6 +607,17 @@ a clearance the state could not produce. ⚠️ Its **diagnosis** half still say
pack is merely empty; that wording is [[SAFETY-56]]'s and is deliberately
untouched here.
⚠️ **AND THAT IS NOW A STATEMENT ABOUT 0.10.1 ONLY — [[SAFETY-56]] REWROTE THE
DIAGNOSIS HALF, AND `## 0.11.0` ABOVE CARRIES THE NEW WORDING.** The quotation
above is left exactly as it stands because it is what the 0.10.1 image prints,
and a reader on 0.10.1 needs to recognise it. The same applies to the reading
rule in the paragraph above: on 0.11.0 the add-on no longer asserts that
*"the register really is not following"* — it names both explanations and says
it cannot tell them apart — so *"the pair repeats → look at the unit, it happens
once and stops → the pack was empty"* stops being a correction of the message
and becomes the message's own advice. Nothing about **what** the gate does moved
in either card.
## 0.10.0
⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE
@@ -389,7 +839,10 @@ out of it that no test in this repository had reached**: [[SAFETY-56]] and
`tools/predeploy_check.py` enforces that by itself: run against a box already
reporting `0.10.0` it refuses with `THE DEPLOY WOULD BE A NO-OP` and names the
runtime commits since the bump. **SAFETY-57's fix is in `## 0.10.1` above**,
which is what a patch number is for.
which is what a patch number is for. ⚠️ **AND SAFETY-56'S IS IN `## 0.11.0`**,
which is where it went because it landed after 0.10.1 had already been cut. Both
defects named in this paragraph are now fixed; this section, and `## 0.10.1`
below it, describe images that still contain them, and are left saying so.
Contents of this release, in merge order since 0.9.0 (`86bdf49`):
@@ -0,0 +1,181 @@
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
**Text only. No behaviour changes in this card**, and §5 is the measurement that
says so: the same stimuli through the tree before and after produce a
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
and every readiness row's `(ok, warn)`.
---
## 1. The defect
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
healthy hardware has failed.
**The log line**, as emitted on the reference site on 2026-09-21:
> SAFETY-37: inverter-a battery-power register **is not following its own
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
> as MISSING rather than as a measurement … Treating it as MISSING, which
> commands 0 W.
**The readiness row**, at the same moment:
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
> **is missing or not numeric**
That entity was present, numeric and updating.
**SAFETY-37's conjunction is correct and is not touched by this card** —
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
genuinely stuck against a real command is exactly what it should catch, and a
pack that cannot comply is a state the site must stop in too. What was wrong is
what the sentences **concluded** from it.
## 2. Two worlds satisfy the conjunction
| | the register | the pack |
|---|---|---|
| **DEAD** | has stopped reporting reality | is doing what it was told |
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
A refused pack holds still under any setpoint, so an honest register publishes
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
separates them.** All three sentences asserted DEAD.
## 3. The live episode was REFUSED, and it is measured, not argued
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
| | |
|---|---|
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
| pack SoC throughout | **11 %** |
| first **charge** command after the restart | 11:05:31 at **-400 W** |
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
**A register that answers a reversed command inside one obligation window had
not stopped reporting.** What would not move was the pack. The sentence the
operator read named the one thing in the loop that was telling the truth.
⚠️ What this does **not** establish: that no register is ever intermittently
wrong. It excludes a register that had simply stopped; it cannot exclude one
that was wrong and then right. That is the point — the new sentence claims
neither.
## 4. The fix, and why it is three sentences and not one helper
Each message states **what was observed** and names **both** explanations.
| where | before | after |
|---|---|---|
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
**Three sentences, three wordings, no shared helper.** The first two are a log
line about a **behaviour**; the third is a banner about a named **entity**, and
the two answer different questions ("what did the register do" against "can I
trust this entity"). A helper serving both would have to drop whichever half
the other caller does not need, and the half it dropped would be the
operator's next step. The log line names the pair **once**, inside the `%s` it
already interpolates, rather than in both of its own halves.
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
asserts the sentence does not move one byte.
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
object at the same moment, and the row deliberately does not ask it: the same
loop renders the grid and SoC rows, which have no gate, and a branch per row
would be a second place where "why is this None" is decided. The gate already
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
the whole STALE_READING sentence in it — so the operator's discriminator is
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
and absent in ABSENT**.
## 5. Behaviour is byte-identical, measured
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
`origin/release/1.0` **archived out of git** and against this branch:
| | |
|---|---|
| the committed live capture | **2390 reads**, 7 verdict edges |
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
unchanged" unless the `sentences` halves **differ**. This card's first
invariance run was exactly that mistake — it archived a branch whose edits were
not committed yet, so `before` and `after` were the same code and the identity
was perfect and worthless.
Both of the tool's refusals are **driven**, not asserted:
| | |
|---|---|
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
## 6. The mutation battery
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
graded by `tools/mutation_run.sh` in the CI image.
| | mutation | Δ bytes | named reds |
|---|---|---|---|
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
| M5 | the second explanation deleted, the first kept | -216 | **1** |
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
| M8 | the row's second cause deleted | -309 | **1** |
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
⚠️ **M9's first run was a defect in this card's own check, and it is the one
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
with **no name attached**, with every check after that line **ungraded**. That
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
raising, and a precondition grades it: **0 named reds before, 3 after.**
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
True, and so is one over three mistyped literals. The positive control runs the
same predicate against the three sentences **as 0.10.1 shipped them** and
requires it to find an accusation in every one, and every literal to match at
least one of them (habit 7: vary one thing from an accepted baseline).
## 7. What part 11 does not grade
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
* It does not grade English. A sentence that named both explanations and then
said something useless about them would pass.
* The `no entity configured` branch beside the rewritten row is untouched and
ungraded: a blank entity really is the only thing it can be.
* The **grid-power and battery-SoC rows** go through the same rewritten line and
have no freshness gate today, so for them the second cause is a hypothesis
about this controller rather than a live possibility. That is weaker than the
battery-power case and is the price of not branching per row. A gate on either
signal — TEL-08 would be one — makes it exact.
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
healthy register could answer), and neither is the 710/790 disagreement
between `test_freshness.py` and the changelog. Both are separate cards; the
second has a **fourth site** at
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
off the PR table alone would miss.
@@ -360,6 +360,14 @@ two cards editing one sentence is how one of them silently loses. Only the
false, and part 10h has a named check asserting the diagnosis half is still
there so that rewrite has to be a deliberate act.
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
> the deliberate act: all three sentences now name **both** explanations and
> accuse neither, and part 10h's check is inverted — it requires the accusation
> to be **gone**. Nothing else in this section moved; the escape clause above is
> still this card's and is untouched by it. See
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
> `test_freshness.py` part 11 for the graded pair of worlds.
**Survivor 5 is older than this card and is not closed by it.** After a long
rest the unchanged clock is already past 35 s, so the **first** read carrying a
travelled command trips the gate — before the 17.0 s in which a healthy register
@@ -0,0 +1,106 @@
# SAFETY-61 — the control task's death ends the process
## 0. The defect
`amain()` started `run_control()` as a bare task and waited on `stop`. Nothing
watched the task. An uncaught exception in `read_inputs()`, `cycle()` or
`publish()` ended the task silently, and `stop` was never set. The web UI, P1
and tariff tasks kept running, and the Supervisor saw a healthy container. The
traceback appeared only at shutdown. The ESP32's layer-1 veto zeroed the
inverter about 30 s later, so the hardware was safe. The pack then sat at 0 W
indefinitely while the add-on showed green.
`test_control_task.py` reproduces this on the pre-fix tree. The child process
reaches its 40 s deadline, has written no 0 W, and has logged nothing.
## 1. Decision: exit with status 70 after the ordinary wind-down
A done-callback on the control task (`control_task_done()`) handles the death.
It logs the traceback at CRITICAL at the moment of death, then sets `stop`.
`amain()` then runs the same wind-down that SIGTERM runs (`shutdown()` →
`command_site_zero()`, then cancel, then clean up). After that it raises
`ControlTaskDied`, and `main()` turns that into `SystemExit(70)` (sysexits
EX_SOFTWARE). A normal stop and `run_control()`'s own refusal path both set
`stop` before the task ends, so the callback ignores them. The refusal still
exits with 78.
**Why not restart the loop in the same process:**
* `cycle()` writes controller state before its awaits: `cycle_at`,
`peak_guard`, `allocation`, then `target`/`reason`, then the writes. An
exception between those steps leaves the controller half-updated. A loop
restarted in the same process would continue from a state that no boot ever
produces. A process restart rebuilds the controller from the Store, which is
the path every boot already runs.
* An unbounded retry is the invisible spin the card forbids. A bounded retry
would reimplement the Supervisor watchdog's own throttle: 10 restarts per
30 min, and 5 attempts with backoff (`apps/const.py`).
## 2. What happens after the exit, verified rather than assumed
* **Exit status reaches Docker.** The add-on image (s6-overlay, `init: false`,
`CMD /run.sh`) was run with `exec python3 -c 'raise SystemExit(70)'`.
`docker inspect` gave ExitCode 70. The same test with exit 0 gave 0, and an
uncaught exception gave 1.
* **Supervisor 2026.08.0** (`ghcr.io/home-assistant/amd64-hassio-supervisor`,
source read from the image):
* A non-zero container exit is recorded as `ContainerState.FAILED`
(`docker/monitor.py`). The Supervisor logs *"App … exited with non-zero
exit code 70"* at ERROR (`apps/app.py container_state_changed`).
* The Supervisor restarts the add-on **only if the per-install Watchdog
toggle is on**. `watchdog_container()` returns when `not self.watchdog`.
The toggle defaults to **off** (`apps/validate.py`:
`vol.Optional(ATTR_WATCHDOG, default=False)`).
* Add-on containers get **no Docker restart policy**. The only
`restart_policy=` in the Supervisor belongs to the observer.
* `boot: auto` in `config.yaml` only controls starting at host boot. The
`watchdog:` key in `config.yaml` is a health-check URL, not a restart
policy, and this add-on does not set it.
* **So on a default install the add-on stays stopped after this exit.** The UI
shows it as stopped, its log holds the traceback, and the Supervisor log holds
an ERROR line. The pack is at 0 W, which is the same physical state as before
this card. The difference is that 0 W is written immediately instead of after
the ~30 s veto, and the add-on no longer shows green. With the Watchdog toggle
on, the Supervisor restarts the add-on.
* **Not verified:** an actual restart by a running Supervisor. That would need a
live Supervisor, and the only one available is the live house.
## 3. Left open for the owner
* Whether `DOCS.md` should tell installers to enable Watchdog. DOCS.md is not
this card's file.
* Setting the Watchdog toggle from inside the add-on (`/addons/self/options`)
was rejected here, because it would change a user setting without the user
knowing.
## 4. Scope: the siblings, and why they are not supervised
* `run_tariff_refresh()` and the P1 HTTP and HA transports already catch
`Exception` inside their loops by design, and the values they feed are
age-bounded downstream.
* The P1 MQTT source *returns* on purpose when it is misconfigured. Making every
long-lived task's end exit the process would turn that deliberate fail-safe
into a restart loop.
* Only the control task is supervised.
## 5. `hass.py`: an undecodable body is an absence
A non-JSON `200` served as `text/html` was **already** treated as an absence,
because aiohttp's `ContentTypeError` is a `ClientError`. The reads that actually
escaped were these three:
* a malformed body labelled `application/json`, which raises `JSONDecodeError`
(a `ValueError`);
* a JSON body that is not an object, which raises `AttributeError` in
`number()` and `limits()`;
* an undecodable error body in `call()`, which raises `UnicodeDecodeError`.
All three were measured against aiohttp 3.13.5. The first two now read as
`None`, and the third is now a failed write (`False`).
## 6. Status row
`checks()` now starts with a *control loop* row showing how long ago the loop
last completed a pass. The row turns red once that age exceeds
`FIRMWARE_INGEST_VETO_S`. The heartbeat is sent from this loop, so past that age
layer 1 has zeroed the inverter.
@@ -0,0 +1,353 @@
# TEL-11 — the PV production channel
*What was decided, what it was decided against, and what the evidence was.*
## 0. The problem, in OPT-10's numbers
Over sixteen days at the reference site the pack reached 90 % SoC on **two** of
them, and **20.5 kWh of surplus left the site while the pack still had room** —
**12.6 kWh** of it with the battery **not charging at all**. That single
measurement is upstream of most of what else was found that week: the nightly
floor, SAFETY-37's misdiagnosis, the SAFETY-57 deadlock, a 2 h 47 min saturation
freeze.
It cannot be **attributed**, because **PV production is recorded nowhere in this
system**. Three causes need three different fixes:
| cause | fix |
|---|---|
| there was no irradiance | none — the site behaved correctly |
| production was there, charge power was limited | a limit, somewhere |
| production was there, the pack had room, the optimiser declined | **the only software defect** |
Guessing between them is how an optimiser gets tuned to solve a cloud problem.
**This card adds the channel and does not do the attribution.** Ship it, let it
record a week, OPT-10 picks it up with data.
## 1. It is an HA entity, not a register — and that is a product decision
The RS485 bus this add-on speaks carries the **battery** inverter it drives. On
nearly every site the PV inverter is a different box from a different vendor,
reporting into Home Assistant through its own integration; it is **not** on this
bus and there is no reason it would be. So the signal comes from an entity:
`pv_entity` takes whatever `sensor.*` the site's PV integration provides, any
brand HA can read.
Reading a register would have been cheaper on one house and wrong for a fleet —
the trade [[product-not-one-house]] names.
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
> Reversed by TEL-14 (flag and negative caution): `DECISIONS/TEL-14-pv-sign.md`.
### The convention is derived, not assumed
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
table rather than inferred here:
* `meter_entity` — **positive must mean importing** (`meter_invert` flips it);
* `batt_entity` — **`+` = discharging** (`batt_invert` flips it).
Both are *"positive = power arriving at the house's AC bus"*. Production arrives
there too, so **positive = producing** is the continuation of the shipped
convention rather than a fresh choice.
### Why an invert flag would be over-building
The question is not "could a sensor be reversed" — it could. It is what a flag
would **buy**, and the answer is nothing, for two reasons that do not apply to
the two keys it would sit beside:
1. **Those two quantities are read by the control path; this one is read by
nothing.** `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor (`_read_unit()`'s own docstring says so), so a
reversed battery sensor hands the site a plausible number it then **acts**
on — a latch that trips when it should not, or does not trip when it should.
`pv_w` reaches no decision at all (§4), so a reversed PV sensor cannot
mislead one.
2. **Production is unipolar, so the error is self-announcing.** Battery power
and grid power are legitimately negative half the time, which is exactly why
a flipped sign is invisible in them. A PV array does not consume: a reversed
sensor reads **−4 kW at noon**, in the first day of the very history this card
exists to create. An invert flag would let an installer *hide* that by
flipping it to something that looks right, and a second place for the sign to
be wrong is a second thing to get wrong.
So: the convention is documented in `config.yaml`, both translations, `DOCS.md`
and `app/pv.py`, the value is published **exactly as the sensor reports it**,
and there is no flag. `test_pv.py` part 1 pins `-8 W` through unchanged.
### And no negative-value alarm either
The tempting middle path — warn when the reading is negative — needs a
threshold, because a PV inverter's own standby draw makes a small negative
reading at night entirely ordinary. Any threshold here would be a number nobody
measured, in a card whose whole subject is not fabricating numbers. The
published row is the detector.
## 3. Absence is never zero, and it is not "dropped" either
This is the constraint that shaped the publication, and the obvious
implementation fails it.
`MqttPublisher.publish()` **skips** a `None` value. That is right for
`circulation` — its note in `main.py` says a missing per-unit reading should
leave the last figure standing rather than publish a zero that reads as health —
and it is **exactly wrong here**: a skipped row leaves Home Assistant holding
the last number it was told, *forever*, and the recorder then draws a **flat
line at that value through the outage**. Read back a week later that is
indistinguishable from a real measurement, and if the last value happened to be
small it manufactures precisely the *"there was no surplus to store"* conclusion
the card exists to **test**.
So TEL-11 publishes the absence, using Home Assistant's own mechanism:
> `homeassistant/components/mqtt/const.py`: `PAYLOAD_NONE = "None"`.
> `homeassistant/components/mqtt/sensor.py`, in the state-message handler:
> `if payload == PAYLOAD_NONE: self._attr_native_value = None; return` — and it
> is tested **before** the numeric branch, so it holds for a row carrying
> `device_class: power`.
> (github.com/home-assistant/core, branch `dev`, retrieved 2026-09-24. The
> shipped MQTT-sensor documentation says the same thing in its `state_topic`
> entry: *"a 'None' value will set the sensor to an unknown state"*.)
The entity therefore goes to **`unknown`** and the recorder stores a **gap**.
`mqtt.ABSENT_AS_UNKNOWN` is a one-key set and `test_pv.py` part 3 asserts both
that the pv row gets this treatment **and that `circulation` still does not** —
widening the rule silently would be a behaviour change to another card's row.
⚠️ **What was NOT built, and why.** A per-entity `availability` block
(`availability_mode: all`, an extra retained topic) would also produce a
distinguishable state — `unavailable` rather than `unknown` — but it needs a
second topic, a second retained message, a change to `_payload()`'s
byte-order-sensitive key construction, and it survives restarts only if the
retention is right. The payload route is one branch in `publish()` and one
constant, and it is graded as **bytes on the topic** rather than as a claim
about HA's availability handling.
### Three states, not two
| state | frame | wire | page |
|---|---|---|---|
| no source (`pv_entity` unset) | no `pv` key | no topic, no entity | no row |
| no reading | `pv: None` | `goodwe_ctl/pv` = `None` | `PV —` + a caution naming the reason |
| zero watts | `pv: 0.0` | `goodwe_ctl/pv` = `0.0` | `PV 0 W` |
A failed read also **clears** the last good value (`_read_pv()` assigns on every
call), so a reading never outlives its own sensor. `test_pv.py` part 2 drives a
good read followed by a dead one, because a fixture that answers the same thing
forever cannot reach that failure — MUTATION.md habit 9's shape.
## 4. It must not become a control input, and that is graded twice
### In the suite
`test_pv.py` part 6 drives twelve cycles of the real `Controller.cycle()`
against a 6 kW export and asserts the commands written to the inverter are
**bit-identical** across `unset` / `no reading` / `0 W` / `8 kW` of production,
with a precondition check that the stimulus commands something at all (habit 1).
`inputs_ok` is asserted not to move either: a broken PV integration is **not** a
missing input, because nothing computes with it.
A computed census (habit 12 — the scope is derived from the tree, not typed)
then reports which of the 33 modules under `app/` mention the material in any
spelling, comments included. It is **three**: `pv.py`, `main.py`, `mqtt.py`. The
scanner's own red direction is exercised on every run.
### Across trees, by hash
`tools/safety56_behaviour_trace.py` is the pattern; `tools/tel11_pv_trace.py` is
this card's version of it, and **its control clause had to be different**, which
is worth stating because the difference is the interesting part.
SAFETY-56 changed wording and had to show behaviour did not move, so its
`--compare` **refuses** unless the `sentences` halves **differ** — otherwise the
two runs might be one tree twice. TEL-11's headline claim is the opposite shape:
with the option unset, **nothing** differs. "The documents are identical" is the
*result*, so it cannot also be the control. The two things proved instead are:
* the two runs came from **different trees** (`app/*.py` sha256 + byte count,
compared, and an identity is **refused**);
* **exactly one** side reacts to `pv_entity` — that side is the HEAD, the other
is a tree that does not have the option. Neither, or both, is refused.
It drives 41 stimulus cells (5 grid worlds × 8 PV schedules, plus a run stopped
part-way) and prints two documents per setting: `control` — every command
written, every `self.target`, `inputs_ok`, the loop's three fields, the
arbiter's resolution, the maintenance phase, tick by tick, **with no TEL-11
material in it** — and `telemetry`, which is everything the card is allowed to
move.
Measured, in the CI image, on purged trees:
```
HEAD is B; baseline is A (it does not react to `pv_entity`).
tree A 73296909170ec789237d6e704cce9ab0d9f83b3df1495a14d2497285c3041a12
tree B d5011b6a18e2e3803dc09cac0214b0378fa4c7388fc0ad8ad89b832a5c92af48
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
OK 1/3 UNSET: control AND telemetry are byte-identical to the baseline tree.
OK 2/3 SET: the control document is byte-identical with the channel switched on.
OK 3/3 CONTROL OF THE CONTROL: the telemetry document DOES move when the
option is set (42 stimulus cells differ).
```
Note what clause 1 claims and clause 2 does not: **unset** is identical in
`control` *and* `telemetry`, which is stronger than "the control path is
untouched" — an install that does not set the option sees no change of any kind,
including its announcement and its status rows. **Set** is identical in
`control` only, which is the whole of "instrumentation, not an input".
## 5. Genuinely optional — and not a second `price_now_entity`
`price_now_entity` / `price_avg_entity` are legal **only** while
`price_provider` is `off`, and the add-on **refuses to start** otherwise. That
is the trap this card was told not to rebuild, and it is avoided structurally
rather than by intention:
* `validate.py` does not mention `pv_entity` at all. It cannot conflict with
anything, because nothing else reads it.
* the MQTT row lives in `OPTIONAL_SENSORS`, not `SENSORS`, and
`discovery_payloads()`'s new `include` argument **defaults to empty**. So a
caller that has never heard of `pv_entity` — every fixture in `test_mqtt.py`,
`tools/rehearse_roster.py`, anything written next year — announces exactly
what it announced before. `p1_age` solves the same problem the other way
round, with `omit`, because it is announced by default and withdrawn when the
meter is off; getting that direction wrong here would have left most installs
with an entity nothing feeds, which is the hazard `omit`'s own note names.
* the read is gated on the option, so an unset install makes **no state call at
all** — not a read that fails quietly.
* there is **no status row** when it is unset. `peak_forecast_entity` nags when
it is unset because an unset forecast leaves a money guard without an input;
this is instrumentation, and a permanent caution on every install that never
asked for a PV channel is the warning spam the card forbids.
* when it **is** set and the read fails, the row is `ok=False, warn=True` — a
**caution**. `status_payload()` builds `NOT READY` out of rows that are
neither `ok` nor `warn`, so a telemetry channel can never put a battery site
into `NOT READY`.
## 6. The unit is read off the entity, and an unreadable one is refused
`read_inputs()` guesses kW-or-W for `peak_forecast_entity` with
`abs(raw) < 50`, and that is safe there: nobody's quarter-hour demand forecast
is 50 W. It is **not** safe here — 30 W of real production at dusk is an
ordinary reading, and that heuristic would publish it as 30 kW.
So `pv.watts()` reads the entity's own `unit_of_measurement` and converts
against Home Assistant's own `UnitOfPower` table (`mW`, `W`, `kW`, `MW`, `GW`,
`TW`; every factor an exact power of ten). A sensor with **no** unit, or a unit
outside that table, is **refused by name** and the status page says which. The
refusal is loud; a guess would be silent, and a factor of 1000 in the one
channel that exists to be believed a week later is the worse failure.
⚠️ `BTU/h` is in HA's enum and is deliberately **not** in the table: its factor
is a measured conversion rather than a decimal shift, no PV integration
publishes in it, and a fabricated constant is what this repository's whole
process exists to prevent. It lands in the refusal, by name, and `test_pv.py`
part 1 pins that.
## 7. The mutation battery
Every check this card adds was driven by the mutation that defeats the claim it
names. Method: `cp -a` of the tree per mutation into a **unique** directory,
the edit applied there, `__pycache__` purged, the suites run in the CI image
with the tree bind-mounted `:ro`, one file at a time. **The shipped tree is
never edited**, so habit 18's revert hazard — SAFETY-37's harness killed between
the edit and the `cp` that put the file back, and two harnesses sharing one
backup path — is closed by construction rather than by a verified revert. Each
row below records the byte delta, the file's sha256 before and after, and that
the mutated line was **read back off disk** before the run started.
| # | mutation | file | bytes | sha256 (first 16) before → after | red on |
|---|---|---|---|---|---|
| M1 | the `pv` row moved into `SENSORS`, i.e. announced on every install | `mqtt.py` | −79 B | `328d249b9d698ef6` → `532d3954c023bf3d` | 3 named in `test_mqtt.py`, 1 in `test_pv.py` |
| M2 | `publish()` drops a `None` `pv` row again, so HA keeps the last number | `mqtt.py` | −98 B | `328d249b9d698ef6` → `dd54be458f446475` | 1 named (the acceptance criterion itself) |
| M3 | `ABSENT_AS_UNKNOWN` widened past the one key it was written for | `mqtt.py` | +23 B | `328d249b9d698ef6` → `8fe571ebff3dacf4` | 1 named (`circulation` must still be dropped) |
| M4 | a dead sensor reported as **0 W** instead of an absence | `pv.py` | −1 B | `55ba7471e9995436` → `9eecc34152b7ac12` | 4 named |
| M5 | `_read_pv()` assigns only on a good read — a value outlives its sensor | `main.py` | +46 B | `c4d2cab34e50b5fd` → `189802aa69ce166d` | 2 named |
| M6 | the unit is read and then ignored — a kW sensor published as watts | `pv.py` | −18 B | `55ba7471e9995436` → `ab19ef66c52f9379` | 2 named |
| **M7** | **`self.grid = self.grid − self.pv_w` in `read_inputs()` — PV wired into the control path** | `main.py` | +106 B | `c4d2cab34e50b5fd` → `c38216edb7c5c8db` | **2 named, plus `tel11_pv_trace.py` clause 2/3 on 26 of the 41 stimulus cells** |
| M8 | `optional_keys()` returns `("pv",)` unconditionally | `mqtt.py` | −52 B | `328d249b9d698ef6` → `86ff23efe3088ad8` | 3 named |
| M9 | the PV caution made a `bad` row, i.e. a dead sensor is a `NOT READY` | `main.py` | +1 B | `c4d2cab34e50b5fd` → `45c74cab89943564` | 1 named |
| M10 | the read gate removed — an unset option is read once a second forever | `main.py` | −10 B | `c4d2cab34e50b5fd` → `b67baa8aba3019e8` | 2 named, **plus two crashes** |
`on_disk = True` for all ten: the mutated line was grepped back out of the file
after the edit and before the run (MUTATION.md reporting rule 2), and no
mutation is size-preserving, so rule 1's `+0 B` caveat does not arise.
⚠️ **M10's two extra reds are CRASHES, not named checks, and that is recorded
rather than tidied away.** Every `Hass` double in `test_inverter.py` and
`test_telemetry.py` predates this read and has no `state()` method, so an
unconditional PV read is an `AttributeError` rather than a failed assertion —
habit 20's shape, a red with no name attached. Fixing those fixtures would be a
change to what *their* checks report and belongs to whoever next touches them;
this card's own fixture has `state()` and grades the property by name.
⚠️ **M7's trace limb is where the two halves meet.** The mutant moves the
control document on 26 of 41 cells — every cell whose PV schedule produces a
READING. The five `none` cells and the five `zero` cells do **not** move, which
is correct and is worth seeing: subtracting nothing, and subtracting zero,
change nothing. A battery that only drove a dead sensor would have reported this
mutation as a survivor for the third time.
The clean tree those `before` hashes belong to: `app/main.py`
`c4d2cab34e50b5fd`, `app/mqtt.py` `328d249b9d698ef6`, `app/pv.py`
`55ba7471e9995436`.
### ⚠️ M7 was a SURVIVOR on the first battery, and the fixture was the reason
The card's **central** mutation — `self.grid = self.grid - self.pv_w` in
`read_inputs()`, i.e. exactly the defect the whole card forbids — came back
**green** on a six-file run at **+106 B**, on disk, purged, genuinely running.
The property did not hold; the fixture could not reach it. `test_pv.py` part 6
drove a **6 kW export over 12 cycles**, which puts the loop **against its
rail**: the command moves `slew_w` every cycle and −6000 W and −14 000 W of
apparent error produce the **same ramp, tick for tick**. MUTATION.md habit 23,
with a saturated actuator in place of a coinciding value — and the habit's list
of no-op-prone constructs is all *expressions*, so this is worth its own entry,
which it now has.
The fixture now drives a **600 W import the loop settles on**, with a
responsive meter (`grid = base − batt`) so the error actually closes, asserts
the resting target is well inside the rail, and asks habit 23's own question as
its own check: **shift the meter by the same 8 kW and confirm the commands
really would have moved.** On the first fixture that question answers *no*, in
one line. Every number in the table above is from the battery re-run against
the fixed tree.
⚠️ **A red count is a property of the suite as much as of the mutation.** These
name **this** tree at this commit; a later card that adds or re-bases a check
moves the number with the mutation untouched. Re-measure rather than inherit.
## 8. What none of this covers, with shapes
* **Home Assistant itself.** That `None` renders as `unknown` and that the
recorder keeps a gap is read out of HA's source and quoted in §3; no HA runs
in this suite. What is graded here is the bytes this add-on publishes.
* **Whether the operator's sensor tells the truth.** A sensor wired backwards,
or reporting DC where AC was meant, or a string instead of the array, is
published faithfully. §2 is the argument for why that is the right failure
mode; it is not a claim that it cannot happen.
* **A roster of more than one.** The row is a **site** row — there is one PV
array as far as this add-on is concerned — so nothing per-unit exists to
separate, and every fixture is the shipped roster of one.
* **The attribution.** This card records production. Whether the 20.5 kWh was
cloud, a limit or the optimiser is OPT-10's, and it needs a week of this
channel's history before it can be asked.
* **`tools/`, the firmware and the ingress HTML** are outside part 6's census.
* **The identity is only as wide as the stimulus.** `tel11_pv_trace.py` prints
its 41 cells with the documents for exactly that reason.
## 9. No `CHANGELOG.md` entry
`test_version_gate.py` 11b refuses a newest `## ` heading that is not
`config.yaml`'s `version:`, and opening an `## Unreleased` section would mean
taking a packaging decision this card does not get to take. The operator account
is in `DOCS.md`; the release note belongs to the next PKG ticket, which should
say that **0.10.1 → next** adds one optional key, announces no new entity on an
install that does not set it, and changes no control behaviour.
@@ -0,0 +1,359 @@
# TEL-12 — several PV inverters, and why a partial total is worse than none
TEL-11 shipped `pv_entity` as a single `str?` in 0.11.0. The owner of the
reference site reported **within minutes of it going live** that they have
several PV inverters and could configure one.
That is not a property of one house. A multi-string or multi-orientation roof
routinely carries two or three inverters, each publishing its own power sensor
into Home Assistant, and the add-on is sold to clients. So the channel had to
become a roster.
**OPT-10 is blocked on this and cannot start recording until it lands.** Which
sets the bar: the number this channel publishes has to be one an attribution can
believe, or the week of history OPT-10 is waiting for is worse than no history.
---
## 1. The surface: `pv_entities` beside `pv_entity`, and the measurement that
decided it
Two candidates, and they differ on exactly one question nobody in this
repository could answer by reading: **does Supervisor accept a stored STRING for
a key whose schema has become a LIST?** 0.11.0 shipped `pv_entity: str?`; the
live install *has* a string persisted in it; and an upgrade that refuses that
string leaves an add-on which will not start — CFG-04 §B's trap, in the other
direction, with the schema needed to fix it pinned to the version that cannot be
installed.
It was measured, against the release image, by a new **§G** of
`tools/supervisor_shape_probe.py` — the instrument CFG-04 built for exactly this
kind of question:
```
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" --entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/probe/supervisor_shape_probe.py
```
```
== G. TEL-12: the SHAPE of a several-inverter PV option ==
PASS the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED - the baseline every refusal below is varied from
pv_entity='sensor.pv_power' pv_entities=[]
PASS ⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as a LIST, the string an 0.11.0 install has persisted is REFUSED, and the refusal names the key - so that shape would leave the live add-on unable to start on upgrade
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's list strategy is `override`, so the operator's string survives the merge and is refused anyway
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...(the list shape itself is fine - it is only the STORED STRING it refuses, which is what makes this a migration and not a schema question)
a list value validates
PASS ⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, a list passes, and so does the integer 417; and the Configuration tab renders it as a plain text box rather than a repeatable field, so an operator cannot enter a second inverter in the UI at all
string=True list=True integer-417=True ui=[{"name": "pv_entity", "optional": true, "type": "string"}]
PASS ⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no `pv_entities` key at all, because it did not exist when that blob was written - validates against THIS manifest, with the new key defaulted to the empty list. The live install upgrades without touching its options
PASS ...and a three-entity roster validates, per-entry, with both keys set - which the add-on resolves by REPLACEMENT and says so on its status page
["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
PASS ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the `pv_entities: []` default is load-bearing, exactly as `inverters: []` is: Supervisor treats a nested list as required unless its element is an OPTIONAL scalar, and `- str` is not one
Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and the Configuration tab renders it as a REPEATABLE string field (`multiple: true`), so an operator adds their second and third inverter in the UI rather than in YAML
[{"name": "pv_entity", "optional": true, "type": "string"}, {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}]
PASS a DOWNGRADE to a schema without `pv_entities` drops the key with a warning rather than refusing, and leaves `pv_entity` standing - so rolling back to 0.11.0 returns the install to its single entity rather than bricking it
== all cases passed ==
```
| Claim | Evidence |
|---|---|
| **Option 1 — `pv_entity` becomes a list — cannot ship** | §G: `Invalid list for option 'pv_entity'` on the string an 0.11.0 install has stored |
| …and a `[]` default does not rescue it | §G: `_OPTIONS_MERGER`'s list strategy is `override`, so the operator's string survives the merge and is refused anyway |
| …and it is not a *schema* problem | §G: a list **value** validates fine against a list schema. It is a **migration**, and a migration is a bigger ticket than this |
| **One key CAN carry both shapes** — `match(.*)?` | §G, and reported even though it *passes*, because "no spelling accepts both" would have been a **false reason** to reject option 1 |
| …and it loses anyway, for a measured reason | §G: it validates nothing at all — a string, a list, and the integer `417` all pass — and the Configuration tab renders it as a plain text box, so an operator cannot add a second inverter in the UI |
| **Option 2 — `pv_entities` beside `pv_entity` — ships** | §G: the 0.11.0 blob validates untouched, the new key defaults to `[]`, three entities validate, the UI renders `multiple: true` |
| `pv_entities: []` in `options:` is load-bearing | §G: the shipped defaults **minus only that key** are refused with `Missing option 'pv_entities' in root` — CFG-04 §B's isolated counterfactual, for this key |
| A downgrade is not the trap in reverse | §G: a schema without the key **drops** it and leaves `pv_entity` standing |
`p1_phase_import_entities` (`options: []` + `schema: - str`) is the shipped
precedent for the pair; nothing new was invented.
**So the surface is uglier than option 1 and that is the correct trade.** The
cleanest surface that breaks the one install this card exists to fix is not the
cleanest surface.
### And `pv_entities` REPLACES `pv_entity` rather than extending it
CFG-04's rule for `inverters:` ("declared, it REPLACES those six keys"), applied
to the only upgrade path that matters. The install being upgraded **has**
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural action
is to put *all* the inverters in the new repeatable field — and under a union
rule that **double-counts A** unless the operator also remembers to clear the old
key. A doubled total is the same defect as a partial one pointing the other way,
in the one channel that exists to be believed a week later.
Under replacement the effective roster is exactly what one key says, and no
arithmetic depends on a de-duplication rule the operator has to know about. It
de-duplicates *within* the list (a site cannot have two sensors with one
entity_id, so a repeated id is a typo that would double that inverter), and
`Controller.checks()` prints a **caution** whenever both keys are set saying
which one won — a replacement nobody is told about is the "one of which silently
wins" `config.yaml` refuses elsewhere.
⚠️ It is a **caution and never a refusal to start**. `validate.py` still does not
mention either key. A telemetry channel that could stop a battery site from
starting would be observability with a foot in the control path.
---
## 2. ⚠️⚠️ The decision the card turns on: absence of ONE is absence of the TOTAL
Three inverters configured, one `unavailable`. The sum of the other two is **a
smaller number indistinguishable from a real one.** Fed into OPT-10's
attribution it reads as *"there was less PV than there was"* — which is the "no
irradiance" conclusion the channel exists to **test** rather than to manufacture.
Two designs were genuinely available:
**(a) publish the total as absent** — `pv.total()` answers `None`, the topic
carries HA's own `None`, the entity goes to `unknown`, the recorder stores a gap.
**(b) publish the partial sum alongside a count of how many contributed.**
**(a) shipped.** It wins on the one question that matters, which is *who reads
the number and when*:
* The **MQTT row** is read back **cold**, a week later, by OPT-10 and by anything
that plots `sensor.goodwe_pv_power` over that week. A partial sum there is a
smaller number indistinguishable from a real one, and the count that would
have qualified it lives in a **second entity with its own history** — so
believing the total requires joining two recorder series by timestamp to find
out which samples to distrust. Nobody re-derives that. And it is worse than a
gap in exactly the direction that matters: **a gap makes the attribution say
"I do not know"; a partial makes it say something false.**
* The **status page** is read **live**, by a human who wants the diagnostic. So
the partial information is *not* thrown away — the row names every entity that
did report, with its watts, and every one that did not, with its reason, and
states plainly that there is no site total. Different reader, different
instant, different rule.
A count published beside the total would also carry **no information** in the
state (a) admits: the total is published *only* when every entity contributed, so
the count would be the constant `len(roster)`.
And this **extends** TEL-11's rule rather than weakening it. `mqtt.py`'s
`ABSENT_AS_UNKNOWN` is still exactly one key and `publish()`'s `None` handling is
untouched: the line that turns an absence into HA's `unknown` did not have to
change, because "absence is never zero" already covered this one layer up.
### The mirror, which matters just as much
A member honestly reporting **0 W** is **summed**, not treated as absent. The
test is `watts is None`, never falsiness. Shading, a tripped string and
nightfall are all real zeros, and a roster that blanked the site whenever one
member read zero would report an absence **on every clear evening** and defeat
the channel from the other end.
### ⚠️ And the answer is NOT "document a template sensor that adds them up"
An operator may legitimately prefer one, and `pv_entity` will happily point at
it. Shipping it as *the* answer pushes the absence problem onto them unseen: a
template sensor summing an `unavailable` source silently yields the partial
total — the same defect one layer down, in a place this add-on cannot see or
report. DOCS.md says so in those terms.
### The three published states, side by side
| what happened | frame | `goodwe_ctl/pv` | status page |
|---|---|---|---|
| no roster at all | no `pv` key | no topic, no entity | no row |
| 1200 / 2100 / 450 W, all three | `pv: 3750.0` | `3750.0` | `PV 3 750 W` + one ok row naming all three |
| all three answer, one of them `0 W` | `pv: 1650.0` | `1650.0` | `PV 1 650 W` |
| one of the three `unavailable` | `pv: None` | `None` → `unknown`, recorder **gap** | `PV —` + a caution naming the missing entity, its reason, and what the others reported |
| all three gone | `pv: None` | as above | as above |
---
## 3. Still not a control input
Unchanged, and re-proved at the new roster size.
`tools/tel11_pv_trace.py` was **extended rather than replaced** — the file name
still says TEL-11 because `DECISIONS/TEL-11-pv-production.md` §5 and
`test_pv.py`'s mutation table quote it, and a renamed instrument makes both
references dangle for no gain.
It now produces **three** document sets per tree — `unset`, `one`, `multi` — and
grades five claims. The roster cells live in their own document precisely so
`unset` and `one` keep the `control` hashes TEL-11 recorded; appending cells to
an existing document would have moved a hash whose stability is itself the
evidence.
```
$ python3 tools/tel11_pv_trace.py --compare base.json head.json
HEAD is B; baseline is A - its roster cells ARE its unset cells, which is what a tree that has never heard of `pv_entities` does with them.
supports_roster A False / B True (self-reported; the verdict above is MEASURED)
tree A fa92ba6a6fc6d49c0f87a494e03b530a8575212a0b2c3046629cb400ef2ae0d4
tree B 0b39198015b6a5a3f7538d4157d77ecadc89311daef18fce883762d94d8351eb
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry A one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
telemetry B one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
telemetry A multi d269168ffece375027c2c18ea33631a961ff92775781884a0592d6a37d78845c
telemetry B multi 550a49a73f39267ba28b1e78cd12b8f5c71ef7269bbbd2e12112696be0fef1ff
OK 1/5 UNSET: control AND telemetry are byte-identical to the baseline tree. …
OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to 0.11.0's single-entity path - the same commands, the same payloads, the same status rows, the same announcement.
OK 3/5 CONTROL, ONE ENTITY: the control document is byte-identical with the channel switched on - PV reaches no decision.
OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control document equals its grid world's `unset` one - 30 cells over 5 grid worlds, covering three healthy entities, one dead at EVERY position, all three dead and one honestly reporting 0 W. Neither the total nor any PARTIAL total reaches a decision, and the document is byte-identical to the BASELINE tree's for the same cells.
OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES move under a roster (30 stimulus cells differ from their grid world's unset telemetry), so 1/5-4/5 are identities this instrument could have broken. AND in no cell with a dead member does the PV row carry the partial sum of the survivors, while every cell whose members all ANSWER publishes its total - including the one where a member honestly reports 0 W.
$ echo $?
0
```
**The control hash is unmoved: `846a10a5b89e…` on `unset` and on `one`, on both
trees.** The roster control document `2aafbfedfb9c…` is byte-identical between
the two trees, which is the strongest available form of the claim — the baseline
runs those cells with a key it has never heard of, so its control document for
them *is* 0.11.0's.
⚠️ **2/5 is a claim TEL-11 could not make.** TEL-11's baseline had no channel at
all to be identical to; TEL-12's baseline is 0.11.0, so "one entity behaves
exactly as 0.11.0 does" is a real cross-tree identity over **both** documents.
`pv.total()` passes a single entity's `(watts, why)` through verbatim for exactly
this reason, and `Controller.checks()` keeps 0.11.0's two f-strings in a
`len(...) == 1` branch rather than generalising them.
### ⚠️ TEL-11's `--compare` refusal had to change, and that is a finding
TEL-11's rule was *"exactly ONE side's `set` document may differ from its own
`unset` — that side is the HEAD"*. It was correct evidence when written, because
`release/1.0` predated `pv_entity` entirely. `release/1.0` is 0.11.0 now, **both**
sides react to `pv_entity`, and that refusal fires on **every honest run**. The
discriminator is the roster instead: only the HEAD has heard of `pv_entities`, so
only the HEAD's `multi` document fails to reduce to its own `unset` one.
It is asserted from the **documents**, not from `tree.supports_roster` — a
self-report is the tree describing itself, whereas "this side's behaviour did not
move when `pv_entities` was set" is the tree being measured. `supports_roster` is
printed beside the verdict so a disagreement between the two is visible.
See MUTATION.md habit 24.
---
## 4. Mutation battery
Twelve mutations, each on a **fresh `cp -a`** of the clean worktree (never
reverted — habit 18 / TARIFF-09's method), `__pycache__` purged before the run
and the tree checked for one afterwards, byte delta from `stat` and the edit
confirmed on disk with `cmp`, suites run in the CI image with `checks.yml`'s own
command one file at a time under a `:ro` mount.
| mutation | bytes | reds |
|---|---|---|
| N1 `total()` returns the partial sum, not an absence | +42 B | 6 named in `test_pv.py` + 1 crash |
| **N1b ⭐ THE REJECTED DESIGN: the partial sum with a contributor count** | −282 B | **10 named + the trace `REFUSED 5/5`** |
| N2 absence tested by falsiness (`not w`) | −4 B | 2 named |
| N3 `pv_entities` UNIONS with `pv_entity` | −27 B | 3 named |
| N4 the de-duplication dropped | −15 B | 1 named |
| N5 `sum` → `max` over the roster | **+0 B** | 6 named |
| N6 the roster-of-one stops passing `why` through | +13 B | 8 named |
| N7 `optional_keys()` back to reading `pv_entity` | −6 B | 1 named + 1 crash |
| N8 `self.grid -= self.pv_w` in `read_inputs()` | +106 B | 3 named + the trace (52 cells on 2/5, 26 on 3/5, 10 on 4/5) |
| N9 the roster caution made a `bad` row (NOT READY) | +1 B | 1 named |
| N10 `entity_is_ignored()` always False | +10 B | 2 named |
| N11 `pv_entities: []` deleted from `options:` | −18 B | 2 named in `test_translations.py` + `11 CASE(S) FAILED` in the probe |
**No survivors.**
⚠️ **N5 is `+0 B` — the risky delta.** `sum` and `max` are the same three
characters, so neither the file size nor its mtime moved, and TEST-15's
stale-bytecode defect is exactly what manufactures a survivor there. The run was
on a fresh purged copy and `cmp` confirmed the edit on disk before it started.
⚠️ **N1b is the row that matters, and it is not a defect.** It is the *other*
defensible design, implemented and run. The point is that the checks and the
instrument can **tell the two apart** — an unexamined partial sum is what the
card forbids, so a check that could not see one would be decoration:
```
REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD', '2550.0'),
('EXPORT-6K/GARAGE-DEAD', '3300.0'), ('EXPORT-6K/WEST-DEAD', '1650.0'),
('IMPORT-3K/EAST-DEAD', '2550.0'), ('IMPORT-3K/GARAGE-DEAD', '3300.0'),
('IMPORT-3K/WEST-DEAD', '1650.0')] … - a smaller number indistinguishable from a
real one, which is the defect this card exists to prevent.
```
⚠️ **N8's 1/5 stays GREEN and that is correct rather than a miss.** With no PV
entity configured `pv_w` is `None` and the leak cannot fire — which is what makes
1/5 a claim about the **default** and 2/5–4/5 the claims about the channel.
### The fixture values are the design (habit 10 and habit 23)
`test_pv.py` carries **two** rosters over the same three entities, because the
two claims need different magnitudes and one fixture cannot serve both.
* **Parts 8–10: 1200 W + 2.1 kW + 450 W = 3750 W.** The sum is separable from
max (2100), min (450), first (1200), last (450), mean (1250), count (3) and
every partial of itself (2550 / 1650 / 3300) — the family is computed, printed
and asserted non-colliding as its own precondition. One member is in **kW** so
"correct against their own units" is exercised. **Not one member is zero**: a
member contributing 0 W could go absent without moving the sum of the rest, and
a fixture like that cannot tell "published the absence" from "published the
partial sum" — habit 23 with a coinciding value in the *fixture* rather than in
the plant, which is precisely the trap the brief named. Absence is driven at
**every position** of the roster, not at one (habit 10's fixture-as-a-SET), and
each position's partial is **derived from the fixture** rather than typed
beside it.
* **Part 6: 1500 W + 4.5 kW + 2000 W = 8000 W.** Sized so every *partial*
(6500 / 3500 / 6000) would still **move** a loop settling on a 600 W import.
Habit 23's own question is asked twice — shift the meter by 8000 W, and again
by 3500 W, the smallest partial any single dead member could produce — because
a leak of the partial is a different mutation leaking a smaller number.
### Two false results this card produced, and what they were
Both were in the **instrument**, and both are recorded rather than tidied away.
1. `partial_w`/`partial_wire` were first written **inside** each telemetry cell.
They are a fact about the *stimulus*, produced by the tool and identical on
both trees — so every roster cell differed from its unset reference for a
reason that was not behaviour, and the HEAD/baseline discriminator fired on
*both* sides. They live in a separate top-level `partials` map now.
2. The `WEST-ZERO` cell (all three answer, one honestly reporting 0 W) had its
**true total** labelled as its "partial". 5/5 then refused the *shipped* tree
for publishing 1650 W, which it is required to publish. A cell with no dead
member has no partial to leak, and `never` is `None` there.
---
## 5. What none of this can see
* **Home Assistant itself.** That `None` on the state topic renders as state
`unknown` and that the recorder keeps a gap is read out of HA's source and
quoted in `app/mqtt.py`; whether the long-term statistics table then
interpolates across the gap is HA's behaviour, and OPT-10 will be reading it.
* **Whether the operator's sensors are telling the truth.** A sensor wired
backwards reads −4 kW at noon and is summed as −4 kW. There is deliberately no
`pv_invert` — §2 of the TEL-11 record is the argument, and it holds at a roster.
* **A roster larger than three.** Three is the smallest size at which the sum
separates from the reduction family *and* absence can be driven at every
position. Nothing here says what twenty entities do to a cycle's latency — the
read is `asyncio.gather`ed across the roster for exactly that reason, so a slow
sensor costs what one costs, but that is an argument rather than a measurement.
* **A PV roster combined with a two-inverter battery roster.** No fixture
crosses CFG-04's `inverters:` with `pv_entities`.
* **The real MQTT transport, and wall-clock behaviour.** As TEL-11.
---
## 6. Not deployable as merged
`config.yaml` stays at `0.11.0`, so `tools/predeploy_check.py` reports a NO-OP
until PKG cuts a version. A CHANGELOG entry is **wanted** and was deliberately
not written: `test_version_gate.py` 11b forbids an `## Unreleased` heading and
the bump is PKG's call. The material an entry needs is this file.
The option surface moves **76 keys → 77**, one new optional key, `pv_entities`,
empty on every install that does not set it.
@@ -0,0 +1,83 @@
# TEL-14 — the PV sign, declared per entity
Owner report on 0.12.0: `sensor.espressif_vermogen` (ESPHome clamp) and
`sensor.kwh_meter_actueel_vermogen` (kWh meter in the PV circuit) read
**positive for consumption, negative for production**. At night the page said
"✓ PV production: 13 W" — standby draw. By day production would publish negative.
## 1. This reverses TEL-11 §2, and why
TEL-11 §2 refused a `pv_invert` on two grounds. "PV is read by nothing in the
control path" still holds: the flip lives in `pv.total()`, and `test_pv.py`
part 12 re-runs part 6's 40-cycle identity with a flip set. "A reversed sensor
announces itself" did not survive a real site: these sensors follow a different
convention, read a plausible small positive number at night, and the only other
fix is a template sensor, the place TEL-12 §2 says goes quietly wrong.
## 2. The shape: a parallel list, `pv_invert_entities`
Measured by `tools/supervisor_shape_probe.py` §H (Supervisor 2026.08.0, against
a 0.12.0 blob); re-run it for the transcript.
| shape | schema | why it lost / won |
|---|---|---|
| (a) `-sensor.x` prefix | accepted | invisible: the sign is a syntax only the description explains, in a field an installer fills with the entity picker's text |
| (b) parallel list | accepted, 0.12.0 blob unchanged | **ships**: its own labelled, translated field ("PV entities whose sign is reversed"); an installer lists the odd ones out |
| (c) dicts on `pv_entities` | **refuses 0.12.0's stored strings** | bricks the upgrade — TEL-12's trap again |
| (c2) dicts as a third key | accepted | per-entry labels render raw (`entity`, `invert`) — probe §F; and a third PV surface |
| (d) global bool | accepted | one sign cannot describe inverter + meter in one roster |
(b)'s known cost is a typo in the second list: `pv.stray_inverts()` turns it into
a named caution. `[]` in `options:` is load-bearing (§H, isolated counterfactual).
Default `[]` flips nothing, so every 0.12.0 install is byte-identical (part 12).
## 3. The negative caution: per entity, below −50 W
The owner's two meters draw ~13 W at night, so a *correctly* signed meter reads
about −13 W then. −50 W clears that with room for a few such devices behind one
entity; a reversed sensor by day reads hundreds to thousands of watts below
zero. It is tested **per entity, not on the total** (a deviation from the brief):
three correct meters at −20 W sum to −60 W and must stay quiet, while a reversed
−1500 W hidden inside a positive total must not. It is a caution, never `bad`;
a wrong threshold costs a spurious or missed caution, never data. The text says
ADD (unflipped) or REMOVE (already flipped).
## 4. Mutations
Fresh `cp -a` each, `__pycache__` purged, CI image, `:ro`, `--network none`;
`test_pv.py` only unless stated. T1-T12 at 7559d21; M2/M5 are review #1628's,
red since B1 (merged into release/1.0 @ 43b2740).
| # | mutation | bytes | reds |
|---|---|---|---|
| T1 | flip ignored in `total()` | −45 | 9 |
| T2 | flip applied to every member | −7 | 6 |
| T3 | `0.0 - w` → `-w` | −5 | 1 |
| T4 | flip written as `abs()` | −1 | 2 |
| T5 | `_read_pv()` stops passing the flip | −26 | 7 |
| T6 | caution threshold −50 → 0 W | −17 | 3 |
| T7 | `[sign inverted]` marker dropped | −39 | 3 |
| T8 | negative caution made `bad` | +1 | 1 |
| T9 | `stray_inverts()` always empty | −50 | 1 |
| T10 | `inverted()` stops stripping | −8 | 1 |
| T11 | ADD/REMOVE advice swapped | +4 | 2 |
| T12 | `pv_invert_entities: []` deleted from `options:` | −25 | 2 test_translations + 17 probe |
| M2 | `stray_inverts()` roster ignores `pv_entity` | +15 | 2 (REPORTED CASE, PADDING) |
| M5 | `_read_pv()` flip set not stripped | +25 | 1 (PADDING) |
## 5. History
OPT-10's `sensor.goodwe_pv_power` recorded since 2026-09-24 on an install with
such sensors carries the old sign; nothing rewrites it.
## 6. CHANGELOG entry for the next PKG cut
Not written into `CHANGELOG.md`: `test_version_gate.py` 11b/11d refuse an
`## Unreleased` heading (measured on this branch: 4 reds). For PKG to paste:
> **`pv_invert_entities` (TEL-14).** A PV sensor that reads negative while
> producing — typically a kWh meter or power clamp in the PV circuit — can now
> be listed under *PV entities whose sign is reversed* in the Configuration tab;
> its sign is flipped before the sum and the status page marks it. Empty (the
> default) changes nothing. ⚠️ PV history recorded before you set it keeps the
> old sign.
+184 -14
View File
@@ -4,8 +4,8 @@ Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can
balance cells and recalibrate its coulomb counter.
Installers: read `FIELD-GUIDE.md` in the repository. It is not optional reading —
it contains the commissioning gates and the failure modes.
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
v0.1.0 and outdated; where it disagrees with this page, this page wins.
## Before you start
@@ -16,6 +16,11 @@ You need:
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml`
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
After installing, turn on **Watchdog** on the add-on's Info tab. A crashed
control loop stops the add-on at 0 W, and the Supervisor restarts it only when
Watchdog is on (it is off by default). See
`DECISIONS/SAFETY-61-control-task-death.md`.
## The safety model, in short
The inverter **holds its last command forever** — it has no meter-timeout. So:
@@ -139,6 +144,9 @@ either direction, and the check is mutation-tested both ways.
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities-tel-14) |
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
those serve cached values, and a stale reading here ends the maintenance charge
@@ -163,12 +171,14 @@ inverters:
soc_entity: sensor.goodwe_a_soc
batt_entity: sensor.goodwe_a_battery_power
max_w: 5000
bms_soc_floor: 10
capacity_kwh: 15.36
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint_w
soc_entity: sensor.goodwe_b_soc
batt_entity: sensor.goodwe_b_battery_power
max_w: 4600
bms_soc_floor: 10
capacity_kwh: 20.48
```
@@ -188,16 +198,18 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
| `setpoint_entity` | yes | This inverter's own ESPHome `number.*_goodwe_setpoint_w` |
| `max_w` | yes | The supervised cap on what may be written to **this** inverter. The site's ceiling is the **sum** |
| `bms_soc_floor` | yes | This pack's own BMS cut-out percent (SAFETY-32). See [`bms_soc_floor`](#tariff-all-optional) |
| `soc_entity` | | This pack's state of charge |
| `batt_entity` | | This unit's battery power, `+` = discharging |
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs-opt-09) |
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
sensor** — it does *not* fall back to the flat one. That is deliberate: every
per-device value became per-instance in 0.5.x precisely so that no inverter is
ever graded against a sibling's numbers, and a silent fallback would rebuild
that in YAML. `name`, `setpoint_entity` and `max_w` are required of every entry
for the same reason: there is no rating to borrow.
that in YAML. `name`, `setpoint_entity`, `max_w` and `bms_soc_floor` are required
of every entry for the same reason: there is no rating or cut-out to borrow.
⚠️ **Clear the flat `setpoint_entity` when you declare a roster,** or the add-on
refuses to start and says so. It is the single-inverter form and it is not read
@@ -413,6 +425,159 @@ and `Inverter B` — are two names to the validator and one id once slugged. The
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
get then depend on roster order. Give your inverters plainly different names.
### PV production (`pv_entity`, TEL-11)
> **More than one solar inverter?** Use `pv_entities` instead — see
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The
> rest of this section applies to both.
**Optional, off by default, and it changes no control behaviour whatsoever.**
Set it and the add-on records what your solar is producing beside what the
meter and the battery are doing. Leave it empty and the add-on reads no entity,
announces no entity, publishes no topic and shows no row — which is what every
install that upgrades into this version does.
#### Why it is a Home Assistant entity and not a register
This add-on's RS485 bus carries the **battery** inverter it drives. On nearly
every site the PV inverter is a different box from a different vendor, on its
own connection, reporting into Home Assistant through its own integration. So
`pv_entity` takes whatever `sensor.*` your PV integration already provides —
any brand Home Assistant can read. It does not have to be on the bus, and there
is no assumption anywhere that it is.
#### What it is for
OPT-10 measured this site over sixteen days: the battery reached 90 % on **two
of them**, and **20.5 kWh of surplus left the site while the pack still had
room** — 12.6 kWh of it with the battery not charging at all. Three different
things produce that number and they need three different fixes:
* there was no irradiance to store — nothing to fix;
* production was there and something limited the charge power;
* production was there, the battery could take it, and the optimiser declined.
Only the third is a software defect, and **none of them can be told apart
without a record of production.** This entity is that record. The attribution
itself is OPT-10's, with a week of history behind it.
#### The sign, and `pv_invert_entities` (TEL-14)
**Positive must mean producing.** Some sensors — typically a kWh meter or an
ESPHome power clamp in the PV circuit — read negative while producing and a few
watts positive at night: list each of those in `pv_invert_entities`, spelled as
in `pv_entity`/`pv_entities`, and its sign is flipped before the sum; the status
page marks it `[sign inverted]` and cautions on any entity reading below −50 W.
History recorded before you set it keeps the old sign. See
`DECISIONS/TEL-14-pv-sign.md`.
#### The unit is read off the entity
The add-on reads your entity's own `unit_of_measurement` and converts: `mW`,
`W`, `kW`, `MW`, `GW`, `TW` — Home Assistant's own power units. A sensor with
**no** unit, or with a unit that is not one of those, is **refused**, and the
status page says which entity and which unit. That is deliberate: there is no
magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess
here would be a silent factor of 1000 in the one channel that exists to be
believed a week later.
#### ⚠️ Absence is never zero
| what happened | `sensor.goodwe_pv_power` | on the status page |
|---|---|---|
| neither `pv_entity` nor `pv_entities` set | the entity does not exist | no row at all |
| set, sensor answered `4100 W` | `4100.0` | `PV 4 100 W` |
| set, sensor answered `0 W` | `0.0` | `PV 0 W` |
| set, sensor unavailable / missing / bad unit | `unknown` | `PV —`, plus a caution naming the reason |
The last two rows are the point. A dead sensor publishes Home Assistant's own
"no value" payload, so the entity goes to **`unknown`** and the recorder stores
a **gap** — not a flat line at the last number it saw. Reading a week of
history back, *"no PV was reported"* and *"PV reported 0 W"* are different
pictures, which is exactly the distinction the whole card exists to preserve.
A failed read also **clears** the previous good one: the value never outlives
its own sensor.
### Several PV inverters (`pv_entities`, TEL-12)
A multi-string or multi-orientation roof routinely carries **two or three PV
inverters**, each publishing its own power sensor into Home Assistant. List them
all and the add-on records the **site total**:
```yaml
pv_entities:
- sensor.pv_east_power
- sensor.pv_west_power
- sensor.pv_garage_power
```
In the Configuration tab this is a repeatable field: press the **+** and add a
row per inverter. Each entity's **own** `unit_of_measurement` is read, so a
kW inverter beside a W one adds up correctly.
#### It replaces `pv_entity` — clear that one
Setting `pv_entities` makes `pv_entity` **ignored**, and the status page says so
until you clear it. That is deliberate rather than tidy: if the two were *added*
together, an upgrade where you leave your first inverter in `pv_entity` and also
list it here would count it **twice**, and a doubled total is as wrong as a
missing one in the one channel that exists to be believed a week later.
#### ⚠️⚠️ If ANY of them stops reporting, there is NO site total
This is the whole reason the key is shaped this way, so it is worth being plain
about:
| what happened | `sensor.goodwe_pv_power` | on the status page |
|---|---|---|
| all three reporting 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 W` |
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which entity, why, and what the others reported |
| all three gone | `unknown` | as above |
**It does not publish the sum of the ones that answered.** Two inverters out of
three add up to a smaller number that is *indistinguishable from a real one* —
and read back a week later that says *"there was less sun than there was"*,
which is precisely the conclusion this recording exists to **test** rather than
to manufacture. A gap makes the analysis say *"I do not know"*; a partial makes
it say something false. So the total goes to `unknown` and the recorder stores a
gap, exactly as it does for a single dead sensor.
The diagnostic is not thrown away — it is put where a person will see it. The
status page names the entity that is missing, the reason, **and** what the
others reported. That page is read live by a human who wants to fix something;
the recorded number is read cold by an analysis that would believe it.
Note the **third row against the second**: a member honestly reporting `0 W` is
**summed**, not treated as missing. Shading, a tripped string and nightfall are
all real zeros, and blanking the site whenever one member read zero would report
an absence on every clear evening.
#### ⚠️ Not "just use a template sensor that adds them up"
You may legitimately prefer a template sensor, and `pv_entity` will happily
point at one. But it is **not** the answer to this problem: a template sensor
summing an `unavailable` source yields the partial total *silently*, which is
the same defect one layer down — in a place this add-on cannot see and cannot
report to you.
#### ⚠️ What it does NOT do
It is **instrumentation**. It is not an input to the control law, the
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog;
it is not part of `inputs_ok`, so a PV integration that breaks cannot stop the
battery being regulated; and its status row is a **caution**, never a
`NOT READY`. Wiring production into the control law would be a behaviour change
on a system driving a real battery — and it would destroy the measurement,
because the loop would then be reacting to the thing being measured.
`tools/tel11_pv_trace.py` is what says so rather than claims it: it drives 41
stimulus worlds through the shipped controller and hashes a document of control
decisions containing none of this material, on this tree and on `release/1.0`,
with the option set and unset.
### P1 meter ingestion
`meter_entity` above expects one signed sensor, which usually means a template
@@ -439,7 +604,7 @@ than in YAML nobody reviews underneath a safety input.
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
> polls the meter directly instead of watching a Home Assistant entity — and
> that is the difference between an age sensor a watchdog can threshold and one
> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for
> it cannot. See "`sensor.goodwe_p1_sample_age`" below; `ha_signed` remains for
> installs where the meter is only reachable through Home Assistant.
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
@@ -594,7 +759,7 @@ connection-level reading is still accepted on its own.
**Why this mode exists:** every HTTP response is an *arrival*. The meter
answered, now, with its current reading — whether or not the number moved. That
is the signal `sensor.p1_sample_age_s` needs and the one Home Assistant cannot
is the signal `sensor.goodwe_p1_sample_age` needs and the one Home Assistant cannot
give it at all (see the note below). It is also simply fewer moving parts: the
five-second cadence is the meter's own, rather than an integration's polling of
it re-published as a state change.
@@ -631,12 +796,13 @@ as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
power that figure stays in the seconds; minutes of it while the load is clearly
not near zero is the meter to go and look at.
#### `sensor.p1_sample_age_s`
#### `sensor.goodwe_p1_sample_age`
Published over MQTT discovery whenever a broker is available: **seconds since the
newest accepted telegram**, refreshed every second rather than only when a
telegram lands. The ESP32's stale-input watchdog subscribes to this exact entity
id, so do not rename it.
id; do not rename the `GoodWe` device or this entity, since HA composes the id
from both (see [What appears in Home Assistant at two inverters](#what-appears-in-home-assistant-at-two-inverters)).
The reason it is recomputed against the clock is that Home Assistant only pushes
a state when the state *changes*. A meter sitting at a genuinely constant reading
@@ -672,7 +838,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
> past the default `meter_max_age_s` of 30.
>
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
> So `sensor.goodwe_p1_sample_age` on `ha_signed` is safe to *read*, and it is
> correct whenever the value is moving, but it must not yet be thresholded by
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
@@ -2659,8 +2825,8 @@ a **copy** of the state file and reports the difference.
--live-entities /w/live-entities.txt \
--reverse --round-trip'
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
pass.**
Exit `0` clean · `1` at least one finding · `2` could not run · `3` clean but
degraded (`--no-live-entities`). ⚠️ **`2` and `3` are not a pass.**
### The owner steps, because this session could not take them
@@ -2682,7 +2848,8 @@ check, **before** the roster is declared:
2. **The options blob**, as `--before`. It is not reachable through the Home
Assistant REST API and no entity carries it; export it from the Supervisor,
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
the same way `tools/predeploy_check.py` needs it: the bare options object,
not the `ha apps info` wrapper, which is refused. Write the roster you intend
to deploy into a second file and pass it as `--after`.
3. **The entity list**, as `--live-entities` — one entity id per line:
@@ -2696,7 +2863,8 @@ check, **before** the roster is declared:
hand back a clean bill of health for a half of the transition it did not
check. If you genuinely have to run without it, pass `--no-live-entities`:
the run then carries a standing banner in the report header **and** on the
exit line saying the orphan check was not graded. The asymmetry used to be
exit line saying the orphan check was not graded, and a clean result exits
3, not 0. The asymmetry used to be
the other way round — an unreadable file refused, a missing flag passed with
exit 0 and no disclosure at all — and the owner runs this once, at
commissioning, under time pressure.
@@ -2844,7 +3012,9 @@ Run it inside the container, so the validator half actually runs:
--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.
reachable through the Home Assistant REST API, and no entity carries it. Pass the
bare object (`ha apps info <slug> --raw-json | jq .data.options`); the wrapped
output is refused.
**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
+86
View File
@@ -896,6 +896,92 @@ Cheap habits fall out of it, and all of them are now used in this repository:
disjointness and the suite pins **that** - at 0, 100, 249, 250, 251 and
1300 W - rather than an order nothing depends on.
⚠️ **SECOND INSTANCE, TEL-11, AND THE NO-OP WAS A SATURATED RAIL RATHER
THAN A VALUE.** The card's whole subject is that a PV reading must never
reach the control law, so its central mutation is
`self.grid = self.grid - self.pv_w` in `read_inputs()` - **+106 B**, on
disk, purged, genuinely running - and it came back a **SURVIVOR** of a
six-file battery. The property held in no sense: the fixture drove a 6 kW
export over 12 cycles, which puts the loop **against its rail**, and a
command moving `slew_w` every cycle produces the SAME ramp for -6000 W and
for -14000 W of apparent error. The mutant ran and could not change the
answer.
Two things generalise past PV:
* **A SATURATED ACTUATOR IS A VALUE AT WHICH EVERY INPUT COINCIDES.** The
habit-23 list above names `or`, `and`, truthiness, default arguments and
`getattr` fallbacks - all *expressions* with a coinciding value. A rail,
a clamp, a slew limit, a deadband and a latched alarm are the same hazard
in the **plant**: they are where a controller's output stops being a
function of its input. A fixture that parks there cannot separate any two
inputs, and this repository's control fixtures park there by default,
because the dramatic stimulus is the saturating one.
* **SO THE PRECONDITION IS NOT "DOES IT COMMAND SOMETHING", IT IS "DOES IT
REST SOMEWHERE THE MUTATION COULD MOVE IT FROM".** `test_pv.py` part 6
now drives a 600 W import the loop SETTLES on, asserts the resting target
is well inside the rail, and asks habit 23's own question as a check:
shift the METER by the same 8 kW and confirm the commands really would
have moved. On the first fixture that question answers NO, in one line,
and it is the line that turns the identity into evidence.
24. **A REFUSAL DEFINED RELATIVE TO "THE BASELINE LACKS THIS FEATURE" DIES ON
THE RELEASE THAT SHIPS THE FEATURE — AND IT DIES AS A FALSE REFUSAL, WHICH
IS THE LOUD DIRECTION.** TEL-11 built `tools/tel11_pv_trace.py` to grade a
cross-tree identity, and because "the documents are identical" was its
*result* rather than its warning sign, it had to prove instead that the two
runs came from different trees and that **exactly one side reacts to
`pv_entity`**. That was correct evidence: `release/1.0` predated the option
entirely, so a side that reacted to it was the HEAD by construction.
TEL-11 then **merged**, `release/1.0` became 0.11.0, and the baseline for
the next card reacts to `pv_entity` too. `--compare` now answers
`REFUSED: set differs from unset on ['A', 'B']` on every honest run — the
instrument refuses its own shipped tree. TEL-12 found it on the first
comparison it ran.
* **A discriminator is a fact about a MOMENT, not about a card.** "The
baseline does not have X" is true until X ships and then false forever.
Write the discriminator against something the NEXT baseline still will not
have — TEL-12's is the roster key, not the channel — and say in the tool
which fact it rests on, so the next card can see what it has to replace.
* **Prefer a discriminator that is MEASURED over one the tree REPORTS.**
`tree.supports_roster` is the tree describing itself and would have been
one line; the shipped test is "this side's roster cells ARE its unset
cells", which is the tree being measured. Both are printed, side by side,
so a disagreement between the self-report and the measurement is visible
rather than silently trusted.
* ⚠️ **And this is the SAFE direction of the same defect, which is why it
was found in ten minutes rather than in a review.** A stale refusal is a
developer chasing a failure they cannot explain (the false-red case at the
top of this file). A stale *identity* — an instrument that goes on printing
OK because its reference drifted into agreement — is the survivor-
manufacturing direction, and nothing announces it. Ask of every cross-tree
instrument: *what does this print when the feature it is about has been in
the baseline for a year?*
TEL-12's own battery produced two more instrument defects of the ordinary
kind, both recorded in `DECISIONS/TEL-12-several-pv-inverters.md` §4: a
stimulus annotation written into the document being compared (so every cell
differed for a reason that was not behaviour, and the discriminator fired on
both sides), and a fixture cell whose TRUE total was labelled as its
"partial" (so the check refused the shipped tree for publishing a number it
is required to publish). ⚠️ **Both were caught by running the instrument
against the shipped tree BEFORE running it against a mutant** — which is the
one ordering rule that separates "the instrument is wrong" from "the tree is
wrong", and `test_mqtt.py` part 8's first line ("the shipped module satisfies
every property this instrument grades") is the same rule as a check.
⚠️ **AND TEL-12's SUBJECT WAS HABIT 23 IN THE FIXTURE ITSELF.** The card adds
a SUM over a roster, and the defect it exists to prevent is publishing the
partial sum when a member is absent. A fixture whose absent member
contributes **0 W** cannot see that defect at all: the partial and the full
total are **the same number**, so "published the absence" and "published the
partial" produce identical output. That is a rail in the *arithmetic* rather
than in the plant — the same hazard as TEL-11's saturated actuator, one
domain over. `test_pv.py`'s roster is three distinct non-zero contributions
in two different units, absence is driven at **every position** rather than
at one (habit 10), and each position's partial is **computed from the
fixture** and asserted to differ from the true total as its own precondition.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+35 -1
View File
@@ -565,10 +565,44 @@ class Freshness:
# and different afterwards, and an operator comparing this line against
# a history graph is reading the register.
shown_s = self.unchanged_s(now_mono)
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
# is satisfied by TWO disjoint worlds, and nothing this object is handed
# separates them:
# (1) the register has stopped reporting reality - what the gate was
# built for - or
# (2) reality is not moving, because the SOURCE cannot comply with
# the command. A pack the BMS is refusing at its floor holds
# still under any setpoint, and a register reporting that
# truthfully publishes the same unchanged number a dead one does.
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
# whole 850 s hold, the commands were discharge-only, and the register
# answered the first CHARGE command within one obligation window
# (test_freshness.py 11a measures all three off the committed capture)
# - and this sentence asserted (1), which sent the operator to look at
# healthy hardware.
# Naming both is what the module can honestly say. Naming WHICH would
# need SoC and the pack floor - deliberately not this module's inputs
# (see the `soc` row in BOUNDS and the docstring's last section), and
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
#
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
# status page and by `_read_unit()` into the action log; that one is a
# BANNER about a named ENTITY, and the two answer different questions
# ("what did the register do" against "can I trust this entity"). One
# helper serving both would have to drop whichever half the other does
# not need, and the half it dropped would be the operator's next step.
self.reason = (
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
f"while the standing command moved {moved_w:.0f} W to "
f"{standing_w:+.0f} W - the reading is not following, so it is "
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
f"CANNOT TELL THEM APART: the register may have stopped reporting "
f"reality, or the source may be unable to comply with the command "
f"- a pack the BMS is refusing holds still under any setpoint, and "
f"an honest register then reads exactly like a frozen one. Either "
f"way the number cannot be read as this command's effect, so it is "
f"reported as MISSING rather than as a measurement (limits: "
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
f"{self.bound.move_w:.0f} W and moved by over "
+11 -3
View File
@@ -61,10 +61,17 @@ class HomeAssistant:
if resp.status == 404:
return None
resp.raise_for_status()
return await resp.json()
except (aiohttp.ClientError, TimeoutError) as err:
obj = await resp.json()
# SAFETY-61: an undecodable body is an absence like any other failed
# read, never an exception into the control loop.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"read:{entity_id}", "read %s failed: %s", entity_id, err)
return None
if not isinstance(obj, dict):
self._moan(f"shape:{entity_id}", "read %s: not a state object: %.80r",
entity_id, obj)
return None
return obj
async def number(self, entity_id: str, invert: bool = False) -> float | None:
"""Numeric state, or None. Never substitutes a default."""
@@ -117,7 +124,8 @@ class HomeAssistant:
domain, service, resp.status, body[:200])
return False
return True
except (aiohttp.ClientError, TimeoutError) as err:
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
domain, service, err)
return False
+386 -21
View File
@@ -25,9 +25,11 @@ battery to zero on its own. Never "hold the last value to be safe".
import asyncio
import contextlib
import functools
import json
import logging
import signal
import time
from datetime import datetime, timedelta, timezone
import aiohttp
@@ -62,10 +64,11 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
)
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs,
unit_state_key)
from . import peakguard
from . import packshare
from . import pv as pv_read
from . import shadow
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option, provider_configured
@@ -73,8 +76,8 @@ from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (ConfigRefused, refuse, validate_startup_config,
validate_unit_ranges)
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
validate_startup_config, validate_unit_ranges)
from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
@@ -110,6 +113,12 @@ __all__ = ["UNIT_A", "Controller", "amain", "main"]
# the re-issue happens by itself) than hold the control loop for ten seconds.
WRITE_BUDGET_S = 4.0
# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
REANNOUNCE_S = 300.0
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
# --------------------------------------------------------------------------- #
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
#
@@ -688,6 +697,9 @@ class Controller:
# does, so the latch alone is not the alarm - re-raising it is.
# `run_control()` does that, once, before the first cycle.
self._circ_announced = False
# SAFETY-34: when each latched alarm was last put in front of HA, by
# notification_id - see REANNOUNCE_S.
self._announced_mono = {}
if self.circ_wd.tripped:
_LOG.error(
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
@@ -872,6 +884,27 @@ class Controller:
self.loop_i_lead = 0.0
self.loop_reason = "not-running"
self.loop_w = None # what the loop asked for last cycle, or None
# --- TEL-11: the site's PV production, and NOTHING READS IT ----------
# ⚠️ INSTRUMENTATION. It is published, it is shown on the status page,
# and it reaches no decision: not `inputs_ok`, not the allocator, not
# the arbiter, not a claim, not a watchdog. Wiring it into the control
# law would be an unrequested behaviour change on a system driving a
# real battery AND would destroy the measurement, because the loop would
# then react to the quantity being measured. `tools/tel11_pv_trace.py`
# is the instrument that says so, on both trees and both settings.
#
# ⚠️ `None` IS A READING THAT DOES NOT EXIST, AND IT IS SEEDED AS ONE.
# Nothing here ever becomes 0.0 by default; see `app/pv.py`.
self.pv_w = None
# Why there is no reading, in words the page prints - or, on a good
# read, the UNIT the value came in. Never empty.
self.pv_why = ("not configured" if not self.pv_roster
else "not read yet")
# TEL-12. EVERY configured entity's own answer, `(entity, watts, why)`,
# in roster order - kept because `checks()` has to be able to name the
# sensor that is missing, and `self.pv_w` alone cannot. Empty before the
# first read, which is what "no entity has answered yet" is.
self.pv_parts = []
# Did the grid-following loop actually win the LAST arbitration? That is
# the question SAFETY-02 has to ask about the command standing on the
# wire right now, so it is recorded when the arbitration happens rather
@@ -963,6 +996,8 @@ class Controller:
# the value a clamp bit is a value written to ONE device.
self.loop_clamped: tuple[bool, str] = (False, "")
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
# -- helpers -------------------------------------------------------------
def log_event(self, msg: str) -> None:
@@ -1651,6 +1686,57 @@ class Controller:
for inv in self.inverters)
# -- io ------------------------------------------------------------------
@property
def pv_roster(self) -> tuple:
"""Every configured PV production entity, or `()`. TEL-11/TEL-12.
⚠️ ONE READING OF THE OPTIONS, and it is `pv.entities()` - the SAME call
`mqtt.optional_keys()` makes. The thing that decides whether the row is
ANNOUNCED, the thing that decides whether it is READ and the thing that
decides WHAT IS SUMMED have to be one predicate, or the add-on ends up
with an entity nothing feeds, a topic nothing declared, or a total taken
over a different list from the announced one. `announced_units()` in
mqtt.py is the same argument for the inverter roster, in its own words.
⚠️ IT REPLACED A `pv_entity` PROPERTY RATHER THAN JOINING ONE. Two
properties where one is a subset of the other is the second spelling
arriving by the back door; every gate in this file reads this one.
"""
return pv_read.entities(self.o)
async def _read_pv(self) -> None:
"""This cycle's SITE PV production, or an ABSENCE with a reason.
TEL-11 for one entity, TEL-12 for a roster of them. `pv.total()` is where
"one of three is absent" becomes an absence of the SITE figure rather
than the sum of the two that answered, and its docstring is the argument.
⚠️ IT ASSIGNS ON EVERY CALL, INCLUDING THE FAILING ONES. Leaving the
last good number standing through an outage is what `circulation` does
on purpose - there, a stale figure beats a zero that reads as health -
and it is exactly wrong here: this row is read back as HISTORY, and a
value that persists through an outage is indistinguishable from a real
one at the moment somebody is trying to work out whether the sun was
shining.
⚠️ `hass.state()` RATHER THAN `hass.number()`, because the UNIT is half
the reading. See `pv.watts()`: a PV integration may publish kW, and
there is no magnitude heuristic that can separate 30 W of dusk from
30 kW of noon.
"""
# ⚠️ TEL-12: EVERY ENTITY IN ONE GATHER, for the reason the outer
# gather exists (see `read_inputs()`). Read serially, a three-inverter
# roster with one slow sensor would add up to three of `hass.state()`'s
# 10 s timeouts to a cycle that feeds the ESP32 heartbeat - i.e. the
# telemetry channel would be able to push a healthy battery towards
# layer 1's veto, which is exactly the foot in the control path this
# card is not allowed to have. Gathered, N entities cost what one does.
roster = self.pv_roster
states = await asyncio.gather(
*(self.hass.state(entity) for entity in roster))
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)), pv_read.inverted(self.o))
async def read_inputs(self, now_mono: float | None = None) -> None:
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
@@ -1682,8 +1768,26 @@ class Controller:
# names for the WRITE path, one function up. Within a unit the order is
# unchanged (SoC, then battery power), so at the shipped roster of one
# this is the same two calls in the same order.
await asyncio.gather(*(self._read_unit(inv, now_mono)
for inv in self.inverters))
#
# --- TEL-11: AND THE PV READ RIDES IN THIS SAME GATHER ---------------
# ⚠️ IN THE GATHER RATHER THAN ON A LINE OF ITS OWN, AND THAT IS THE
# WHOLE "OBSERVABILITY MUST NOT BE ABLE TO DELAY CONTROL" DECISION.
# This coroutine feeds the ESP32 heartbeat; a sequential read would add
# its own worst case - `hass.state()`'s 10 s timeout - to every cycle,
# so a hung PV sensor at a client site would push a healthy battery
# towards layer 1's ~30 s veto for the sake of a telemetry row.
# Gathered, its latency OVERLAPS reads that already happen and the
# added worst case is zero. `peak_forecast_entity` below is read
# serially and stays that way because it is a CONTROL input (the
# capacity guard sizes off it), so the site is entitled to wait for it.
#
# ⚠️ AND AN EMPTY PV ROSTER APPENDS NOTHING. Not a coroutine, not an
# HTTP call, not a log line: at the shipped default this gather is the
# same list of the same coroutines it has always been.
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
if self.pv_roster:
_reads.append(self._read_pv())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
raw = await self.hass.number(o["peak_forecast_entity"])
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
@@ -1785,13 +1889,28 @@ class Controller:
# 0 W it announces in the sentence before is exactly what stops the
# register ever being asked to report anything different. The live
# site sat in it for 790 s on 2026-09-21 and was freed by a human.
# ⚠️ THE DIAGNOSIS HALF IS SAFETY-56'S AND IS DELIBERATELY UNTOUCHED
# HERE - "is not following its own command" is wrong in a different
# way (a pack the BMS is refusing is not a faulty register) and that
# card is rewriting it. Two cards editing one sentence is how one of
# them silently loses.
_LOG.error("SAFETY-37: %s battery-power register is not following "
"its own command - %s. Treating it as MISSING, which "
# ⚠️ SAFETY-56 REWROTE THE DIAGNOSIS HALF, WHICH SAFETY-57 LEFT
# STANDING ON PURPOSE. It used to open "battery-power register is
# not following its own command" - an accusation against a named
# piece of hardware, and FALSE on the very episode above. The
# pack read 11 % for the whole hold, every command in it was a
# DISCHARGE, and the register answered the first CHARGE command
# three seconds later - so the BMS was refusing, the register was
# publishing that refusal correctly, and the operator was sent to
# look at a working sensor. All three numbers are measured off the
# committed capture by test_freshness.py part 11a. The gate's
# conjunction is right and is NOT touched; only its conclusion was.
#
# ⚠️ AND THE TWO EXPLANATIONS ARE NAMED ONCE, IN `%s`, NOT TWICE.
# This line's own words are the OBSERVATION plus the one thing a
# log reader has to be told before acting - that the observation is
# not yet a verdict - and `freshness._grade()`'s sentence, which
# this line already interpolates, is where the pair is spelled out.
# Saying it in both halves of one sentence is how an operator
# learns to skim it.
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
"ON THE REGISTER - %s. Treating it as MISSING, which "
"commands 0 W. It clears itself the moment the register "
"reports a different value, and failing that %.0f s "
"after the standing command falls below %.0f W "
@@ -3833,15 +3952,17 @@ class Controller:
The latch says "this site is tripped"; the flag says "this process has
put that in front of a human". A restart clears the flag and keeps the
latch, so the notification is RE-RAISED on the first cycle after every
add-on start - which is also the first cycle after a Home Assistant
restart, because the add-on is restarted with it.
add-on start. A Core-only restart does NOT restart the add-on, so it is
also re-asserted every REANNOUNCE_S (SAFETY-34).
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
an existing trip raise the alarm through these same lines. Two paths
would be two chances for the restart one to rot unnoticed, and the
restart one is the case nobody exercises by hand.
"""
if not self.circ_wd.tripped or self._circ_announced:
if not self.circ_wd.tripped:
return
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
return
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
# every cycle for a broker that is down would be an alarm storm on the
@@ -3849,6 +3970,7 @@ class Controller:
# already durable and the log line above already fired; this surface is
# best-effort by design.
self._circ_announced = True
self._announced_mono["goodwe_circulating_power"] = _mono()
names = self.units
pos, neg = opposing([inv.batt for inv in self.inverters],
min_w=self.circ_wd.cfg.min_w / 2.0)
@@ -3883,6 +4005,11 @@ class Controller:
f"know why two inverters were opposing."),
})
def _reannounce_due(self, notification_id: str) -> bool:
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
t = self._announced_mono.get(notification_id)
return t is None or _mono() - t >= REANNOUNCE_S
async def check_not_delivering(self) -> None:
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
@@ -3902,8 +4029,8 @@ class Controller:
not persisted. The latch says "this unit is tripped"; membership says
"this process has put that in front of a human". A restart empties the
set and keeps the latch, so the notification is RE-RAISED on the first
cycle after every add-on start - which is also the first cycle after a
Home Assistant restart, because the add-on is restarted with it.
cycle after every add-on start, and re-asserted every REANNOUNCE_S
because a Core-only restart does not restart the add-on (SAFETY-34).
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
argument. A fresh trip and a restart into an existing trip raise the
@@ -3921,7 +4048,10 @@ class Controller:
names = self.units
slugs = unit_slugs(names)
for inv, slug in zip(self.inverters, slugs):
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
nid = f"goodwe_not_delivering_{slug}"
if not inv.delivery_wd.tripped:
continue
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
continue
# Added BEFORE the await, for `check_circulating()`'s reason:
# `hass.call()` can raise or hang, and a retry every cycle for a
@@ -3929,9 +4059,10 @@ class Controller:
# latch is already durable and the log line already fired; this
# surface is best-effort by design.
self._delivery_announced.add(inv.name)
self._announced_mono[nid] = _mono()
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
await self.hass.call("persistent_notification", "create", {
"notification_id": f"goodwe_not_delivering_{slug}",
"notification_id": nid,
"title": f"GoodWe: {inv.name} is not delivering its share",
"message": (
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
@@ -4291,6 +4422,7 @@ class Controller:
await self.send_heartbeats(asyncio.get_running_loop().time(),
heartbeat)
self.publish()
self.loop_pass_mono = time.monotonic()
await asyncio.sleep(1)
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
@@ -4382,6 +4514,30 @@ class Controller:
"loop_lead": round(self.loop_i_lead, 1),
"loop_reason": self.loop_reason,
}
# --- TEL-11 ----------------------------------------------------------
# ⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO READING,
# AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. Those are two different
# states and the publisher treats them as two: `mqtt.ABSENT_AS_UNKNOWN`
# turns the first into HA's own `None` payload, so the entity's state
# goes to `unknown` and the recorder records a GAP - which is how a
# reader of a week of history tells "no PV reported" from "PV reported
# 0 W". The second never reaches the broker at all, because an install
# that never set `pv_entity` or `pv_entities` has no such entity
# announced. ⚠️ TEL-12: AND THE ROW IS STILL ONE NUMBER. At a roster of
# three it is the SITE total, `None` unless EVERY entity answered - see
# `pv.total()`, whose note is where the decision not to publish a
# partial sum is argued. This line did not have to change for that, and
# that is the point: TEL-11's `None`-is-an-absence rule already covered
# it, one layer up.
#
# ⚠️ AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE
# ANNOUNCEMENT USES. Written as a second predicate here the two could
# disagree, and both directions are bad - an announced entity nothing
# feeds, or a topic nothing declared. `announced_units()` two blocks
# down is the same discipline for the roster.
if "pv" in optional_keys(self.o):
values["pv"] = (None if self.pv_w is None
else round(self.pv_w, 1))
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
# note in mqtt.py; the composed name is not the object_id - and forces layer-1
@@ -4466,6 +4622,22 @@ class Controller:
"""
o = self.o
out = []
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
if self.loop_pass_mono is None:
out.append({"ok": True, "warn": False,
"text": "control loop: starting"})
else:
_pass_age = time.monotonic() - self.loop_pass_mono
if _pass_age > FIRMWARE_INGEST_VETO_S:
out.append({"ok": False, "warn": False,
"text": f"control loop: NO PASS FOR {_pass_age:.0f} s"
f" (layer 1 zeroes the inverter after "
f"{FIRMWARE_INGEST_VETO_S:.0f} s without a "
f"heartbeat)"})
else:
out.append({"ok": True, "warn": False,
"text": f"control loop: last pass {_pass_age:.0f} s ago"})
if self.p1_enabled:
age = self.p1.published_age_s
if self.p1.stale:
@@ -4663,8 +4835,38 @@ class Controller:
if not entity:
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
elif value is None:
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
# "<entity> is missing or not numeric" for ANY None, and one of
# the ways this value becomes None is SAFETY-37's freshness gate
# withholding a reading that is present, numeric and updating -
# `Freshness.reading()` returns None BY DESIGN there. On the
# live site on 2026-09-21 the operator opened Developer Tools on
# the entity this row named, found it working, and had every
# reason to conclude the diagnostics were broken.
#
# ⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The
# row could ask `inv.batt_fresh.stale` - it is on the same
# object at the same moment - but this loop also renders the
# grid and SoC rows, which have no gate, and a branch per row
# would be a second place where "why is this None" is decided.
# The gate already has a surface of its own: `stale_note()`
# rides `liveness_notes()` a few rows below with the whole
# STALE_READING sentence in it, so the operator's discriminator
# is "is that row there?" and this row's job is only to stop
# pre-empting the answer. Same reason the wording is not shared
# with `freshness._grade()`: that one is a log line about a
# BEHAVIOUR, this one is a banner about an ENTITY.
out.append({"ok": False, "warn": False,
"text": f"{label}: {entity} is missing or not numeric"})
"text": f"{label}: no usable value this cycle from "
f"{entity} - and this row cannot tell which "
f"of two things that is: the entity is "
f"missing or not numeric, or it is "
f"publishing a number that this controller "
f"is withholding (a freshness gate does "
f"exactly that, and names itself in a row "
f"of its own). Confirm the entity in "
f"Developer Tools before replacing it"})
else:
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
@@ -4869,6 +5071,120 @@ class Controller:
# is both is a check nobody reads.
out.append({"ok": False, "warn": True,
"text": "no peak forecast: maintenance charging is not capacity-capped"})
# --- TEL-11 ----------------------------------------------------------
# ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE
# DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one
# nags because an unset forecast entity leaves a MONEY GUARD without an
# input; this one is pure instrumentation, and a permanent caution on
# every install that never asked for a PV channel is exactly the
# "no warning spam, no degraded status row" the card forbids.
#
# ⚠️ AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a
# CAUTION, never `bad`. `status_payload()` builds "NOT READY" from rows
# that are neither ok nor warn, and a telemetry channel that could put a
# battery site into NOT READY would be observability with a foot in the
# control path. The reason comes from `pv.watts()` rather than being
# re-derived here, so the page cannot disagree with the reading.
#
# --- TEL-12: AND AT A ROSTER OF ONE THESE TWO SENTENCES ARE 0.11.0's --
# ⚠️ THE `len(...) == 1` BRANCH IS 0.11.0's CODE UNCHANGED, DOWN TO THE
# f-string, so an install that set `pv_entity` reads the row it already
# reads. That is an ACCEPTANCE CRITERION rather than a courtesy, and it
# is graded across trees by `tools/tel11_pv_trace.py` rather than argued
# here: `pv.total()` passes a single entity's `why` through verbatim for
# exactly this reason. Multi-entity sentences are separate BECAUSE they
# have to say something the single-entity ones never could.
roster = self.pv_roster
if roster:
# TEL-14: each flipped entity is marked where its watts are printed;
# an unflipped one gets "", so 0.12.0's sentences are unchanged.
flip = pv_read.inverted(self.o)
def inv(ent):
return " [sign inverted]" if ent in flip else ""
if len(roster) == 1:
if self.pv_w is None:
out.append({"ok": False, "warn": True,
"text": f"PV production: no reading from "
f"{roster[0]} - {self.pv_why}"})
else:
# habit 14: the value sits inside its own literal text, in
# its own slot, beside the entity it was read from and the
# unit it was read in - so a transposition of any two of the
# three is visible in the sentence rather than merely
# present in it.
out.append({"ok": True, "warn": False,
"text": f"PV production: {self.pv_w:.0f} W "
f"from {roster[0]} (reported "
f"in {self.pv_why})" + inv(roster[0])})
elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
# THOSE WORDS. `pv.total()`'s note is the argument; this is
# where the operator meets it. The partial information is NOT
# thrown away - every entity that reported is named with its
# watts and every one that did not is named with its reason -
# because this surface is read LIVE by a human who wants the
# diagnostic, whereas the MQTT row is read back COLD a week
# later by an attribution that would believe a smaller number.
# Different reader, different instant, different rule.
out.append({"ok": False, "warn": True,
"text": "PV production: NO SITE TOTAL - "
+ self.pv_why + ". Reporting: "
+ (", ".join(
f"{ent} {watt:.0f} W" + inv(ent)
for ent, watt, _y in self.pv_parts
if watt is not None) or "nothing")})
else:
# habit 14 at a roster: the total in its own slot, then EVERY
# contributor in its own slot with its own watts and its own
# unit. A sum that disagrees with the addends it is printed
# beside is visible in the sentence.
out.append({"ok": True, "warn": False,
"text": f"PV production: {self.pv_w:.0f} W from "
f"{len(self.pv_parts)} entities ("
+ ", ".join(
f"{ent} {watt:.0f} W in {why}"
+ inv(ent)
for ent, watt, why in self.pv_parts)
+ ")"})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is the
# right rule (its docstring says why) and is also the rule an
# operator upgrading from 0.11.0 is most likely to trip over: they
# add all their inverters to the new field and the old key is left
# standing, doing nothing. A replacement nobody is told about is the
# "one of which silently wins" `config.yaml` refuses elsewhere - and
# refusing to START over a telemetry key would be observability with
# a foot in the control path, so this is a CAUTION and never a `bad`
# row.
if pv_read.entity_is_ignored(self.o):
out.append({"ok": False, "warn": True,
"text": "PV production: `pv_entities` is set, so "
"`pv_entity` is IGNORED - the "
f"{len(roster)} entities in the "
"list are what is summed. Clear "
"`pv_entity` to remove this caution"})
# TEL-14: a sign that looks reversed, per entity rather than on the
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
for ent, watt, _y in self.pv_parts:
if watt is not None and watt < pv_read.NEGATIVE_W:
out.append({"ok": False, "warn": True,
"text": f"PV production: {ent} reads "
f"{watt:.0f} W - far below zero, so "
f"its sign is probably reversed. If "
f"it is producing now, "
+ ("REMOVE it from "
"`pv_invert_entities`"
if ent in flip else
"ADD it to `pv_invert_entities`")
+ " in the Configuration tab"})
for ent in pv_read.stray_inverts(self.o):
out.append({"ok": False, "warn": True,
"text": f"PV production: `pv_invert_entities` "
f"names {ent}, which is not a PV entity in "
f"use, so it flips nothing - check the "
f"spelling against `pv_entities`"})
return out
def status_payload(self) -> dict:
@@ -4988,6 +5304,13 @@ class Controller:
# identically zero and a permanent "0 W" row would be noise.
*(() if len(self.inverters) <= 1
else ((("Circulating", w(self.site_circulating_w)),))),
# TEL-11, and ONLY when a source is configured - see `checks()`.
# `w(None)` renders "—", which is how this row says "no reading"
# rather than "0 W". It sits beside Grid and Battery because the
# question it answers is the one those two cannot: at "Grid 0 W,
# Battery 0 W" the site is either becalmed or throwing away every
# watt it makes, and only this row separates them.
*(() if not self.pv_roster else ((("PV", w(self.pv_w)),))),
("State of charge", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
@@ -5196,6 +5519,30 @@ class Controller:
return None
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
EXIT_CONTROL_DIED = 70
class ControlTaskDied(RuntimeError):
"""The control task ended while nothing had asked it to stop."""
def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
died: list) -> None:
"""Done-callback on the control task: log its death now and wake amain()
to wind down and exit (SAFETY-61, see its DECISIONS record)."""
if stop.is_set() or controller.stopping:
return # the normal wind-down, or run_control()'s refusal path
exc = None if task.cancelled() else task.exception()
_LOG.critical("CONTROL LOOP DIED (%s) - commanding 0 W and exiting with "
"status %d", "cancelled" if task.cancelled() else
(repr(exc) if exc else "returned"), EXIT_CONTROL_DIED,
exc_info=exc)
died.append(exc)
stop.set()
async def amain() -> None:
opts = load_options()
logging.basicConfig(
@@ -5261,6 +5608,11 @@ async def amain() -> None:
broker.get("username") if broker else None,
broker.get("password") if broker else None,
omit=() if is_enabled(opts) else ("p1_age",),
# TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE
# OPTION - `Controller.publish()` gates the row on this same
# call. An install with no PV roster announces exactly
# what it announced before: no topic, no entity, no row.
include=optional_keys(opts),
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
# would construct a SECOND set of `Inverter`s off the same
# Store, minutes before `Controller.__init__` constructs the
@@ -5288,7 +5640,13 @@ async def amain() -> None:
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
tasks = [asyncio.create_task(controller.run_control())]
# SAFETY-61: the control task's death ends the process, after the
# wind-down below. See DECISIONS/SAFETY-61-control-task-death.md.
died: list = []
control = asyncio.create_task(controller.run_control())
control.add_done_callback(functools.partial(
control_task_done, controller=controller, stop=stop, died=died))
tasks = [control]
# OPT-04. The tariff refresh runs as its OWN long-lived task, for the
# reason P1 ingestion does and then some: `resolve_tariff()` may open an
@@ -5323,10 +5681,14 @@ async def amain() -> None:
for task in tasks:
task.cancel()
for task in tasks:
if task is control and died:
continue # already logged by control_task_done()
with contextlib.suppress(asyncio.CancelledError):
await task
await runner.cleanup()
_LOG.info("stopped")
if died:
raise ControlTaskDied(died[0])
# The late-arriving-device refusal from run_control(). Re-raised only
# now, so the wind-down above still ran and the inverter is at 0 W.
if controller.refused:
@@ -5345,6 +5707,9 @@ def main() -> None:
# EX_CONFIG, so even a script reading the exit status can tell a bad
# config from a bug.
raise SystemExit(78)
except ControlTaskDied:
# No second traceback: control_task_done() logged it at the death.
raise SystemExit(EXIT_CONTROL_DIED)
if __name__ == "__main__":
+109 -4
View File
@@ -117,6 +117,82 @@ SENSORS = [
("loop_reason", "goodwe_loop_reason", "Loop reason", None, None, None, "mdi:sigma"),
]
# --- TEL-11: rows that exist ONLY when the operator configured a source ------
#
# ⚠️ A SECOND LIST RATHER THAN A TENTH ENTRY IN `SENSORS`, AND THE DEFAULT IS
# WHAT MAKES IT ONE. `SENSORS` is announced by every caller of
# `discovery_payloads()`; `OPTIONAL_SENSORS` is announced only by a caller that
# ASKS, so a caller that has not been taught about `pv_entity` - `test_mqtt.py`'s
# fixtures, `tools/rehearse_roster.py`, anything written next year - announces
# exactly what it announced before. `p1_age` solves the same problem the other
# way round, with `omit`, because `p1_age` is announced by DEFAULT and withdrawn
# when the meter is off; this one does not exist by default and appears when a
# source for it does. Getting that direction wrong is the hazard `omit`'s own
# note names: an entity that exists and is never fed is a worse signal than one
# that does not exist at all, and MOST installs will never set `pv_entity`.
#
# ⚠️ `state_class: measurement` IS THE WHOLE POINT OF THE ROW. It is what puts
# the number into the recorder's long-term statistics, and a week of statistics
# is what OPT-10 needs to say whether the surplus that left this site was cloud,
# a charge-power limit, or the optimiser declining to charge.
OPTIONAL_SENSORS = [
("pv", "goodwe_pv_power", "PV power", "W", "power", "measurement", None),
]
# ⚠️ HA's OWN "this sensor has no value" PAYLOAD, and the one place this add-on
# spells it. `homeassistant/components/mqtt/const.py` declares
# `PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric
# branch - `if payload == PAYLOAD_NONE: self._attr_native_value = None` - so the
# entity's state becomes `unknown` rather than a number. (Read out of
# github.com/home-assistant/core `dev` on 2026-09-24; the shipped MQTT-sensor
# documentation says the same thing in its `state_topic` entry: "a 'None' value
# will set the sensor to an unknown state".)
PAYLOAD_NONE = "None"
# The keys whose ABSENCE MUST BE PUBLISHED rather than skipped. ⚠️ THIS IS THE
# DIFFERENCE BETWEEN "no PV reported" AND "PV reported 0 W" IN A WEEK OF
# HISTORY, AND WITHOUT IT THE CHANNEL LIES IN THE WORSE DIRECTION. `publish()`
# below drops a `None` - which is right for `circulation`, where the last figure
# standing is better than a zero that reads as health - but a DROPPED pv row
# leaves Home Assistant holding the last number it was told, forever, and the
# recorder then draws a flat line at that value through an outage. Someone
# reading it back would conclude the sun was not shining, which is precisely the
# conclusion this card exists to TEST rather than to manufacture.
ABSENT_AS_UNKNOWN = frozenset({"pv"})
def optional_keys(opts: dict) -> tuple:
"""Which `OPTIONAL_SENSORS` rows this configuration has a source for.
⚠️ ONE ANSWER TO "IS THE PV ROW LIVE", read by the ANNOUNCEMENT (through
`MqttPublisher`) and by `Controller.publish()`. `announced_units()` above
exists for exactly this reason and says it in full: two spellings of one
predicate is how an entity gets announced that nothing feeds, or fed on a
topic nothing announced.
⚠️ AND SINCE TEL-12 IT IS `pv.entities()` RATHER THAN A SECOND READING OF
THE OPTION - `Controller.pv_roster` is the SAME call. TEL-11 got away with
two spellings because both were `bool()` of one string; with `pv_entities`
beside `pv_entity`, and `pv_entities` REPLACING it, a second spelling is how
the row gets announced off one key and summed off the other. ⚠️ THE ROW IS
ONE SITE TOTAL AT EVERY ROSTER SIZE: there is deliberately no per-entity
topic, because the recorder statistic OPT-10 reads back is the site figure
and a per-inverter breakdown is a dashboard question the operator's own PV
integration already answers.
"""
# ⚠️ THE IMPORT IS FUNCTION-LOCAL AND THAT IS NOT A STYLE CHOICE. This
# module is EXEC'D STANDALONE, outside its package, by `test_mqtt.py`
# part 8's mutation harness - which says so in its own comment, and whose
# honesty depends on it ("`app/mqtt.py` imports nothing from this package,
# which is what makes exec'ing it standalone honest rather than a stub").
# A module-scope `from . import pv` turns that whole instrument into an
# `ImportError` before it grades anything. `tools/tel11_pv_trace.py`'s
# `_load()` is the same decision for the same kind of reason. Measured, not
# assumed: move this line to module scope and test_mqtt.py part 8 dies.
from . import pv as _pv
return ("pv",) if _pv.entities(opts) else ()
BASE = "goodwe_ctl"
AVAILABILITY = f"{BASE}/availability"
@@ -299,15 +375,23 @@ def _payload(name, object_id, unique_id, state_topic, device,
return cfg
def discovery_payloads(units=(), omit=()) -> list:
def discovery_payloads(units=(), omit=(), include=()) -> list:
"""Every `(topic, payload)` this add-on announces, in announcement order.
Split out of `_announce()` so that the acceptance criterion can be graded as
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
reading of the code. `_announce()` publishes exactly this list and nothing
else, which is itself checked.
`include` names `OPTIONAL_SENSORS` keys to announce. ⚠️ IT DEFAULTS TO
NOTHING AND THE ROWS GO LAST, so no existing row's POSITION moves under any
configuration - not the site rows, not the per-unit ones. Order is part of
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
ordered LIST precisely because a re-order is a zero-byte edit that
re-announces every entity on a live install).
"""
omit = set(omit)
include = [k for k in include if k not in omit]
out = []
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
if key in omit:
@@ -336,12 +420,19 @@ def discovery_payloads(units=(), omit=()) -> list:
unique_id,
f"{BASE}/{unit_state_key(slug, key)}",
device, unit, dev_class, state_class, icon)))
for key, object_id, name, unit, dev_class, state_class, icon in OPTIONAL_SENSORS:
if key not in include:
continue
out.append((
f"homeassistant/sensor/{BASE}_{key}/config",
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
DEVICE, unit, dev_class, state_class, icon)))
return out
class MqttPublisher:
def __init__(self, host, port, username=None, password=None, omit=(),
units=()):
units=(), include=()):
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
# sensor that nothing will ever publish to is not harmless here:
# p1_sample_age_s is a watchdog input, and an entity that exists but is
@@ -353,6 +444,10 @@ class MqttPublisher:
# per-unit is announced, so every caller that has not been taught about
# the roster keeps publishing what it published before.
self.units = [str(u) for u in units]
# TEL-11. `mqtt.optional_keys(opts)` - the shipped predicate, not a
# second reading of the option. Defaults to empty, so a caller that has
# not been taught about it announces exactly what it announced before.
self.include = [str(k) for k in include]
self.enabled = mqtt is not None and bool(host)
self.client = None
if not self.enabled:
@@ -389,7 +484,8 @@ class MqttPublisher:
# announcement, so the check that diffs it against `release/1.0` is
# diffing what actually goes on the wire - and a payload built here
# instead would be one this add-on publishes and no check has ever seen.
for topic, cfg in discovery_payloads(self.units, self.omit):
for topic, cfg in discovery_payloads(self.units, self.omit,
self.include):
self.client.publish(topic, json.dumps(cfg), retain=True)
self.client.publish(AVAILABILITY, "online", retain=True)
@@ -399,7 +495,16 @@ class MqttPublisher:
try:
for key, value in values.items():
if value is None:
continue
# ⚠️ TEL-11. A DROPPED ROW IS NOT AN ABSENT ROW. Skipping
# leaves Home Assistant holding the last value it was told,
# which is what `circulation` wants (its note in main.py
# says why) and is exactly wrong for a measurement somebody
# will read back as history: a flat line at the last number
# is indistinguishable from a real one. See
# `ABSENT_AS_UNKNOWN`.
if key not in ABSENT_AS_UNKNOWN:
continue
value = PAYLOAD_NONE
self.client.publish(f"{BASE}/{key}", str(value))
except OSError as err: # pragma: no cover
_LOG.debug("MQTT publish failed: %s", err)
+274
View File
@@ -0,0 +1,274 @@
"""TEL-11/TEL-12 - the site's PV production, read from Home Assistant entities.
WHY THIS IS AN ENTITY AND NOT A REGISTER. This add-on's RS485 bus carries the
BATTERY inverter it drives. A household's PV inverter is, on nearly every site,
a different box from a different vendor that reports into Home Assistant
through its own integration - so the only place a product can read production
for an arbitrary install is an HA entity. `pv_entities` therefore takes whatever
`sensor.*` the site's own PV integration provides.
⚠️ AND THERE IS USUALLY MORE THAN ONE OF THEM, WHICH IS TEL-12. TEL-11 shipped
`pv_entity` as a single string and the owner of the reference site reported
within minutes of 0.11.0 going live that they have SEVERAL PV inverters and
could configure one. That is not a property of one house: a multi-string or
multi-orientation roof routinely carries two or three inverters, each
publishing its own power sensor. `entities()` below is the roster and `total()`
is the site figure over it.
⚠️ AND A PARTIAL TOTAL IS WORSE THAN NO TOTAL, WHICH IS THE WHOLE OF TEL-12.
Three inverters configured, one `unavailable`, and the sum of the other two is A
SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Fed into OPT-10's attribution
that reads as "there was less PV than there was" - which is the "no irradiance"
conclusion the card exists to TEST rather than to manufacture. So `total()`
answers `None` the moment ANY configured entity is absent. See the note on
`total()` for the alternative that was weighed and why it lost.
WHY THE CHANNEL EXISTS AT ALL. OPT-10 measured that 20.5 kWh of surplus left the
site in 16 days while the pack had room, 12.6 kWh of it with the battery not
charging at all - and could not say WHY, because PV production is recorded
NOWHERE. Three causes need three different fixes (no irradiance: nothing to fix;
a charge-power limit; the optimiser declining to charge - the only software
defect), and guessing between them is how an optimiser gets tuned to solve a
cloud problem. This module is the missing measurement and NOTHING ELSE.
⚠️ NOTHING HERE MAY EVER REACH A CONTROL DECISION, AND THAT IS THE CARD'S FIRST
CONSTRAINT RATHER THAN A STYLE NOTE. The add-on regulates a real battery; adding
production as a term of the control law would be an unrequested behaviour change
AND it would destroy the measurement, because the loop would then be reacting to
the quantity being measured. `tools/tel11_pv_trace.py` is what says the path is
untouched: it grades a control document that contains none of this material on
both trees and with `pv_entity` set and unset.
⚠️ ABSENCE IS NEVER ZERO, AND THAT IS THE SECOND CONSTRAINT. Every refusal below
returns `None` with a REASON, never `0.0`. A PV channel that reported 0 W for a
dead sensor would manufacture exactly the "there was no surplus to store"
conclusion the card exists to TEST - and it would do it silently, in a week of
recorder history nobody would re-derive. `app/hass.py`'s own note on `state()`
is the same rule one layer down. ⚠️ TEL-12 EXTENDS THAT RULE RATHER THAN
WEAKENING IT: at a roster of three, "one of them is absent" is an absence of the
SITE figure, and it publishes as an absence. A partial sum is a second way of
reporting 0-where-there-was-production, one layer up, and it is refused for the
same reason.
THE SIGN CONVENTION: POSITIVE IS PRODUCTION, as for `meter_entity` and
`batt_entity` ("power arriving at the house's AC bus"). TEL-14: a kWh meter or
power clamp in the PV circuit reads the other way, so `pv_invert_entities`
flips the sign per entity. See `DECISIONS/TEL-14-pv-sign.md`.
"""
from .hass import BAD_STATES
# Home Assistant's OWN power units, each as the number of watts one of it is.
# ⚠️ READ OUT OF HA, NOT LISTED FROM MEMORY: `UnitOfPower` in
# `homeassistant/const.py` (github.com/home-assistant/core, `dev`, retrieved
# 2026-09-24) declares mW, W, kW, MW, GW, TW and BTU/h. Every factor here is an
# exact power of ten, so none of them is a number anybody chose.
#
# ⚠️ BTU/h IS IN HA's ENUM AND DELIBERATELY NOT HERE. Its factor (0.293071…)
# is a measured conversion rather than a decimal shift, no PV integration
# publishes in it, and a wrong constant in the one channel that exists to
# attribute a 20.5 kWh shortfall is worse than a refusal an operator can read.
# It lands in the "not a power unit" refusal below, BY NAME.
WATTS_PER = {
"mW": 0.001,
"W": 1.0,
"kW": 1000.0,
"MW": 1000000.0,
"GW": 1000000000.0,
"TW": 1000000000000.0,
}
# What `watts()` answers when there is nothing to report. ⚠️ THE REASON IS PART
# OF THE RETURN VALUE, not a log line: it is what the status page prints, and an
# absence whose cause is invisible sends an operator to look at the wrong box.
UNIT_KEY = "unit_of_measurement"
def watts(state: dict | None) -> tuple:
"""`(watts, why)` from a Home Assistant state object.
`watts` is `None` whenever there is no reading - and `why` then says which
of the five refusals it was, in words an operator can act on. On a good read
`why` is the unit the value was read in, because "1234 W" and "1234, unit
unknown" are different claims and the page has to be able to make the first.
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
real production at dusk is an ordinary reading, and that heuristic would
publish it as 30 kW - a fabricated number, in the channel whose entire
purpose is to be believed a week later. A sensor whose unit this cannot read
is REFUSED rather than guessed at.
"""
if state is None:
# `hass.state()` answers None for a non-existent entity, a 404 and a
# transport failure alike. All three are "no reading", and HA does not
# consider a non-existent entity an error - see `hass.state()`.
return None, "no state (entity missing, or the read failed)"
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
# ⚠️ THE SAME LIST `hass.number()` REFUSES ON, IMPORTED RATHER THAN
# RESPELLED. A second copy of it is how one path starts treating
# `unavailable` as a number the other one rejects.
return None, f"entity is {raw or 'empty'}"
try:
value = float(raw)
except ValueError:
return None, f"state {raw!r} is not numeric"
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
if not unit:
return None, ("no unit_of_measurement on the entity - it cannot be "
"read in watts")
if unit not in WATTS_PER:
return None, (f"unit {unit!r} is not one of "
f"{', '.join(WATTS_PER)} - it cannot be read in watts")
return value * WATTS_PER[unit], unit
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
def entities(opts: dict) -> tuple:
"""Every PV entity this configuration names, in configured order. TEL-12.
⚠️ ONE ANSWER TO "WHICH ENTITIES ARE THE PV CHANNEL", and it is the answer
BOTH sides read. `Controller.pv_roster` is this call and
`mqtt.optional_keys()` is `bool()` of this call, so the thing that decides
whether the row is ANNOUNCED, the thing that decides whether it is READ and
the thing that decides HOW MANY entities are summed cannot disagree. TEL-11
already had that discipline for one entity and spelled the predicate TWICE
- `str(opts.get("pv_entity") or "").strip()` in `main.py` and again in
`mqtt.py`. Two spellings of one predicate is how an entity gets announced
that nothing feeds; a roster makes it how a SUM gets taken over a different
list from the one that was announced. It is one function now.
⚠️ `pv_entities` REPLACES `pv_entity` RATHER THAN EXTENDING IT, and that is
CFG-04's shipped precedent (`inverters:` replaces the six flat keys) applied
to the only upgrade path that matters. The install this card exists for HAS
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural
action is to put ALL the inverters in the new repeatable field - and under a
UNION rule that double-counts A unless the operator also remembers to clear
the old key. A doubled total is the same defect as a partial one in the
other direction, in the one channel that exists to be believed a week later.
Under REPLACE the effective roster is exactly what one key says, and no
arithmetic depends on a de-duplication rule the operator has to know about.
⚠️ `Controller.checks()` says WHICH key is in use whenever both are set, so
the replacement is stated on the page rather than being silent.
⚠️ AND IT DE-DUPLICATES WITHIN THE LIST, ORDER PRESERVED. A site cannot have
two sensors with one entity_id, so a repeated id in the list is never a
second inverter - it is a typo that would DOUBLE that inverter's production
in the total. `dict.fromkeys` is the order-preserving spelling.
"""
listed = [e for e in (str(x or "").strip()
for x in (opts.get("pv_entities") or [])) if e]
if listed:
return tuple(dict.fromkeys(listed))
one = str(opts.get("pv_entity") or "").strip()
return (one,) if one else ()
def entity_is_ignored(opts: dict) -> bool:
"""Is a set `pv_entity` being REPLACED by a set `pv_entities`? TEL-12.
⚠️ HERE RATHER THAN IN `Controller.checks()`, for the reason `entities()`
exists: the rule and the sentence that reports the rule must not be two
readings of the options. This is the negative half of `entities()`' first
branch and it is spelled once, beside it, where an edit to one is in front
of whoever is editing the other.
"""
return bool(str(opts.get("pv_entity") or "").strip()
and [x for x in (opts.get("pv_entities") or [])
if str(x or "").strip()])
def inverted(opts: dict) -> frozenset:
"""The entity ids `pv_invert_entities` flips. TEL-14."""
return frozenset(e for e in (str(x or "").strip()
for x in (opts.get("pv_invert_entities") or []))
if e)
def stray_inverts(opts: dict) -> list:
"""`pv_invert_entities` entries naming no entity in the roster - a typo
that would otherwise leave a sign unflipped with nothing said. TEL-14."""
roster = set(entities(opts))
return sorted(e for e in inverted(opts) if e not in roster)
# TEL-14: a reading below this is not standby draw. A correctly signed meter in
# the PV circuit reads a few watts NEGATIVE at night (the owner's: 13 W); -50 W
# clears that with room for a couple of such devices on one entity, while a
# reversed sensor in daylight reads hundreds to thousands of watts below zero.
NEGATIVE_W = -50.0
def total(readings, flip=frozenset()) -> tuple:
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
per configured entity, each `state` shaped as `hass.state()` returns.
`parts` is `[(entity_id, watts, why), ...]` - EVERY entity's own answer from
`watts()`, kept because the operator surfaces have to be able to name which
sensor is missing. `watts` is the sum when every entity answered, and `None`
otherwise; `why` always says which of those it was, in words a status page
can print.
⚠️ ONE OR MORE ABSENT -> THE TOTAL IS ABSENT. NOT THE PARTIAL SUM. This is
the decision the card turns on and it was taken deliberately against the
alternative, which was to publish the partial sum ALONGSIDE a count of how
many entities contributed. Both are defensible; this one wins on the ONE
question that matters, which is who reads the number and when:
* the MQTT row is read back COLD, a week later, by OPT-10's attribution
and by whatever plots `sensor.goodwe_pv_power` over that week. A partial
sum there is a smaller number indistinguishable from a real one, and the
count that would have qualified it lives in a SECOND entity with its own
history - so believing the total requires joining two recorder series by
timestamp to find out which samples to distrust. Nobody re-derives that,
which is the same "nobody will re-derive it" TEL-11's note names. And it
is worse than a gap in exactly the direction that matters: a gap makes
the attribution say "I do not know", a partial sum makes it say "there
was less sun than there was".
* the STATUS PAGE is read LIVE, by a human, who wants the diagnostic. So
the partial information is not thrown away - `Controller.checks()` names
every entity that DID report, with its watts, and every one that did
not, with its reason, in a row that states plainly that there is no site
total. Different reader, different instant, different rule.
A count published beside the total would also carry no information in the
state this function actually admits: the total is published ONLY when every
entity contributed, so the count would be the constant `len(parts)`.
⚠️ AND `0 W` IS STILL A READING AT EVERY ROSTER SIZE - the test is
`watts is None`, never falsiness. An inverter honestly reporting 0 W at dusk
beside two that are producing must give the SUM of the three, not blank the
site: shading, a tripped string and nightfall are all real 0s, and a roster
that went to `unknown` whenever one member read zero would report an absence
on every clear evening and defeat the channel from the other end.
⚠️ TEL-14: `flip` is `inverted(opts)`; each member's sign is applied HERE,
before the sum, so every surface reads the corrected figure. `0.0 - w`
rather than `-w`, so a flipped 0 W is `0.0` and never `-0.0` on the wire.
"""
parts = [(ent, w if w is None or ent not in flip else 0.0 - w, why)
for ent, (w, why) in ((e, watts(st)) for e, st in readings)]
if not parts:
# No roster. `Controller` does not call this in that state - the gate is
# `pv_roster` - but a reader of `parts` should never have to wonder.
return None, "not configured", parts
if len(parts) == 1:
# ⚠️ AT ONE ENTITY THIS IS `watts()` AND NOTHING ELSE: the same value
# and the SAME `why` string, passed through rather than re-composed, so
# 0.11.0's single-entity surfaces are byte-identical rather than
# similar. `tools/tel11_pv_trace.py` grades that identity across trees.
return parts[0][1], parts[0][2], parts
missing = [(ent, why) for ent, w, why in parts if w is None]
if missing:
return None, (
f"only {len(parts) - len(missing)} of {len(parts)} entities are "
f"reporting and a partial sum would understate production: "
+ "; ".join(f"{ent} - {why}" for ent, why in missing)), parts
return (sum(w for _e, w, _y in parts),
f"{len(parts)} entities", parts)
+112 -6
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.10.1"
version: "0.12.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -93,7 +93,7 @@ options:
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
# validate.py rule 9 enforces it again for the callers the Supervisor never
# validate.py rules 8 and 9 enforce it again for the callers the Supervisor never
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
# SENSOR - `inputs_ok` already stops the site for that - and never means
# "use the other one's".
@@ -147,6 +147,76 @@ options:
inverters: []
estop_fitted: false
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
# when it is empty the add-on reads no entity, announces no entity, publishes
# no topic and shows no status row.
#
# ⚠️ AN HA ENTITY AND NOT A REGISTER, BECAUSE MOST SOLAR INVERTERS ARE NOT ON
# THIS BUS. The RS485 bus this add-on speaks carries the BATTERY inverter it
# drives; a household's PV inverter is normally a different box from a
# different vendor, reporting into Home Assistant through its own integration.
# So this takes whatever `sensor.*` the site's PV integration provides, any
# brand Home Assistant can read.
#
# POSITIVE IS PRODUCTION. An entity that reads the other way round goes in
# `pv_invert_entities` below - see DECISIONS/TEL-14-pv-sign.md.
#
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
# and the rest of Home Assistant's power units. A sensor with no unit, or with
# a unit that is not power, is REFUSED and the status page says which: there
# is no magnitude heuristic that can tell 30 W of dusk from 30 kW of noon, and
# a factor of 1000 in the one channel that exists to be believed a week later
# is worse than a refusal an operator can read.
pv_entity: ""
# TEL-12 - THE SAME CHANNEL WHEN THE ROOF HAS SEVERAL INVERTERS, WHICH IS THE
# ORDINARY CASE AND NOT AN EDGE ONE. A multi-string or multi-orientation roof
# routinely carries two or three PV inverters, each publishing its own power
# sensor; 0.11.0 shipped `pv_entity` as a single string and the reference
# site's owner reported within minutes that they could configure one of
# theirs. List them all here and the row carries the SITE total.
#
# ⚠️ DECLARED, THIS REPLACES `pv_entity` - it does not extend it. Set both and
# ONLY this list is read, and the status page says so. That is CFG-04's rule
# for `inverters:` applied to the upgrade path this key exists for: the
# install being upgraded HAS `pv_entity` set, and an operator who adds all
# their inverters to this list while the old key still names one of them would
# DOUBLE that inverter under a union rule. Clear `pv_entity` when you fill
# this in.
#
# ⚠️ AND IF ANY ONE OF THEM IS NOT REPORTING, THERE IS NO SITE TOTAL - the row
# publishes `unknown` and the recorder records a GAP. It does NOT publish the
# sum of the ones that answered: two of three inverters add up to a smaller
# number that is indistinguishable from a real one, and a week later that
# reads as "there was less sun than there was" - the exact conclusion this
# channel exists to TEST rather than to manufacture. The status page names
# which entity is missing and why, and lists what the others reported, because
# a human reading it live wants the diagnostic; the recorded number does not,
# because nobody re-derives a week of history. See
# DECISIONS/TEL-12-several-pv-inverters.md.
#
# ⚠️ THE FIX IS NOT A TEMPLATE SENSOR THAT SUMS THEM. You may legitimately
# prefer one, but a template sensor summing an `unavailable` source yields the
# partial total silently - the same defect one layer down, in a place this
# add-on cannot see or report.
#
# pv_entities:
# - sensor.pv_east_power
# - sensor.pv_west_power
# - sensor.pv_garage_power
#
# Each entity's OWN `unit_of_measurement` is read, so a kW inverter beside a W
# one sums correctly. Empty is the shipped default and means "use `pv_entity`,
# which is also empty" - i.e. no PV channel at all, which is every install.
pv_entities: []
# TEL-14. Every PV entity listed here (in `pv_entity` or `pv_entities`) has
# its sign flipped, for a kWh meter or power clamp that reads production as
# negative. Per entity, because one roster can mix both conventions. `[]` is
# load-bearing like `pv_entities: []`; see DECISIONS/TEL-14-pv-sign.md.
pv_invert_entities: []
# ==========================================================================
# METER - where the grid reading comes from
# ==========================================================================
@@ -156,7 +226,7 @@ options:
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
# homewizard_local skips Home Assistant entirely and polls the meter's own
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
# local API. ⚠️ It is the ONLY mode whose sensor.goodwe_p1_sample_age measures when
# the meter REPORTED rather than when the value last CHANGED - HA emits
# nothing at all for a repeated reading - so it is the only mode a firmware
# watchdog may threshold. See DOCS.md.
@@ -848,12 +918,13 @@ schema:
# batteries, whereas an absent capacity has an honest answer - no opinion
# about the split, every unit weighs 1.0, which is what shipped.
#
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# ⚠️ FOUR KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# no `name` has no Store namespace and would share another unit's SAFETY-02
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
# no `max_w` would have to borrow a rating, which is the sibling-grading
# defect DEV-02 removed. The optional pair is the sensors, whose absence
# honestly means "this unit reports none".
# defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's (options block above).
# The optional keys are the sensors, whose absence honestly means "this unit
# reports none", and `capacity_kwh`.
#
# ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears
# TWICE under `schema:` - once nested here, once flat below - and a reader
@@ -872,6 +943,41 @@ schema:
capacity_kwh: float(0.1,200)?
estop_fitted: bool
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
# `price_now_entity`/`price_avg_entity`, which are legal only while
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
# Nothing in validate.py mentions this key: it cannot conflict with anything,
# because nothing else reads it.
pv_entity: str?
# TEL-12. ⚠️ A SECOND KEY RATHER THAN A LIST SHAPE ON `pv_entity`, AND THAT IS
# A MEASUREMENT AND NOT A PREFERENCE. `pv_entity` shipped in 0.11.0 as `str?`
# and the reference site has a string persisted in it. Declared as a list,
# Supervisor 2026.08.0 answers that stored string with
#
# Invalid list for option 'pv_entity' in GoodWe RS485 Controller
#
# and `deepmerge`'s list strategy is `override`, so a shipped `[]` default does
# NOT rescue it - the operator's string stands and is refused. That is CFG-04
# §B's trap in the other direction: an add-on that will not start, and the
# schema needed to fix it pinned to the version that cannot be installed. The
# only single-key spelling Supervisor accepts for BOTH shapes is `match(.*)?`,
# which validates nothing at all (it admits a number, a dict, any list of
# anything) and renders as a plain text box. Measured, both of them, by
# `tools/supervisor_shape_probe.py` §G - the transcript is in
# DECISIONS/TEL-12-several-pv-inverters.md §1.
#
# ⚠️ AND `pv_entities: []` IN THE `options:` BLOCK IS LOAD-BEARING, exactly as
# `inverters: []` is. §G measures the same isolated counterfactual: the
# shipped defaults MINUS only this key are refused with `Missing option
# 'pv_entities' in root`, which is every options blob written before 0.12.
# `p1_phase_import_entities` is the shipped precedent for the pair.
pv_entities:
- str
# TEL-14. A parallel list, not a prefix or a dict: measured by
# tools/supervisor_shape_probe.py section H, DECISIONS/TEL-14-pv-sign.md.
pv_invert_entities:
- str
# ==========================================================================
# METER - where the grid reading comes from
+99
View File
@@ -0,0 +1,99 @@
"""Read a value a check prints or grades without raising (TEST-26).
Each test file is one script, so an IndexError in a print or a check name
kills every check after it (MUTATION.md, FCST-06). Read through these instead,
and conjoin `present(x)` in the check so the absence grades as a named FAIL.
`ABSENT` values never raise: they format as `<ABSENT ...>` under any spec,
index and attribute to themselves, compare unequal AND not-unequal, and are
falsy. `not x`, `"a" not in x` and `all(... for _ in x)` are therefore TRUE of
one, which is why the check needs the `present()` term. `int()`, `range()` and
use as a list index still raise: guard those with `present()`.
"""
_NAN = float("nan")
class Absent:
__slots__ = ("what",)
def __init__(self, what):
object.__setattr__(self, "what", what)
def __repr__(self):
return f"<ABSENT {self.what}>"
__str__ = __repr__
def __format__(self, spec):
return repr(self)
def __bool__(self):
return False
def __eq__(self, other):
return False
__ne__ = __lt__ = __le__ = __gt__ = __ge__ = __eq__
def __hash__(self):
return id(self)
def __getitem__(self, _):
return self
def __getattr__(self, _):
return self
def __call__(self, *_a, **_k):
return self
def __len__(self):
return 0
def __iter__(self):
return iter(())
def __contains__(self, _):
return False
def __float__(self):
return _NAN
def _same(self, *_):
return self
__add__ = __radd__ = __sub__ = __rsub__ = __mul__ = __rmul__ = _same
__truediv__ = __rtruediv__ = __floordiv__ = __rfloordiv__ = _same
__mod__ = __rmod__ = __neg__ = __pos__ = __abs__ = __round__ = _same
def present(*xs):
"""True when none of `xs` is an ABSENT stand-in."""
return not any(isinstance(x, Absent) for x in xs)
def nth(seq, i=0, what=None):
"""`seq[i]`, or an ABSENT saying how many items there were."""
n = 0 if seq is None else len(seq)
if -n <= i < n:
return seq[i]
return Absent(what or f"[{i}] of {n} item(s)")
def first(it, what="no match"):
"""`next(it)`, or ABSENT when it is exhausted."""
return next(iter(it), Absent(what))
def field(mapping, key, what=None):
"""`mapping[key]`, or ABSENT when the key (or the mapping) is missing."""
try:
return mapping[key]
except (KeyError, IndexError, TypeError):
return Absent(what or f"key {key!r}")
def given(x, what="None"):
"""`x`, or ABSENT when it is None - so `:.2f` and arithmetic cannot raise."""
return Absent(what) if x is None else x
+3 -5
View File
@@ -10,12 +10,10 @@ IN FULL.
⚠️ WHAT THIS FILE CAN AND CANNOT CLAIM, SAID BEFORE THE FIRST CHECK.
The two-master rig lives in admin/goodwe (TEST-19, `sim/sitesim.py`), where
`compute()` closes the loop against `plantsim.Plant` with the firmware's own
encoder on the wire. That repository is PRIVATE and this repository's CI token
is minted for this one, so the rig is not reachable from the gate - and a test
file that needed it would either fail the gate or, like test_freeze.py's hwsim
section, pass SILENTLY having checked nothing.
encoder on the wire. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12),
but this file does not import the rig.
So the split is deliberate:
The split:
* `tools/alloc_sim.py` drives the REAL rig, and its output is quoted in the
pull request. It is where "no limit cycle" is a claim about the shipped
+13 -11
View File
@@ -79,6 +79,7 @@ import sys
from datetime import datetime, timedelta, timezone
from app import arbitrage as _arb
from named_absence import first
from app import main as _main
from app import peakguard as _pg
from app.arbiter import (
@@ -933,12 +934,12 @@ check("main.py passes the guard's OWN measured headroom at its one "
# once in `__init__` and never refreshed would satisfy the pin above and would
# size every trade from the guard decision of whatever cycle happened to run
# last - which is the defect the hoist's own comment warns about, one level up.
_s45_cycle = next(n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "cycle"
and any(isinstance(c, ast.Call)
and ast.unparse(c.func).endswith("arbitrage.decide")
for c in ast.walk(n)))
_s45_cycle = first((n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "cycle"
and any(isinstance(c, ast.Call)
and ast.unparse(c.func).endswith("arbitrage.decide")
for c in ast.walk(n))), "no cycle() calling arbitrage.decide")
check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
"so the headroom the trade is sized from is this cycle's guard decision "
"and not one carried from an arbitrary earlier one",
@@ -1069,22 +1070,23 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
res = resolve(claims)
target = res.target_w
head_w = _pg.charge_headroom_w(pk) if sized else HEADROOM_CLEAR
rec.append(dict(
row = dict(
t_s=t, action=arb.action, power_w=arb.power_w,
reason=arb.reason, price=arb.price_now_eur_kwh,
spread=arb.spread_eur_kwh, winner=res.winner,
target_w=float(res.target_w), headroom_w=head_w,
engaged=bool(pk.engaged),
sized_from_meter=_arb.sized_from_meter(
arb, head_w, winner=res.winner)))
arb, head_w, winner=res.winner))
rec.append(row)
if shadow:
# --- TRANSCRIBED 1/3: `_shadow_settle()`'s record, taken
# BEFORE the re-seed destroys its evidence.
rec[-1]["would_w"] = float(res.target_w)
rec[-1]["row"] = _shadow.words(
row["would_w"] = float(res.target_w)
row["row"] = _shadow.words(
{"shadow_mode": "on"}, control_started=False,
would_w=float(res.target_w),
sized_from_meter=rec[-1]["sized_from_meter"],
sized_from_meter=row["sized_from_meter"],
reason=res.explain())
# --- TRANSCRIBED 2/3: `_write_unit()`'s substitution - the
# plant below is driven from WIRE_W, not from the decision.
+3 -4
View File
@@ -62,10 +62,9 @@ the step it wants: if checks.yml is restructured so that the step cannot be
found, this file goes RED rather than quietly grading nothing.
⚠️ NEEDS `git`, which is why .gitea/ci/Dockerfile installs it. No network: the
fixtures are local repositories built in a temp directory, and the workflow's
`git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:...@#")"`
is a no-op substitution on a path with no `://` in it, which is what lets the
shipped line be used verbatim.
fixtures are local repositories built in a temp directory; the workflow's
token header (TOOL-9) is ignored for a local path, which is what lets the
shipped lines be used verbatim.
⚠️ This file writes only under a temporary directory. It must stay that way:
CI mounts the checkout READ-ONLY.
+10 -7
View File
@@ -78,6 +78,8 @@ import re
import sys
from datetime import datetime, timedelta, timezone
from named_absence import given, nth, present
from app.clamp import (
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
clamp_to_rating,
@@ -322,15 +324,15 @@ _esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
print(f" budget spent by a benign episode, then a continuous runaway from "
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
f"(delay {_esc_delay:.0f}s)")
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={nth(_esc_fires, 0):.0f}s "
f"(delay {given(_esc_delay):.0f}s)")
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
f"spent the budget - it is not held for REASSERT_S ({given(_esc_delay):.0f}s "
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
check("...and it speaks at the escalation boundary rather than immediately, "
"which is what keeps the scattered flood suppressed",
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
_esc_delay is not None and _esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
@@ -523,7 +525,7 @@ check(f"a long winding episode does NOT flood the 40-row event log "
f"({len(rows_l)} rows for {ctl_l.clamp_wd.engagements} engagements)",
ctl_l.clamp_wd.engagements > 40 and len(rows_l) <= ctl_l.clamp_wd.episodes)
check("...and the count in the row is the counter's, so nothing is lost",
f"{ctl_l.clamp_wd.engagements} engagement(s)" in rows_l[-1])
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1))
# The RED row exists only while engaged, and the episode above is short, so it
# is captured mid-run rather than asserted after the hysteresis has cleared it.
check("the status page goes red WHILE it is engaged",
@@ -1093,7 +1095,8 @@ check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
f"{_asked_x:+.0f} W" in e for e in _rows_x)
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
f"{_asked_x:+.0f} W" not in notes_x[0]["message"])
f"{_asked_x:+.0f} W" not in nth(notes_x, 0)["message"]
and present(nth(notes_x, 0)))
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
# unit of coverage is the call site, and `bound` is the only thing that differs
# between the two rows an operator can receive.
@@ -1105,7 +1108,7 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the "
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
f"{_sent_w:+.0f} W" in notes_w[0]["message"])
f"{_sent_w:+.0f} W" in nth(notes_w, 0)["message"])
# ==============================================================================
+42 -77
View File
@@ -17,6 +17,7 @@ Read MUTATION.md before you report a survivor.
import sys
from named_absence import nth
from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_S, PLANT_SETTLE_S,
SETTLE_CYCLES, SLEW_RAIL_FRAC, Tuning, compute,
slew_limit_w, unasked_power,
@@ -59,68 +60,34 @@ def check(name, cond):
# stopped testing anything is precisely the failure this ticket exists to fix.
# A comment cannot go stale-green, because it never claims to be running.
#
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
# ⚠️ THE SAFETY-13 ROWS WERE RE-MEASURED IN ROUND 2 AND THREE OF THEM WERE
# WRONG AS FIRST WRITTEN. Every figure below marked (S13) was taken by deleting
# the mechanism, purging __pycache__, running this file in the CI image and
# counting `FAIL` lines, on THIS tree; the release/1.0 comparison figures were
# taken the same way on a pristine c8dcb38 worktree.
# Measured at TEST-26 on a purged tree in the CI image, each run graded to the
# end of the file (98/99 where the `if _bad:` check is skipped). Before TEST-26
# the rows marked * killed the file with an IndexError or ZeroDivisionError in
# a check, so their earlier figures were short; those checks now fail by name.
#
# - `output freeze` was written as 11. The mutation form its own note
# describes - the whole `target = min(...)/max(...)` line removed, `reason`
# left alone - gives 6 on this tree AND 6 on the first SAFETY-13 commit,
# against 2 on release/1.0. 11 is not reproducible by that form, or by
# removing the line and its `reason` (4), or by swapping min/max (12). The
# row is 6.
# - `saturation detector, saturated_now = False` was left at 11 while the
# SAFETY-13 section added checks that grade it. It is 15.
# - the footnote's `frozen = False` figure was left at 10 for the same
# reason. It is 13.
#
# The rows below `output freeze` are this ticket's own mechanisms: removing the
# EXIT restores release/1.0's unbounded freeze; making `unasked_power()` return
# True gives an exit to the limit case as well, which is the half of the
# decision that has to stay unbounded; deriving SETTLE_CYCLES from
# PLANT_SETTLE_S (18 s, the UP ramp) instead of PLANT_SETTLE_DOWN_S (23 s) is
# the round-1 defect itself; and pinning `_SAT_COUNT_CAP` back at release/1.0's
# literal 10 caps the counter below the bound and deletes the exit silently.
#
# ⚠️ ONLY THE ROWS THIS BRANCH MOVES WERE RE-MEASURED. Everything not marked
# (S13) still carries 389d9ec's figures and was not re-run here.
#
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to
# `return abs(float(tuning.slew_w))`, which leaves the limit intact and only
# removes the 0-means-the-rail resolution. Three checks notice, which is over
# the invariant's floor of two. Both figures were reproduced by deleting the
# mechanism, running this file and counting, on a tree with __pycache__ purged.
#
# mechanism in compute() checks that fail when deleted
# ------------------------------------------ -----------------------------
# integrator freeze (AC 3) 2
# integrator clamp (AC 1) 6
# integrator bound follows max_w 4
# output clamp 3
# slew limit 5
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
# output freeze (S13) 6
# the output freeze's EXIT (S13), freeze made unconditional again 4
# `unasked_power()` discriminates, replaced by `return True` (S13) 6
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
# `_SAT_COUNT_CAP` pinned back at release/1.0's 10 (S13) 6
# deadband 5
# quantisation 2
# saturation detector, `saturated_now = False` (S13) 15
# saturation duration (AC 2), fires instantly 2
# sat counter reset on a good cycle 6
# target_grid_w bias 3
# i_w=None seeding from prev_w 6
#
# The detector figure is for the `saturated_now = False` form specifically;
# disabling it further down as `frozen = False` is a weaker mutation and gives
# 13 on this tree (10 on release/1.0). Reproduce the same form or the number
# will not match.
# mechanism in compute(), and the exact form deleted checks that fail
# --------------------------------------------------------- ----------------
# integrator freeze: `if not frozen or i_w == 0.0` -> `if True` 6
# integrator clamp: `bounded = i_w` 6
# integrator bound follows max_w: default `limit = 5000.0` 3
# output clamp: `target = want` 3
# slew limit: `slewed = target` 17
# slew follows the rail (SAFETY-39): `slew_limit_w()` returns
# `abs(float(tuning.slew_w))` * 9
# output freeze: the `target = min(...)/max(...)` line removed 7
# output freeze: `if frozen:` -> `if False:` * 14
# the output freeze's EXIT (S13): its `if` -> `if False` * 11
# `unasked_power()` returns True (S13) 6
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
# `_SAT_COUNT_CAP = 10`, release/1.0's literal (S13) * 11
# deadband: its `if` -> `if False` 7
# quantisation: `target = target` 4
# saturation detector: `saturated_now = False` * 27
# saturation detector: `frozen = False` * 25
# saturation duration fires instantly: `sat_count >= 1` 5
# sat counter not reset on a good cycle: `else sat_count` 5
# target_grid_w bias: `error = grid_w` 5
# i_w=None seeding from prev_w: `i_w = 0.0` 11
#
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
# for the mechanism under test. Deleting the integrator freeze once failed
@@ -142,10 +109,9 @@ def check(name, cond):
# mutation run always has one, because the previous run wrote it. -B is still
# worth passing, for the NEXT edit rather than this one.
#
# The error is one-directional - stale bytecode can only under-report - so
# every figure above is a lower bound at worst, and the two zeros ever recorded
# were both confirmed by fixing them and watching the count rise, which a
# caching artefact cannot do.
# Two things under-report a figure here, both in the same direction: stale
# bytecode, and a check that raises and stops the file before the rest are
# graded (MUTATION.md, FCST-06). The second is what the * rows above had.
# ---------------------------------------------------------------------------
print("control law")
@@ -520,7 +486,7 @@ for _ in range(20):
rows.append((prev, d.target_w, d.i_w, d.reason))
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
pinned = [r for r in rows if r[3] == "saturated-freeze"]
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
check(f"non-zero prev_w, another writer: pinned at {nth(pinned, 0)[1]:.0f} W...",
pinned and all(r[1] == -800.0 for r in pinned))
# ⚠️ SAFETY-48 MOVED THIS NUMBER FROM -2390 W TO -1200 W, DELIBERATELY, AND
# THE PROPERTY IT IS HERE FOR IS UNCHANGED. The release lets the command walk
@@ -550,9 +516,9 @@ for c in range(24):
cmds.append(d.target_w)
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
check("the card's case: the block does hold at first, at 0 W",
cmds[6] == 0.0 and cmds[7] == 0.0)
nth(cmds, 6) == 0.0 and nth(cmds, 7) == 0.0)
started = [i for i, c in enumerate(cmds) if c != 0.0]
check(f"the card's case: the charge starts at cycle {started[0]}"
check(f"the card's case: the charge starts at cycle {nth(started, 0)}"
f" instead of never", bool(started))
# ⚠️ AND IT STOPS AT THE FROZEN INTEGRATOR, NOT AT THE RAIL. This is AC 4: the
# integrator moved while the output was blocked, and the release must not turn
@@ -991,7 +957,8 @@ check("the shipped Tuning default is 0, i.e. the rail, so the law carries no "
# serial, per goodwe/NOTES.md §1.1b. It is here because the ARITHMETIC is what
# is being graded, and the arithmetic does not care.
_rails = [100.0, 500.0, 1500.0, 2000.0, 5000.0, 9600.0, 10000.0]
_ticks = {r: r / slew_limit_w(Tuning(max_w=r)) for r in _rails}
# A zero limit grades as nan ticks (a named FAIL), not a ZeroDivisionError.
_ticks = {r: r / (slew_limit_w(Tuning(max_w=r)) or float("nan")) for r in _rails}
print(" ticks to cross the rail at the default, by rail: "
+ ", ".join(f"{r:.0f}W={t:.1f}" for r, t in _ticks.items()))
check("⭐ AT EVERY RAIL THE DEFAULT TAKES THE SAME NUMBER OF TICKS TO CROSS IT - "
@@ -1010,7 +977,7 @@ check("...and that is the same as saying the crossing takes the plant's own "
# refuses on, and it is asserted HERE too so that the law and the gate cannot
# drift apart - the rule can only ever refuse what this ratio says is too fast.
_broken = Tuning(max_w=5000.0, slew_w=1000.0)
_broken_s = 5000.0 / slew_limit_w(_broken) * LOOP_TICK_S
_broken_s = 5000.0 / (slew_limit_w(_broken) or float("nan")) * LOOP_TICK_S
print(f" the 2026-09-05 configuration (max_w 5000, slew_w 1000) crosses its "
f"rail in {_broken_s:.0f} s against a plant measured at {PLANT_SETTLE_S:.0f} s")
check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by "
@@ -1023,13 +990,11 @@ check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by
#
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
# `plantsim.Plant` is the battery model this project grades control changes
# against, and this repository's CI cannot reach it - that repository is private
# and this job's token is minted for this one (see .gitea/workflows/checks.yml).
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs
# HERE is a first-order lag with the time constant that reproduces §5.10's
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
# same reason and says so in the same words.
# against. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12), but this
# file does not import `plantsim`. The rig evidence lives in `tools/slew_sim.py`,
# is quoted in the pull request and in DECISIONS/SAFETY-39-slew-against-the-rail.md,
# and what runs HERE is a first-order lag with the time constant that reproduces
# §5.10's measured ~18 s to the deadband. test_alloc.py makes the same split.
#
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
+373
View File
@@ -0,0 +1,373 @@
"""Runnable check for SAFETY-61: the control task's death. `python3 test_control_task.py`
If `run_control()` raised, nothing noticed: the task ended, `stop` was never
set, the web UI kept serving and the Supervisor saw a healthy container. The
card's fix and its reasons are in DECISIONS/SAFETY-61-control-task-death.md.
Parts 1-3 drive the REAL `app.main.main()` - the process entry point - in a
child process (this file re-invoked with `--driver`), with only the outside
world faked: options, Home Assistant, the store, the web server and the core
API probe. The child's exit status is the real one. Each child runs under a
deadline, because the defect this card closes is a HANG (MUTATION.md habit 5).
Takes ~15 s. Purge `__pycache__` before a mutation run (MUTATION.md).
"""
import asyncio
import json
import os
import signal
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
FAULT = "injected SAFETY-61 fault"
DEADLINE_S = 40
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# --------------------------------------------------------------------------- #
# The child: the real main() with the outside world faked.
# --------------------------------------------------------------------------- #
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 0,
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 1,
"auto_start": True}
def driver(scenario):
import aiohttp
import app.main as M
import app.store
import app.web
def event(*e):
"""Streamed as it happens, so a child killed at the deadline or by
os._exit() still reports what it did."""
print("EV " + json.dumps(list(e)), flush=True)
t0 = time.monotonic()
class FakeHass:
def __init__(self):
self.batt = 0.0
self.limit_calls = 0
async def limits(self, entity):
self.limit_calls += 1
# "refuse": ESPHome not up at the startup gate, then a rating
# below max_w once run_control() re-reads it -> its refusal path.
if scenario == "refuse":
return (None, None, None) if self.limit_calls == 1 else (-1000.0, 1000.0, 1.0)
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
if entity == "sensor.grid":
# a steady 600 W import, alternating by 1 W so every pass cycles
return 600.0 + (int(time.monotonic() - t0) % 2)
if entity == "sensor.batt":
return self.batt
if entity == "sensor.soc":
return 50.0
return None
async def state(self, entity):
return None
async def set_number(self, entity, value):
event("w", float(value))
self.batt = float(value)
return True
async def call(self, *a, **k):
return True
async def mqtt_service(self):
raise RuntimeError("no broker in this test")
class _Store:
data = {}
def get(self, key, default=None):
return default
def set(self, *a, **k):
pass
class _Runner:
async def cleanup(self):
event("cleanup")
async def _web_start(controller, port=8099, selfcfg=None):
return _Runner()
class _NoProbeSession:
"""The core API probe in amain() must not touch a network."""
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def get(self, *a, **k):
raise OSError("no network in this test")
M.load_options = lambda: dict(BASE)
M.HomeAssistant = lambda session: FakeHass()
app.store.Store = _Store
app.web.start = _web_start
aiohttp.ClientSession = _NoProbeSession
real_cycle = M.Controller.cycle
n = {"cycles": 0}
async def cycle(self):
n["cycles"] += 1
if scenario == "fault" and n["cycles"] == 4:
event("fault")
raise RuntimeError(FAULT)
await real_cycle(self)
if scenario == "clean" and n["cycles"] == 4:
os.kill(os.getpid(), signal.SIGTERM) # the Supervisor's stop
M.Controller.cycle = cycle
M.main()
def run_child(scenario):
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
try:
p = subprocess.run([sys.executable, "-B", __file__, "--driver", scenario],
cwd=HERE, env=env, capture_output=True, text=True,
timeout=DEADLINE_S)
except subprocess.TimeoutExpired as e:
rc, out, err = None, e.stdout or "", e.stderr or ""
else:
rc, out, err = p.returncode, p.stdout, p.stderr
out = out.decode() if isinstance(out, bytes) else out
err = err.decode() if isinstance(err, bytes) else err
events = [json.loads(ln[3:]) for ln in out.splitlines() if ln.startswith("EV ")]
return rc, out, err, events
def tail(text, n=15):
return "\n".join(" | " + ln for ln in text.splitlines()[-n:])
if len(sys.argv) == 3 and sys.argv[1] == "--driver":
driver(sys.argv[2])
sys.exit(0) # unreachable in every scenario: main() exits itself
# --------------------------------------------------------------------------- #
print("1. an exception inside cycle() - the real main(), in a child process")
rc, out, err, ev = run_child("fault")
print(f" exit status {rc}; events {ev}")
check(f"the process ENDS inside {DEADLINE_S} s rather than sitting there "
"healthy (the defect is a hang)", rc is not None)
check("exit status is 70 (EX_SOFTWARE), not 0 and not an unhandled crash's 1",
rc == 70)
fault_at = ev.index(["fault"]) if ["fault"] in ev else None
before = [e[1] for e in ev[:fault_at] if e[0] == "w"] if fault_at is not None else []
after = [e[1] for e in ev[fault_at + 1:] if e[0] == "w"] if fault_at is not None else []
# Precondition (habit 23): the inverter was holding a NON-ZERO command when the
# fault hit, so a 0 W write afterwards is evidence of the wind-down, not rest.
check("precondition: the fault hit while the last command was non-zero "
f"(last before fault = {before[-1] if before else None})",
fault_at is not None and bool(before) and before[-1] != 0.0)
check(f"the wind-down ran: 0 W written AFTER the fault (writes after = {after})",
after == [0.0])
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
["cleanup"] in ev[(fault_at or 0):])
died_line = next((i for i, ln in enumerate(err.splitlines())
if "CONTROL LOOP DIED (RuntimeError('" + FAULT + "'))" in ln), None)
wind_line = next((i for i, ln in enumerate(err.splitlines())
if "shutting down - commanding 0 W" in ln), None)
check("a CRITICAL line names the exception and the exit status",
died_line is not None
and "CRITICAL" in err.splitlines()[died_line]
and "commanding 0 W and exiting with status 70" in err.splitlines()[died_line])
check("the traceback is logged, down to the frame that raised",
"Traceback (most recent call last)" in err
and f"RuntimeError: {FAULT}" in err and "in cycle" in err)
check("it is logged AT THE DEATH, before the wind-down - not only at exit",
died_line is not None and wind_line is not None and died_line < wind_line)
check("exactly one traceback: main() does not print a second one",
err.count("Traceback (most recent call last)") == 1)
if fails:
print(tail(err, 30))
# --------------------------------------------------------------------------- #
print("2. the same entry point stopped by SIGTERM - the callback must stay quiet")
rc2, out2, err2, ev2 = run_child("clean")
print(f" exit status {rc2}; events {ev2}")
check("an ordinary stop still exits 0", rc2 == 0)
check("an ordinary stop logs no CONTROL LOOP DIED", "CONTROL LOOP DIED" not in err2)
check("an ordinary stop still winds down to 0 W, from a non-zero command",
any(e[0] == "w" and e[1] != 0.0 for e in ev2)
and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]])
# --------------------------------------------------------------------------- #
print("3. run_control()'s own refusal path returns on purpose - still exit 78")
rc3, out3, err3, ev3 = run_child("refuse")
print(f" exit status {rc3}")
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
rc3 == 78)
check("and is not reported as a death", "CONTROL LOOP DIED" not in err3)
if rc3 != 78:
print(tail(err3))
# --------------------------------------------------------------------------- #
print("4. hass.py: a body that is not a state object is an ABSENCE")
import aiohttp # noqa: E402
from aiohttp import web as aioweb # noqa: E402
import app.hass as H # noqa: E402
async def hass_cases():
async def handler(req):
k = req.match_info["k"]
if k == "sensor.good":
return aioweb.json_response({"entity_id": k, "state": "42"})
if k == "sensor.badjson":
return aioweb.Response(text="{not json", content_type="application/json")
if k == "sensor.list":
return aioweb.Response(text="[1, 2]", content_type="application/json")
if k == "sensor.html":
return aioweb.Response(text="<html>", content_type="text/html")
return aioweb.Response(status=404)
async def svc(req):
return aioweb.Response(body=b"\xff\xfe\xfa", status=400,
headers={"Content-Type": "text/plain; charset=utf-8"})
app_ = aioweb.Application()
app_.router.add_get("/states/{k}", handler)
app_.router.add_post("/services/{d}/{s}", svc)
runner = aioweb.AppRunner(app_)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
H.CORE_API = f"http://127.0.0.1:{port}"
got = {}
try:
async with aiohttp.ClientSession() as session:
h = H.HomeAssistant(session, token="t")
for e in ("sensor.good", "sensor.badjson", "sensor.list", "sensor.html"):
try:
got[e] = ("ok", await h.number(e))
except Exception as err: # noqa: BLE001
got[e] = ("raised", type(err).__name__)
try:
got["call"] = ("ok", await h.set_number("number.sp", 0))
except Exception as err: # noqa: BLE001
got["call"] = ("raised", type(err).__name__)
finally:
await runner.cleanup()
return got
got = asyncio.run(hass_cases())
print(f" {got}")
check("baseline: a well-formed state reads as its value (42.0)",
got["sensor.good"] == ("ok", 42.0))
check("a malformed JSON body labelled application/json reads as None, not a raise",
got["sensor.badjson"] == ("ok", None))
check("a JSON body that is not an object reads as None, not a raise",
got["sensor.list"] == ("ok", None))
check("a text/html 200 reads as None (already true before SAFETY-61)",
got["sensor.html"] == ("ok", None))
check("a rejected write with an undecodable body is a failed write (False)",
got["call"] == ("ok", False))
# --------------------------------------------------------------------------- #
print("5. the control-loop status row")
from app.main import Controller # noqa: E402
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Store:
data = {}
def set(self, *a, **k):
pass
class _RowHass:
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
return {"sensor.grid": 600.0, "sensor.soc": 50.0}.get(entity, 0.0)
async def state(self, entity):
return None
async def set_number(self, entity, value):
return True
async def call(self, *a, **k):
return True
def rows_for(pass_age):
c = Controller(dict(BASE), _RowHass(), _Store(), _Pub(), stop=asyncio.Event())
c.loop_pass_mono = None if pass_age is None else time.monotonic() - pass_age
return c.checks()
r_new, r_29, r_31 = rows_for(None), rows_for(29), rows_for(31)
print(f" {r_new[0]} | {r_29[0]} | {r_31[0]}")
check("before the first pass the row is ok and says 'starting'",
r_new[0] == {"ok": True, "warn": False, "text": "control loop: starting"})
check("29 s after a pass it is ok",
r_29[0] == {"ok": True, "warn": False,
"text": "control loop: last pass 29 s ago"})
check("31 s after a pass it is RED and first, naming the age and the veto",
r_31[0] == {"ok": False, "warn": False,
"text": "control loop: NO PASS FOR 31 s (layer 1 zeroes the "
"inverter after 30 s without a heartbeat)"})
async def one_pass():
c = Controller(dict(BASE), _RowHass(), _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
before = getattr(c, "loop_pass_mono", "absent")
t = asyncio.create_task(c.run_control())
await asyncio.sleep(2.5)
c.stopping = True
await asyncio.wait_for(t, timeout=10)
return before, getattr(c, "loop_pass_mono", None)
_before, _after = asyncio.run(one_pass())
print(f" stamp before run: {_before}; age after 2.5 s: "
f"{None if _after is None else round(time.monotonic() - _after, 2)} s")
check("the real run_control() stamps each pass",
_before is None and _after is not None and time.monotonic() - _after < 3)
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all SAFETY-61 checks passed")
+42 -6
View File
@@ -31,6 +31,8 @@ import re
import sys
from datetime import datetime, timedelta, timezone
from named_absence import given, present
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
@@ -738,8 +740,8 @@ async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
_c_ship, _r_ship = asyncio.run(_at_saturation_w(_SAT_SHIPPED))
_c_up, _r_up = asyncio.run(_at_saturation_w(_SAT_RAISED))
_b_ship, _b_up = _c_ship.inverters[1], _c_up.inverters[1]
_gap_ship = short_by(_b_ship.standing_w(), _b_ship.batt)
_gap_up = short_by(_b_up.standing_w(), _b_up.batt)
_gap_ship = given(short_by(_b_ship.standing_w(), _b_ship.batt), "B not short")
_gap_up = given(short_by(_b_up.standing_w(), _b_up.batt), "B not short")
check(f"the raised threshold is a config an operator can actually enter: "
f"{_SAT_RAISED} W is inside the shipped schema "
@@ -762,7 +764,7 @@ check("the raised arm REACHED the comparison: B was asked for a discharge "
"share, is short of it, is live, is not parked, and NOTHING accounts for "
"it - the only thing standing between this unit and an alarm is the "
"configured watts",
float(_b_up.standing_w()) > 0 and _gap_up is not None
float(_b_up.standing_w()) > 0 and present(_gap_up)
and not _b_up.sign_wd.tripped
and account(live=True, parked=False, soc=_b_up.soc,
bms_floor=_b_up.bms_soc_floor) is None)
@@ -780,7 +782,7 @@ check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, "
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
"thresholds, which is the one place the two answers can differ. Part 4's "
"fixtures are all above both, which is exactly why they cannot see this",
_gap_up is not None and float(_SAT_SHIPPED) < _gap_up < float(_SAT_RAISED))
present(_gap_up) and float(_SAT_SHIPPED) < _gap_up < float(_SAT_RAISED))
check("AT THE SHIPPED THRESHOLD THE UNIT IS NAMED",
_r_ship[-1]["named"] == ("inverter-b",))
@@ -1193,8 +1195,8 @@ async def _restart():
c1 = _ctrl(h1, store=shared)
await _drive(c1)
# A NEW process, a NEW HomeAssistant object (so the in-memory notification
# is gone with it), the SAME durable file. That is what a Home Assistant
# restart is from this add-on's point of view: the add-on goes with it.
# is gone with it), the SAME durable file: an ADD-ON restart. A Core-only
# restart keeps the process; graded in part 8b (SAFETY-34).
h2 = _DeliveryHass(b_soc=50.0)
c2 = _ctrl(h2, store=shared)
await c2.check_not_delivering()
@@ -1248,6 +1250,40 @@ check("the operator's START press is the only way out, and it clears the "
and _rs.data.get(unit_key("inverter-b", "delivery_reason")) == ""
and _r2._delivery_announced == set())
print()
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
import app.main as _main_mod # noqa: E402
async def _core_restart():
shared = _MemStore()
c = _ctrl(_DeliveryHass(b_soc=50.0), store=shared)
await _drive(c)
return c
_c = asyncio.run(_core_restart())
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
try:
_c.hass = _DeliveryHass(b_soc=50.0)
asyncio.run(_c.check_not_delivering())
_early = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
_clock[0] += _main_mod.REANNOUNCE_S
asyncio.run(_c.check_not_delivering())
_late = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
finally:
_main_mod._mono = _real_mono
check("SAFETY-34: the latch tripped in this process",
_c.inverters[1].delivery_wd.tripped)
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
len(_early) == 0)
check("SAFETY-34: after REANNOUNCE_S the SAME process re-asserts the card, "
"because a Core-only restart wiped it and did not restart the add-on",
len(_late) == 1 and "inverter_b" in _late[0][2]["notification_id"])
print()
if fails:
+210
View File
@@ -0,0 +1,210 @@
"""Runnable check that the operator docs say what the code does. `python3 test_docs.py`
DOC-1. Two things the docs got wrong and an installer would have copied:
1. The `inverters:` example in DOCS.md must be ACCEPTED by the shipped
validator (`app/validate.py`) and carry every key config.yaml's schema
requires per entry. It omitted `bms_soc_floor` and would not start.
2. The P1 age entity the ESP32 watchdog subscribes to must be documented under
the id Home Assistant composes from what `app/mqtt.py` announces, and the
old wrong id must appear in no shipped doc.
Each part also runs its own mutation (habit 1 of MUTATION.md): the spoilt input
must turn it red, or the green above it means nothing.
"""
import os
import re
import sys
from app import mqtt
from app.validate import validate_startup_config
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def read(*parts):
with open(os.path.join(*parts), encoding="utf-8") as fh:
return fh.read()
DOCS = read(HERE, "DOCS.md")
CFG = read(HERE, "config.yaml")
# --------------------------------------------------------------------------- #
# A reader for the two YAML shapes used here. No PyYAML in the checks image
# (test_defaults_pin.py says why), so this is by hand, and part 1a checks it
# actually found something.
def scalar(raw):
raw = raw.split("#", 1)[0].strip()
if raw[:1] in "\"'" and raw[-1:] == raw[:1] and len(raw) >= 2:
return raw[1:-1]
if raw in ("true", "false"):
return raw == "true"
if raw == "[]":
return []
for cast in (int, float):
try:
return cast(raw)
except ValueError:
pass
return raw
def parse_example(text):
"""Top-level `key: value` lines plus one list of flat mappings."""
out, cur_list, cur = {}, None, None
for line in text.splitlines():
if not line.split("#", 1)[0].strip():
continue
m = re.match(r"^([a-z0-9_]+):\s*(.*)$", line)
if m:
if m.group(2).split("#", 1)[0].strip():
out[m.group(1)] = scalar(m.group(2))
cur_list = None
else:
cur_list = out[m.group(1)] = []
continue
m = re.match(r"^\s+-\s+([a-z0-9_]+):\s*(.*)$", line)
if m and cur_list is not None:
cur = {m.group(1): scalar(m.group(2))}
cur_list.append(cur)
continue
m = re.match(r"^\s+([a-z0-9_]+):\s*(.*)$", line)
if m and cur is not None:
cur[m.group(1)] = scalar(m.group(2))
continue
raise ValueError(f"unparsed line in the DOCS.md example: {line!r}")
return out
# --------------------------------------------------------------------------- #
print("DOC-1 - the docs say what the code does\n")
print("1. the `inverters:` example in DOCS.md starts")
section = DOCS[DOCS.index("### More than one inverter (`inverters`)"):]
section = section[:section.index("\n### ", 1)]
block = re.search(r"```yaml\n(.*?)```", section, re.S).group(1)
example = parse_example(block)
entries = example.get("inverters") or []
# The options a fresh install is given, from config.yaml itself: the example is
# pasted on top of these, exactly as an installer does.
opts_block = CFG[CFG.index("\noptions:"):CFG.index("\nschema:")]
shipped = {m.group(1): scalar(m.group(2))
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", opts_block, re.M)}
# config.yaml's per-entry schema: required keys are the ones without `?`.
schema_block = CFG[CFG.index("\nschema:"):]
inv_schema = re.search(r"^ inverters:\n((?: .*\n)+)", schema_block, re.M).group(1)
schema_keys = dict(re.findall(r"^ -? ?\s*([a-z0-9_]+): *(\S+)", inv_schema, re.M))
required = sorted(k for k, t in schema_keys.items() if not t.endswith("?"))
check(f"1a. the reader found the example: {len(entries)} entries, "
f"{len(shipped)} shipped options, schema keys {sorted(schema_keys)}",
len(entries) == 2 and all(len(e) >= 4 for e in entries)
and len(shipped) > 50 and "bms_soc_floor" in required)
missing = [(e.get("name"), k) for e in entries for k in required if k not in e]
check(f"1b. every entry carries every key the schema requires {required} "
f"(missing: {missing})", not missing)
unknown = [(e.get("name"), k) for e in entries for k in e if k not in schema_keys]
check(f"1c. no entry uses a key the schema does not declare (found: {unknown})",
not unknown)
def with_example(ex):
opts = dict(shipped)
opts.update(ex)
return opts
problems = validate_startup_config(with_example(example))
for p in problems:
print(f" refused: {p[:160]}")
check("1d. the shipped validator accepts the example as documented", problems == [])
# Precondition: the example clears the flat setpoint_entity, so without its
# roster there is no actuator. If this passed, 1d graded the flat keys, not the
# roster.
flat_only = {k: v for k, v in example.items() if k != "inverters"}
check("1e. ...and it is the ROSTER that 1d graded (the example without it is "
"refused)", validate_startup_config(with_example(flat_only)) != [])
# The mutation this card is about: the key the docs used to leave out.
spoilt = dict(example, inverters=[
{k: v for k, v in e.items() if k != "bms_soc_floor"} for e in entries])
sp = validate_startup_config(with_example(spoilt))
check("1f. MUTATION: the example with `bms_soc_floor` removed is refused, "
"and the refusal names it",
sp != [] and any("bms_soc_floor" in p for p in sp))
# --------------------------------------------------------------------------- #
print("\n2. the P1 age entity is documented under its real id")
def compose(device_name, entity_name):
"""HA's entity_id from device name + entity name (mqtt.py's note on
DEVICE). Written here, not borrowed from mqtt._slug, so a change to that
helper cannot move both sides of the comparison."""
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
return "sensor." + s.strip("_")
p1_rows = [r for r in mqtt.SENSORS if r[0] == "p1_age"]
check("2a. mqtt.py announces exactly one `p1_age` sensor", len(p1_rows) == 1)
real_id = compose(mqtt.DEVICE["name"], p1_rows[0][2]) if p1_rows else ""
print(f" composed from mqtt.py: {real_id}")
# Anchor outside mqtt.py: the id recorded from the live install.
recorded = read(HERE, "tools", "rehearsal", "announced-reference.txt")
check("2b. ...and that composes to the id recorded from the live install "
"(tools/rehearsal/announced-reference.txt)",
re.search(rf"^{re.escape(real_id)}\s+goodwe_ctl_p1_age\b", recorded, re.M)
is not None)
m = re.search(r"^#### `(sensor\.[a-z0-9_]+)`\n\n(?:.*\n){0,6}?.*stale-input watchdog",
DOCS, re.M)
documented = m.group(1) if m else None
check(f"2c. DOCS.md's watchdog-entity section is headed with that id "
f"(found {documented!r})", documented == real_id)
# The wrong id, anywhere an installer reads. CHANGELOG.md and DECISIONS/ are
# history and keep what was said at the time.
SHIPPED_DOCS = [
os.path.join(HERE, "DOCS.md"), os.path.join(HERE, "config.yaml"),
os.path.join(REPO, "README.md"), os.path.join(REPO, "FIELD-GUIDE.md"),
os.path.join(REPO, "docs", "build_guide.py"),
os.path.join(REPO, "docs", "field-guide.html"),
]
WRONG = "p1_sample_age_s"
hits = []
for path in SHIPPED_DOCS:
for n, line in enumerate(read(path).splitlines(), 1):
if WRONG in line:
hits.append(f"{os.path.relpath(path, REPO)}:{n}")
check(f"2d. `{WRONG}` appears in no shipped doc (hits: {hits})", not hits)
# MUTATION: the old heading must fail 2c's comparison.
bad = DOCS.replace(f"#### `{real_id}`", f"#### `sensor.{WRONG}`")
mb = re.search(r"^#### `(sensor\.[a-z0-9_]+)`\n\n(?:.*\n){0,6}?.*stale-input watchdog",
bad, re.M)
check("2e. MUTATION: the old heading is found and does not match",
mb is not None and mb.group(1) != real_id)
print()
if fails:
print(f"FAILED: {len(fails)}")
sys.exit(1)
print("all ok")
+16 -5
View File
@@ -19,7 +19,8 @@ shipped `meter_freeze_s` itself:
a check that calls the predicate directly would not be testing the thing
that ships. Uses the rig's own `sim/hwsim.py --fault freeze` when the infra
repo is next to this one (or `$GOODWE_SIM`), and an equivalent local stub
otherwise. Both are exercised; the run says which one it used.
otherwise. Both are exercised; the run says which one it used. CI sets
`GOODWE_REQUIRE_HWSIM=1`, which refuses the stub (CI-12).
4. THE WIRING: the real `Controller.cycle()`, asserted on the service call
that leaves the process;
@@ -600,6 +601,15 @@ hw = find_hwsim()
# that cannot fail, from the other end.
PART3_TRANSPORT = f"the rig hwsim at {hw}" if hw else "the local stub"
print(f"frozen meter over HTTP - using {PART3_TRANSPORT}")
# CI-12: CI sets GOODWE_REQUIRE_HWSIM=1, so a missing infra checkout fails here
# instead of passing on the stub. Local runs may still use the stub, and say so.
if not hw:
if os.environ.get("GOODWE_REQUIRE_HWSIM"):
print("FAIL: GOODWE_REQUIRE_HWSIM is set but sim/hwsim.py was not found "
"(sibling ../goodwe or $GOODWE_SIM) - refusing to fall back to the stub")
sys.exit(1)
print("STUB USED: part 3 ran against the local stub, NOT the infra repo's "
"sim/hwsim.py - not a CI-grade result")
if hw:
def argv_for(mode):
return [HWSIM_DRIVER, "8791" if mode == "freeze" else "8792", mode,
@@ -821,9 +831,9 @@ all_notes = all_notes_of(hass_f) # the whole surface
# install has. Flipped here rather than plumbing a whole P1 transport in - the
# row's own content is what is under test.
ctl_f.p1_enabled = True
rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
frozen_rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
f"{len(notes)} notification(s), {len(rows)} red status row(s)")
f"{len(notes)} notification(s), {len(frozen_rows)} red status row(s)")
# SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03
# ships `goodwe_output_clamp`, and this scenario - a runaway loop winding into
# the rail - raises it legitimately: the loop asks for more than the rating and
@@ -849,7 +859,7 @@ check("...with a fixed notification_id so repeats replace, not stack",
check("...and it says the controller is still running",
bool(notes) and "STILL RUNNING" in notes[0]["message"])
check("the status page carries a failed row saying so",
len(rows) == 1 and not rows[0]["ok"] and not rows[0]["warn"])
len(frozen_rows) == 1 and not frozen_rows[0]["ok"] and not frozen_rows[0]["warn"])
check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
# ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here
# and change this expectation deliberately, not discover it in the field.
@@ -1263,7 +1273,8 @@ check("a Controller built with the key absent lands on the SHIPPED hold, so "
_ctl_c.freeze_wd.cfg.hold_s == SHIPPED_HOLD_S)
print()
print(f"part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}")
# `CI: ` lines are echoed into the CI log even on a pass (checks.yml).
print(f"CI: test_freeze part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}")
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
+379 -32
View File
@@ -35,6 +35,12 @@ module does honour.
7. THE POLICY TABLE tells the truth about what is wired.
8. THE MEASUREMENT the bound rests on, replayed from the committed capture.
9. KNOWN SURVIVORS, graded so they are residuals rather than surprises.
10. SAFETY-57's EXIT from the verdict, against the committed live capture.
11. SAFETY-56: THE SENTENCE THIS GATE CAN HONESTLY SAY. The conjunction is
satisfied by two disjoint worlds - a register that has stopped reporting,
and a source that cannot comply - and nothing here separates them. Part
11 builds both, shows the verdict is byte-identical across them, and
grades the three operator-facing sentences against that ambiguity.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12; goodwe/NOTES.md §1.1b).
Every two-unit case below is two copies of inverter A's behaviour with
@@ -63,6 +69,7 @@ from datetime import datetime, timezone
from pathlib import Path
from app import freshness as F
from named_absence import first, given, nth, present
from app.circulation import circulating_w
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
from app.inverter import Inverter
@@ -247,11 +254,12 @@ check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s "
check("⚠️ STALE IS. The same forty readings, the same instants, the same "
"numbers - and the command moved 2500 W underneath them. The reading "
"becomes None to its consumers, which is the whole card",
_stale.stale and _stale_read[-1] is None)
_stale.stale and bool(_stale_read) and _stale_read[-1] is None)
check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged "
"arrivals, so the last fresh verdict is at t=34 and the first None at "
"t=35",
_stale_read[34] == A_FROZEN_W and _stale_read[35] is None
len(_stale_read) > 35
and _stale_read[34] == A_FROZEN_W and _stale_read[35] is None
and BATT_UNCHANGED_S == 35.0)
check("...and NEVER a held number and never a zero - `reading()` has exactly "
"two shapes, the arrived value or None",
@@ -443,14 +451,14 @@ _cu, _circu, _tripu, _standu, _ = run(_cycles(False))
_WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above
print(f" standing on A (gated): {[round(v) for v in _standg]}")
print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...")
print(f" gate closed after {_tgate:.2f} s of WALL clock, against the "
f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)")
print(f" gate closed after {given(_tgate):.2f} s of WALL clock, against the "
f"{_WD_S:.0f} s watchdog window ({_WD_S / given(_tgate):.1f}x margin)")
print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...")
check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN "
"ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog "
"on the real one, so this fixture only means something while the gate "
f"closes well inside the window: it closed at {_tgate:.2f} s against "
f"closes well inside the window: it closed at {given(_tgate):.2f} s against "
f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and "
"saying so here is what stops that being read as a defect",
_tgate is not None and _tgate < 0.5 * _WD_S)
@@ -661,13 +669,13 @@ print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends")
def option_default(key):
"""`key`'s default out of config.yaml's `options:` block, at two spaces."""
"""`key`'s default out of config.yaml's `options:` block, at two spaces - or ABSENT."""
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
body = body[body.index("\noptions:"):body.index("\nschema:")]
for line in body.splitlines():
if line.startswith(f" {key}:") and not line[2:3].isspace():
return float(line.split(":", 1)[1].strip())
return None
return given(None, f"{key} not in config.yaml options")
_circ_hold = option_default("circulation_hold_s")
@@ -916,11 +924,11 @@ for _day in _loop_days:
_prev8b = _f8b.stale
_t += 1.0
_waits.sort()
_p50 = _waits[len(_waits) // 2]
_p50 = nth(_waits, len(_waits) // 2)
print(f" {len(_loop_days)} days replayed at 1 Hz in "
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
print(f" wait for the register's next change after a trip: "
f"min {_waits[0]:.1f} s median {_p50:.1f} s max {_waits[-1]:.1f} s; "
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
f"710 s")
@@ -934,7 +942,7 @@ check("⚠️ THE OLD EXIT WAS NOT RARE AND IT WAS NOT SHORT. Ninety-three trips
"143.4 s and as long as 21440.1 s. Seventeen of the ninety-three are at "
"or beyond the 710 s the live site was parked for on 2026-09-21",
_trips8b == 93 and abs(_p50 - 143.4) < 0.1
and abs(_waits[-1] - 21440.1) < 0.1
and abs(nth(_waits, -1) - 21440.1) < 0.1
and sum(1 for w in _waits if w >= 710.0) == 17)
check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE - "
"which is the whole reason the exit could not stay as it was. The short "
@@ -1066,6 +1074,8 @@ def _decoder():
def hms(t):
if not present(t):
return repr(t)
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{int(t % 60):02d}"
@@ -1235,9 +1245,9 @@ _holds = [(_bt_moves[i + 1][0] - _bt_moves[i][0], _bt_moves[i][0],
_bt_moves[i + 1][0], _bt_moves[i][1])
for i in range(len(_bt_moves) - 1)]
_HOLD_S, _HOLD_FROM, _REG_MOVES, _HOLD_W = max(_holds)
_TRIP = next(t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM)
_PARK = next(t for t, v in _sp if t > _TRIP and v == 0.0)
_HUMAN = next(t for t, v in _sp if t > _PARK and v != 0.0)
_TRIP = first((t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM), "no trip")
_PARK = first((t for t, v in _sp if t > _TRIP and v == 0.0), "no 0 W after the trip")
_HUMAN = first((t for t, v in _sp if t > _PARK and v != 0.0), "no command after the park")
_blips = [t for t, s, _h, _sw, _r in _edges(_rows_after)
if s and t < _HOLD_FROM]
print(f" longest register hold {_HOLD_S:.1f} s at {_HOLD_W:+.0f} W, "
@@ -1253,8 +1263,8 @@ def _first_clear_after(rows, t_from):
return next((t for t, s, _h, _sw, _r in rows if t > t_from and not s), None)
_clear_before = _first_clear_after(_rows_before, _TRIP)
_clear_after = _first_clear_after(_rows_after, _TRIP)
_clear_before = given(_first_clear_after(_rows_before, _TRIP), "no clear")
_clear_after = given(_first_clear_after(_rows_after, _TRIP), "no clear")
print(f" BEFORE (release_s=inf): stale {hms(_TRIP)} -> clear "
f"{hms(_clear_before)} {_clear_before - _TRIP:.0f} s")
print(f" AFTER (release_s={BOUNDS['batt'].release_s:.0f}): stale "
@@ -1273,7 +1283,8 @@ check("PRECONDITION: the episode is in the capture where the manifest says it "
"several. 0x0518 answers -3 W for 850.0 s from 10:51:24, the gate arms "
"at 10:52:00, the site's own 0 W reaches the wire at 10:52:21, and "
"nothing else moves until a human does at 11:05:31",
abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0
present(_TRIP, _PARK, _HUMAN)
and abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0
and hms(_HOLD_FROM) == "10:51:24" and hms(_TRIP) == "10:52:00"
and hms(_PARK) == "10:52:21" and hms(_HUMAN) == "11:05:31"
and hms(_REG_MOVES) == "11:05:34"
@@ -1304,7 +1315,7 @@ check("⚠️ GREEN AFTER, AND THE REGISTER NEVER MOVES - THIS IS THE CARD. The
< _PARK + BOUNDS["batt"].release_s + 1.0
and hms(_clear_after) == "10:52:39" and _f_after.releases >= 1
and _D.held_at(_bt, _clear_after) == _D.held_at(_bt, _TRIP) == -3.0
and next(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0)
and first(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0)
check("...and the recovery is worth 776 s of this one episode: the site is "
"handed its telemetry back at 10:52:39 instead of at 11:05:35, and 772 "
"of those seconds are BEFORE the human touched anything. That is the "
@@ -1549,8 +1560,9 @@ async def _wired_release():
_c57, _seen57 = run(_wired_release())
_trip57 = next(t for t, b, _ok in _seen57 if b is None)
_back57 = next(t for t, b, _ok in _seen57 if t > _trip57 and b is not None)
_trip57 = first((t for t, b, _ok in _seen57 if b is None), "batt never None")
_back57 = first((t for t, b, _ok in _seen57 if t > _trip57 and b is not None),
"batt never back")
print(f" real read path: batt None from t={_trip57}, a number again at "
f"t={_back57}, register held {A_FROZEN_W:+.0f} throughout")
check("⚠️ THROUGH THE SHIPPED READ PATH, NOT THE POLICY OBJECT. "
@@ -1575,12 +1587,12 @@ check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATO
"the event log and the logger, in order, naming the unit",
len(_ev57) == 2 and "STALE inverter-a" in _ev57[1]
and "STALE WITHDRAWN inverter-a" in _ev57[0]
and len(_rel57) == 1 and "inverter-a" in _rel57[0])
and len(_rel57) == 1 and "inverter-a" in nth(_rel57, 0))
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
"happened: it says in its own words that nothing has been learned about "
"the register, and it prints the value the register is STILL holding",
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in _rel57[0]
and f"still holding {A_FROZEN_W:+.0f}" in _rel57[0])
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in nth(_rel57, 0)
and f"still holding {A_FROZEN_W:+.0f}" in nth(_rel57, 0))
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
@@ -1590,7 +1602,7 @@ check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has
# escape clause, and 250 W and 17.0 s again in the release note - so each is
# asserted with its value embedded in its own literal position.
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
print(f" escape clause: ...{_stale57[0][-150:]}")
print(f" escape clause: ...{nth(_stale57, 0)[-150:]}")
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
"PAIR. It used to end 'It clears itself the moment the register reports "
"a different value.' - which was FALSE, because the 0 W in the sentence "
@@ -1598,22 +1610,28 @@ check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDER
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
"red",
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in _stale57[0])
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
check("...and the release note names 250 W in the POWER slot and 17 s in the "
"TIME slot, in that order, which is the reverse arrangement of the same "
"two numbers",
f"sat below {BOUNDS['batt'].move_w:.0f} W for "
f"{BOUNDS['batt'].release_s:.0f} s" in _rel57[0])
f"{BOUNDS['batt'].release_s:.0f} s" in nth(_rel57, 0))
check("...and the 35.0 s the register is re-graded over is named in the note "
"too, because it is what the operator is being told to wait out",
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window"
in _rel57[0])
check("⚠️ AND THE DIAGNOSIS HALF OF THE STALE LINE IS UNTOUCHED, WHICH IS "
"SAFETY-56'S. That card is rewriting 'is not following its own command' "
"- the live episode's register was following perfectly and the pack was "
"simply empty - and two cards editing one sentence is how one of them "
"silently loses. This check is here so that rewrite is a deliberate act",
"battery-power register is not following its own command" in _stale57[0])
in nth(_rel57, 0))
check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
"WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power "
"register is not following its own command' to still be present, so "
"that removing it had to be a deliberate act rather than a side effect "
"of editing the escape clause beside it. SAFETY-56 is that act, and the "
"requirement is now the inverse: the accusation is GONE from this line "
"and part 11 grades what replaced it. The escape clause above is "
"SAFETY-57's and is untouched by it",
present(nth(_stale57, 0))
and "battery-power register is not following its own command"
not in _stale57[0]
and "did not move under a command that did" in _stale57[0])
check("...and a re-trip's reason names the TWO durations separately, each "
"beside its own value, because after a release the verdict's window and "
"the register's own unchanged time are different facts",
@@ -1630,6 +1648,335 @@ check("...and a re-trip's reason names the TWO durations separately, each "
Freshness(BOUNDS["batt"])))
# ==============================================================================
print()
print("11. SAFETY-56: the sentence this gate can honestly say")
# ⚠️ THE SUBJECT OF THIS PART IS A CLAIM, NOT A STRING, AND NOTHING IN IT
# CHANGES A DECISION. SAFETY-37's conjunction is correct and is untouched;
# what was wrong is what two sentences CONCLUDED from it. The conjunction is
# satisfied by two disjoint worlds -
#
# DEAD the register has stopped reporting reality. The pack really is
# doing what it was told; the published number does not say so.
# REFUSED the register is honest and the SOURCE cannot comply. The BMS is
# refusing at the pack floor, so the pack does nothing whatever the
# command asks, and an unchanged reading is the TRUTH.
#
# - and nothing this module is handed separates them. Three sentences named
# DEAD as fact: `freshness._grade()`'s "the reading is not following",
# `main.py`'s log line "battery-power register is not following its own
# command", and the NOT READY row's "<entity> is missing or not numeric". On
# the live 2026-09-21 episode all three were the wrong one of the two, and the
# third was the worst because it asserts a false fact about a NAMED entity: the
# operator opens Developer Tools, finds it working, and concludes the
# diagnostics are broken.
#
# So the property graded below is not "the new words are present". It is that
# the two worlds are REAL AND DIFFERENT (11a on the committed capture, 11b in a
# driven pair), that the gate CANNOT separate them (11b), and therefore that a
# clause asserting either one is false half the time - which is what makes 11c
# and 11d a claim rather than decoration. Habit 4: without 11a and 11b those
# two would be substring tests over a sentence this file also wrote.
# --- 11a. the old diagnosis was FALSE on the site it was printed on ----------
# ⚠️ OFF THE COMMITTED CAPTURE, NOT OFF A FIXTURE. Part 10a has already graded
# this file's bytes against the manifest and its decoder's sign convention
# against the frames, so the three series below are evidence rather than a
# story. Every boundary is derived in part 10b from the register's own longest
# hold; nothing here is typed.
_S56_CMDS_HELD = sorted({_D.held_at(_sp, t)
for t in range(int(_HOLD_FROM), int(_PARK) if present(_PARK) else 0)})
_S56_SOC_HELD = sorted({_D.held_at(_soc, t)
for t in range(int(_HOLD_FROM), int(_HUMAN) if present(_HUMAN) else 0)})
_S56_CHARGE = first(((t, v) for t, v in _sp if t > _PARK and v < 0.0), "no charge command")
_S56_CHARGE_T, _S56_CHARGE_W = _S56_CHARGE[0], _S56_CHARGE[1]
_S56_AFTER = _D.held_at(_bt, _REG_MOVES + 5.0)
print(f" during the 850 s hold: commands {_S56_CMDS_HELD} W, "
f"SoC {_S56_SOC_HELD} %, register {_HOLD_W:+.0f} W")
print(f" first CHARGE command {hms(_S56_CHARGE_T)} at "
f"{_S56_CHARGE_W:+.0f} W -> register moves {hms(_REG_MOVES)} "
f"({_REG_MOVES - _S56_CHARGE_T:.0f} s) to {_S56_AFTER:+.0f} W")
check("11a. ⚠️ THE OLD DIAGNOSIS WAS FALSE ON THE ONE SITE IT WAS EVER "
"PRINTED ON. Through the whole hold the site asked for DISCHARGE only "
"(+260 to +1060 W) out of a pack reading 11 %, and the register answered "
"-3 W. Then the first CHARGE command arrives and it moves within ONE "
"17.0 s obligation window. A register that answers a reversed command in "
"one window had not stopped reporting; what would not move was the PACK",
bool(_S56_CMDS_HELD) and all(v > 0.0 for v in _S56_CMDS_HELD)
and min(_S56_CMDS_HELD) == 260.0 and max(_S56_CMDS_HELD) == 1060.0
and _S56_SOC_HELD == [11.0] and _HOLD_W == -3.0
and _S56_CHARGE_W < 0.0
and 0.0 < _REG_MOVES - _S56_CHARGE_T <= F.BATT_OBLIGED_S
and _S56_AFTER != _HOLD_W)
check("...and the gate was RIGHT to fire anyway, which is why this card touches "
"no term of the conjunction. The three limits are still the derived ones "
"and the same capture still arms at 10:52:00 with the verdict standing: "
"a register genuinely stuck against a real command is exactly what "
"SAFETY-37 should catch, and a pack that cannot comply is a state the "
"site must stop in too. What was wrong was never the firing",
BOUNDS["batt"].unchanged_s == BATT_UNCHANGED_S
and BOUNDS["batt"].move_w == MOVE_W
and BOUNDS["batt"].release_s == F.BATT_OBLIGED_S
and hms(_TRIP) == "10:52:00" and _f_after.releases == 1
and first(r for r in _rows_after if r[0] == _TRIP)[1] is True)
# --- 11b. two worlds, one published stream, one verdict ----------------------
# ⚠️ GROUND TRUTH IS WRITTEN DOWN, NOT DERIVED FROM THE PUBLISHED STREAM
# (habit 2). A check that computes "what the pack was really doing" out of the
# number the register published is comparing a thing with itself, which is the
# defect MUTATION.md has recorded fifteen times.
_BATT_ENT = "sensor.batt_a"
_S56_N = 40
_S56_CMD = [800.0 if t < 1 else 1600.0 for t in range(_S56_N)]
_S56_PUB = [-3.0] * _S56_N # what the ENTITY publishes, both
_S56_TRUE_DEAD = list(_S56_CMD) # the pack obeyed; the register lied
_S56_TRUE_REFUSED = [-3.0] * _S56_N # the pack refused; the register told
async def _s56_world(soc_pct):
"""The shipped read path at N=1. The ground-truth stream above is never
handed to the controller - it is what the WORLD is, not an input."""
c, hass = one_site()
hass.numbers["sensor.soc_a"] = soc_pct
for t in range(_S56_N):
hass.numbers[_BATT_ENT] = _S56_PUB[t]
c.inverters[0].last_write = _S56_CMD[t]
await c.read_inputs(now_mono=float(t))
return c, hass
_S56_LOG0 = len(_LOGREC)
# ⚠️ THE TWO WORLDS ARE DRIVEN AT DIFFERENT SoC ON PURPOSE - 50 % against the
# 11 % the live pack sat at - so that "the sentence did not move" below is a
# measurement of this module's `soc` bound being STATED, NOT WIRED, and not an
# artefact of both runs being handed the same percentage. Wiring it is TEL-08's.
_c56_dead, _h56_dead = run(_s56_world(50.0))
_c56_ref, _h56_ref = run(_s56_world(11.0))
_pub_dead = [v for e, v in _h56_dead.served if e == _BATT_ENT]
_pub_ref = [v for e, v in _h56_ref.served if e == _BATT_ENT]
_S56_DIFFER = sum(1 for a, b in zip(_S56_TRUE_DEAD, _S56_TRUE_REFUSED) if a != b)
print(f" DEAD truth {_S56_TRUE_DEAD[:2]}... published {_pub_dead[:2]}...")
print(f" REFUSED truth {_S56_TRUE_REFUSED[:2]}... published {_pub_ref[:2]}...")
check(f"11b. PRECONDITION: the two worlds are REALLY TWO - their ground truth "
f"differs on {_S56_DIFFER} of {_S56_N} reads - and the PUBLISHED stream "
f"is bit-identical in both, so nothing downstream of {_BATT_ENT} can "
f"tell them apart. Without this the rest of part 11 is one world twice",
_S56_DIFFER == _S56_N and _pub_dead == _pub_ref == _S56_PUB
and len(_pub_dead) == _S56_N)
check("...and in REFUSED the register is RIGHT on every one of the 40 reads "
"(published == truth) while in DEAD it is wrong on all 40. That is the "
"fact the old sentence got backwards: it named the register in a world "
"where the register is the only thing telling the truth",
_S56_PUB == _S56_TRUE_REFUSED
and not any(p == t for p, t in zip(_S56_PUB, _S56_TRUE_DEAD)))
_r56_dead = _c56_dead.inverters[0].batt_fresh.reason
_r56_ref = _c56_ref.inverters[0].batt_fresh.reason
check("⚠️ AND THE GATE FIRES IDENTICALLY IN BOTH, WHICH IS THE WHOLE REASON "
"ITS SENTENCE MAY NOT NAME ONE OF THEM: same verdict, same MISSING "
"reading, same site stop - and the sentence is BYTE-IDENTICAL across two "
"worlds that share no ground truth at all",
_c56_dead.inverters[0].batt_fresh.stale is True
and _c56_ref.inverters[0].batt_fresh.stale is True
and _c56_dead.inverters[0].batt is None
and _c56_ref.inverters[0].batt is None
and _c56_dead.inputs_ok is False and _c56_ref.inputs_ok is False
and _r56_dead == _r56_ref and _r56_dead != "")
check("...and the two runs were driven at 50 % and 11 % SoC and the sentence "
"did not move one byte, because `BOUNDS['soc']` is STATED, NOT WIRED. A "
"message honest about its own ambiguity needs no new input, and adding "
"one here would make this sentence depend on a second measurement being "
"right - TEL-08's card, not this one",
_c56_dead.inverters[0].soc == 50.0 and _c56_ref.inverters[0].soc == 11.0
and _r56_dead == _r56_ref)
# --- 11c. both explanations, and no accusation, on every surface -------------
# The two explanations, each tagged with the world it is true OF. The emitted
# sentence is the same in both worlds (11b), so a sentence naming only one is
# false half the time - which is why BOTH are required, as an ORDERED PAIR
# (habit 14): two clauses in one sentence can always be transposed at zero
# bytes, and "the register may be unable to comply / the source may have
# stopped reporting reality" is a different and wrong claim.
_S56_EXPLANATIONS = [
("DEAD", "the register may have stopped reporting reality"),
("REFUSED", "the source may be unable to comply with the command"),
]
# ⚠️ WHAT THE THREE SENTENCES USED TO ASSERT. Each is a claim about the
# REGISTER or about the ENTITY, and 11a measured the register to be alive on
# the live episode while 11d measures the entity to be serving a float. They
# are listed with the file they came from so a reviewer can see the set is the
# whole set rather than the ones that were easy to find.
_S56_ACCUSATIONS = [
("app/freshness.py _grade()", "the reading is not following"),
("app/main.py _read_unit()", "register is not following its own command"),
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:] if "SAFETY-37" in r.getMessage()]
def _s56_row(c):
"""The battery-power readiness row, or a NAMED absence - never a raise.
⚠️ IT MUST NOT RAISE, AND THAT IS FCST-06'S RULE RATHER THAN TIDINESS. The
first draft of this part was `[... ][0]`, and the mutation that transposes
`label` and `entity` in the row - zero bytes - makes the row unfindable. The
run then died on an IndexError with every check after this line UNGRADED:
a red with no name attached, which reads like broken infrastructure and gets
retried rather than read. Measured: 0 named reds before, 2 after.
"""
hits = [r for r in c.checks() if r["text"].startswith("battery power")]
if len(hits) == 1:
return hits[0]
return {"ok": None, "warn": None,
"text": f"<NO SINGLE `battery power` ROW: {len(hits)} found>"}
_row56_ref = _s56_row(_c56_ref)
_S56_SURFACES = {
"reason (status page + action log)": _r56_ref,
"log line (SAFETY-37 ERROR)": _log56[-1] if _log56 else "",
"stale_note() (NOT READY row)": _c56_ref.inverters[0].stale_note(),
"checks() battery-power row": _row56_ref["text"],
}
for _n, _s in _S56_SURFACES.items():
print(f" {_n}: {_s[:96]}...")
check("PRECONDITION: the readiness row is FINDABLE BY ITS LABEL in both worlds "
"- exactly one `battery power` row each - so every row graded below is "
"that row, and a mutation that moves `label` out of the first slot is a "
"NAMED red here rather than an IndexError three checks later (FCST-06). "
f"REFUSED: {_row56_ref['text'][:44]!r}",
_row56_ref["ok"] is not None)
check("PRECONDITION: all four operator-facing surfaces were really produced by "
"this run and are non-empty, so the two quantifiers below are not over "
"an empty set - and the log line is the one the SHIPPED read path "
"emitted, not one this file formatted. Both worlds emitted one and the "
"two are the same string, which is 11b again on the log surface",
len(_log56) == 2 and _log56[0] == _log56[1]
and all(len(s) > 30 for s in _S56_SURFACES.values())
and _r56_ref in _log56[-1])
_idx56 = [_r56_ref.find(t) for _w, t in _S56_EXPLANATIONS]
check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
f"{_S56_EXPLANATIONS[0][1]!r} at {_idx56[0]} then "
f"{_S56_EXPLANATIONS[1][1]!r} at {_idx56[1]}. Pinned as an ordered pair "
f"because transposing 'register' and 'source' between the two clauses is "
f"a zero-byte edit that leaves both words in the sentence (habit 14)",
all(i >= 0 for i in _idx56) and _idx56 == sorted(_idx56)
and len(set(_idx56)) == len(_idx56))
check("...and the sentence says in its own words that it CANNOT choose, rather "
"than leaving two explanations side by side for a reader to rank - an "
"operator who reads a list picks the first item",
"CANNOT TELL THEM APART" in _r56_ref)
# ⚠️ THE POSITIVE CONTROL FOR THE `not any(...)` BELOW, AND IT IS NOT OPTIONAL:
# a quantifier over an empty list is True, and so is one over three literals
# with a typo in each. These are the three sentences as 0.10.1 SHIPPED them,
# quoted from the release rather than rebuilt, and the same predicate is
# required to FIND an accusation in every one of them (habit 7: vary one thing
# from an ACCEPTED baseline).
_S56_SHIPPED_010_1 = {
"reason": "STALE_READING: -3 unchanged for 35 s while the standing command "
"moved 800 W to +1600 W - the reading is not following, so it is "
"reported as MISSING rather than as a measurement (limits: "
"unchanged over 35 s, command over 250 W and moved by over 250 W)",
"log": "SAFETY-37: inverter-a battery-power register is not following its "
"own command - STALE_READING: ... Treating it as MISSING, which "
"commands 0 W.",
"row": f"battery power: {_BATT_ENT} is missing or not numeric",
}
check(f"POSITIVE CONTROL: the very same predicate, run against the three "
f"sentences as 0.10.1 SHIPPED them, finds an accusation in EVERY one - "
f"and every one of the {len(_S56_ACCUSATIONS)} literals matches at least "
f"one of them, so neither an empty list nor a mistyped literal can make "
f"the check below pass by vacuity",
len(_S56_ACCUSATIONS) == 3 and len(_S56_EXPLANATIONS) == 2
and all(any(t in old for _w, t in _S56_ACCUSATIONS)
for old in _S56_SHIPPED_010_1.values())
and all(any(t in old for old in _S56_SHIPPED_010_1.values())
for _w, t in _S56_ACCUSATIONS))
check("⚠️ AND NOT ONE OF THE THREE ACCUSATIONS SURVIVES, ON ANY OF THE FOUR "
"SURFACES: " + "; ".join(f"{w} -> {t!r}" for w, t in _S56_ACCUSATIONS)
+ ". This is the check that goes red if any of the three sentences is "
"reverted, and it is stated over the SURFACES rather than over the "
"source files because two of the three are assembled from two modules",
not any(t in s for _w, t in _S56_ACCUSATIONS
for s in _S56_SURFACES.values()))
# --- 11d. the NOT READY row that named a working entity ----------------------
async def _s56_absent():
"""The other half of the row's ambiguity: the entity really is gone."""
c, hass = one_site()
for t in range(3):
hass.numbers[_BATT_ENT] = None # Home Assistant has no state
c.inverters[0].last_write = 0.0
await c.read_inputs(now_mono=float(t))
return c, hass
_c56_abs, _h56_abs = run(_s56_absent())
_last_ref = [v for e, v in _h56_ref.served if e == _BATT_ENT][-1]
_last_abs = [v for e, v in _h56_abs.served if e == _BATT_ENT][-1]
_row56_abs = _s56_row(_c56_abs)
print(f" WITHHELD: {_BATT_ENT} served {_last_ref!r}, stale="
f"{_c56_ref.inverters[0].batt_fresh.stale} ABSENT: served "
f"{_last_abs!r}, stale={_c56_abs.inverters[0].batt_fresh.stale}")
check("11d. PRECONDITION: the row's two causes are both REACHED. In REFUSED "
f"{_BATT_ENT} served a real float on the last read and the gate is "
f"holding the value back; in ABSENT Home Assistant served None and the "
f"gate is not armed at all. Two different causes, one `inv.batt is None`",
_last_ref == -3.0 and _last_abs is None
and _c56_ref.inverters[0].batt_fresh.stale is True
and _c56_abs.inverters[0].batt_fresh.stale is False
and _c56_ref.inverters[0].batt is None
and _c56_abs.inverters[0].batt is None)
check("⚠️ AND THE VERDICT IS UNCHANGED BY THIS CARD - both rows are still "
"ok=False, warn=False, i.e. still NOT READY. The defect was never the "
"pass/fail, it was the row asserting a fact about a named entity that "
"was at that moment present, numeric and updating",
_row56_ref["ok"] is False and _row56_ref["warn"] is False
and _row56_abs["ok"] is False and _row56_abs["warn"] is False
and _row56_ref["text"] == _row56_abs["text"])
check("...and what it says instead is the observation plus BOTH causes, with "
f"{_BATT_ENT} in its own slot - `label` and `entity` are two "
f"interpolations in one sentence and swapping them costs zero bytes, so "
f"the literal around each is pinned (habit 14)",
f"battery power: no usable value this cycle from {_BATT_ENT} - and this "
f"row cannot tell which of two things that is" in _row56_ref["text"]
and "the entity is missing or not numeric" in _row56_ref["text"]
and "a number that this controller is withholding" in _row56_ref["text"])
check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which is "
"why the row does not need to ask `batt_fresh.stale` itself: `checks()` "
"carries a STALE_READING row in REFUSED and NONE in ABSENT, so 'is that "
"row below?' answers the question this one declines to answer. A row "
"that pointed at a surface present in both states would be worse than "
"the accusation it replaced",
any("STALE_READING" in r["text"] for r in _c56_ref.checks())
and not any("STALE_READING" in r["text"] for r in _c56_abs.checks()))
check("...and the row's wording is NOT shared with the log line's, which is a "
"design choice rather than an omission: the two sentences have no clause "
"in common longer than the word 'entity', because one explains a "
"BEHAVIOUR to a log reader and the other tells a page reader whether to "
"trust a NAME. A shared helper would have to drop whichever half the "
"other caller does not need",
_S56_EXPLANATIONS[0][1] not in _row56_ref["text"]
and _S56_EXPLANATIONS[1][1] not in _row56_ref["text"]
and "no usable value this cycle" not in _r56_ref)
# --- 11e. what part 11 does NOT grade, with its shape (habit 13) -------------
# * It does not show the live episode was a BMS refusal beyond doubt. 11a
# measures that the register answered a reversed command inside one
# obligation window, which excludes a register that had simply stopped; it
# does not exclude one that was intermittently wrong. That is the point: the
# sentence no longer claims to know either.
# * It does not grade ENGLISH. A wording that named both explanations and then
# said something useless about them would pass 11c.
# * The `no entity configured` branch beside the rewritten row is untouched and
# ungraded here - it has no second explanation, because a blank entity really
# is the only thing it can be.
# * The grid-power and battery-SoC rows go through the same rewritten line and
# have no freshness gate today, so for them the second cause is a hypothesis
# about this controller rather than a live possibility. That is weaker than
# the battery-power case and is the price of not branching per row; a gate on
# either of those signals (TEL-08 would be one) makes it exact.
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+6 -3
View File
@@ -73,6 +73,8 @@ import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from named_absence import first, given, nth, present
from app import main as app_main
from app.inverter import LINK_STALE_S, UNIT_A, Inverter, build_roster
from app.main import WRITE_BUDGET_S, Controller
@@ -993,8 +995,9 @@ _compute_calls = [n for n in ast.walk(MAIN_AST)
and n.func.id == "compute"]
check("main.py has exactly ONE `compute(` call site, so the argument graded "
"below is unambiguously the shipped one", len(_compute_calls) == 1)
_actual_src = ast.unparse(
next(k.value for k in _compute_calls[0].keywords if k.arg == "actual_w"))
_actual_kw = first((k.value for k in nth(_compute_calls, 0).keywords if k.arg == "actual_w"),
"no actual_w= keyword")
_actual_src = ast.unparse(_actual_kw) if present(_actual_kw) else repr(_actual_kw)
_site_batt_assign = [ast.unparse(n.value) for n in ast.walk(CTRL)
if isinstance(n, ast.Assign) and len(n.targets) == 1
and isinstance(n.targets[0], ast.Name)
@@ -1073,7 +1076,7 @@ print("6. AC 6: independent heartbeats, with the hang INJECTED")
#
# Two things fix it and both are graded: the writes go out CONCURRENTLY, and
# each one is bounded by `WRITE_BUDGET_S`.
_HB_MAX = read_schema_max("heartbeat_s")
_HB_MAX = given(read_schema_max("heartbeat_s"), "heartbeat_s bound")
print(f" WRITE_BUDGET_S {WRITE_BUDGET_S:.0f} s + the schema's largest "
f"heartbeat_s {_HB_MAX:.0f} s = {WRITE_BUDGET_S + _HB_MAX:.0f} s, against "
f"layer 1's {FIRMWARE_INGEST_VETO_S:.0f} s veto")
+60 -3
View File
@@ -203,15 +203,21 @@ class FakeClient:
self.msgs.append((topic, payload, retain))
def announce(units=(), omit=(), module=mqtt):
def announce(units=(), omit=(), module=mqtt, include=()):
"""The messages `_announce()` publishes, through the SHIPPED method.
⚠️ Not `discovery_payloads()` directly. Part 2 proves the two agree; every
other part goes through this one, so what is diffed is what paho is told.
`include` is TEL-11's optional-row set and DEFAULTS TO EMPTY here for the
same reason it does in the shipped constructor: every check in this file
written before that key existed must go on grading the announcement an
install that never set `pv_entity` receives, which is all of them.
"""
pub = module.MqttPublisher.__new__(module.MqttPublisher)
pub.omit = set(omit)
pub.units = [str(u) for u in units]
pub.include = [str(k) for k in include]
pub.enabled = True
pub.client = FakeClient()
pub._announce()
@@ -316,6 +322,52 @@ check("...and the `omit` path is unchanged too: with P1 ingestion off the "
"watchdog input is still not announced, and nothing else moved",
(m, c, a) == ([], [], []))
# --- TEL-11 ------------------------------------------------------------------
# ⚠️ THE OPTIONAL ROW'S DEFAULT IS THE CLAIM, AND IT IS THE OPPOSITE DEFAULT
# FROM `omit`'s. `p1_age` is announced unless withdrawn; `pv` does not exist
# unless asked for. Every check above this line passes `include` nothing, so
# they are all grading the announcement an install that never set `pv_entity`
# receives - which is MOST installs, and is the acceptance criterion "unset
# behaves exactly as today".
check("⭐ TEL-11 ADDS NOTHING AT THE SHIPPED DEFAULT: a publisher that is told "
"nothing about `pv_entity` announces the same list, in the same order, "
"with the same bytes - so an install that never sets the option sees no "
"new entity, no new topic and no re-announcement of an old one",
announce(units=roster_names({})) == SITE_NOW)
_PV_TOPIC = "homeassistant/sensor/goodwe_ctl_pv/config"
_PV_PAYLOAD = ('{"name": "PV power", "object_id": "goodwe_pv_power", '
'"unique_id": "goodwe_ctl_pv", "state_topic": "goodwe_ctl/pv", '
'"availability_topic": "goodwe_ctl/availability", "device": '
+ _DEV + ', "unit_of_measurement": "W", "device_class": '
'"power", "state_class": "measurement"}')
_pv_one = announce(units=roster_names({}), include=("pv",))
_m2, _c2, _a2 = diff(_pv_one, SITE_NOW)
check("⭐ AND WITH `pv_entity` SET IT IS EXACTLY ONE NEW TOPIC AND NOTHING "
"ELSE MOVES: same eleven configs first, same bytes, same order - the "
"additions this card makes are additions, not a re-composition",
(_m2, _c2) == ([], []) and _a2 == [_PV_TOPIC]
and _pv_one[:len(SITE_NOW) - 1] == SITE_NOW[:-1])
check("...and the payload is the site device, in watts, with "
"`state_class: measurement` - which is what puts it in the recorder's "
"statistics, and a week of statistics is the entire point of the card",
(_PV_TOPIC, _PV_PAYLOAD, True) in _pv_one)
# ⚠️ AND IT GOES LAST, WHICH IS WHY A ROSTER OF TWO IS ASKED THE SAME QUESTION.
# Placed among the site rows it would shift every per-unit entity's position in
# the retained announcement - a zero-byte edit that re-announces inverter B.
_pv_two = announce(units=NAMES2, include=("pv",))
check("...and at a roster of two it is STILL the last config, after every "
"per-unit row, so no existing entity's position in the retained "
"announcement moves when the option is turned on",
[t for t, _p, _r in _pv_two if t.startswith("homeassistant/")][-1]
== _PV_TOPIC
and _pv_two == announce(units=NAMES2)[:-1] + [
(_PV_TOPIC, _PV_PAYLOAD, True)] + announce(units=NAMES2)[-1:])
check("...and `omit` still wins over `include`, so the two arguments cannot "
"disagree about one key",
[t for t, _p, _r in announce(units=roster_names({}), include=("pv",),
omit=("pv",))
if t == _PV_TOPIC] == [])
# ---------------------------------------------------------------------------
print("\n2. what is diffed is what goes on the wire")
# ---------------------------------------------------------------------------
@@ -664,8 +716,13 @@ print("\n8. verified by breaking it")
# ---------------------------------------------------------------------------
# ⚠️ MUTATED SOURCE, EXEC'D INTO A FRESH NAMESPACE. No file is edited and no
# `.pyc` is consulted, so TEST-15's stale-bytecode failure cannot produce a
# false survivor here. `app/mqtt.py` imports nothing from this package, which is
# what makes exec'ing it standalone honest rather than a stub.
# false survivor here. `app/mqtt.py` imports nothing from this package AT MODULE
# SCOPE, which is what makes exec'ing it standalone honest rather than a stub.
# ⚠️ TEL-12 IS WHY THAT NOW SAYS "AT MODULE SCOPE". `optional_keys()` reads
# `pv.entities()` - one spelling of the roster predicate for the announcement and
# for the SUM alike - and imports it INSIDE the function precisely so this
# harness keeps working; its own comment says so. Nothing graded below calls
# `optional_keys()`, so no mutant here is stubbed by that.
def load(pairs):
"""`app/mqtt.py` with `pairs` applied, executed into a fresh module.
+426
View File
@@ -0,0 +1,426 @@
"""Runnable check for TEST-26 - a missing value must be a NAMED red. `python3 test_named_absence.py`
Every test file here is one script. A check or print that RAISES on a missing
value (an empty list indexed, `next()` exhausted, `:.0f` on None) kills every
check after it: the mutation it should name shows up as one unnamed traceback,
and the checks below it are never graded (MUTATION.md, FCST-06).
1. `named_absence.py`, the shared non-raising accessors.
2. A scan of every `test_*.py` for the shapes that raise, which must find none.
3. What the scan cannot see, counted on every run.
"""
import ast
import sys
from collections import Counter
from pathlib import Path
from named_absence import field, first, given, nth, present
HERE = Path(__file__).resolve().parent
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# ==============================================================================
print("1. the accessors never raise, and an absence is never a pass")
# ==============================================================================
_a = nth([], 0)
_b = first(x for x in (1, 2) if x > 5)
_c = field({"a": 1}, "pv")
_d = given(None)
print(f" {_a!r} {_b!r} {_c!r} {_d!r}")
check("each accessor hands back the value when it is there",
nth([7, 8], -1) == 8 and first(iter([3])) == 3
and field({"pv": 0.0}, "pv") == 0.0 and given(0.0) == 0.0
and present(0.0, None, "", []))
check("...and a stand-in that says what was missing when it is not",
repr(_a) == "<ABSENT [0] of 0 item(s)>" and repr(_c) == "<ABSENT key 'pv'>"
and not present(_a) and not present(1, _b) and not present(_c, _d))
check("formatting, indexing, attributes and arithmetic do not raise",
f"{_a:.2f}|{_a[2]['x']:+.0f}|{_d.attr.get('k'):,}|{_d / 2 - 1:.1f}"
== "|".join(["<ABSENT [0] of 0 item(s)>"] * 2 + ["<ABSENT None>"] * 2))
check("an absence is never equal, unequal, ordered or truthy - so `==`, `!=` "
"and `<` grade it FAIL",
not (_a == 0) and not (_a != 0) and not (_a < 1) and not (_a >= 1)
and not bool(_a) and not (_a == _a))
# ==============================================================================
print()
print("2. no test file reads a possibly-missing value in a way that raises")
# ==============================================================================
# The scan flags, anywhere in a file:
# index `name[k]` (constant k) where `name` holds a FILTERED list - a
# comprehension with an `if`, `filter()`, or an accumulator started
# as `[]` - directly or as returned by a function in the same file;
# next `next(it)` without a default;
# format a numeric format spec on a name that may be None - assigned None,
# an `... else None`, a one-argument `.get()`, or tested `is None`.
# A site is guarded when an earlier operand of the same `and`/`or`, or the
# test of an enclosing `if`/`while`/conditional expression, names the value,
# or it sits in a `try`. A check's NAME is evaluated before its condition, so
# a guard in the condition does not guard the name.
_NUMFMT = set("fdeEgGn%xXbo")
_FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
_COMPS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
def _filtered(rhs, returns):
if isinstance(rhs, (ast.ListComp, ast.GeneratorExp)):
return any(g.ifs for g in rhs.generators)
if isinstance(rhs, ast.List):
return not rhs.elts
if isinstance(rhs, ast.Call) and isinstance(rhs.func, ast.Name):
f = rhs.func.id
if f == "filter":
return True
if f in ("list", "sorted", "tuple") and rhs.args:
return _filtered(rhs.args[0], returns)
return "listy" in returns.get((f, None), ())
return False
def _maybe_none(rhs, returns):
if isinstance(rhs, ast.Constant):
return rhs.value is None
if isinstance(rhs, ast.IfExp):
return _maybe_none(rhs.body, returns) or _maybe_none(rhs.orelse, returns)
if isinstance(rhs, ast.Call):
if isinstance(rhs.func, ast.Attribute) and rhs.func.attr == "get":
return len(rhs.args) == 1 or (len(rhs.args) == 2 and _maybe_none(rhs.args[1], returns))
if isinstance(rhs.func, ast.Name):
return "noney" in returns.get((rhs.func.id, None), ())
return False
class _Scope:
def __init__(self, node, parent):
self.node, self.parent = node, parent
self.bound, self.listy, self.noney = set(), set(), set()
def _dump(n):
return ast.dump(n)
def _mentions(expr, base):
"""`base` appears in `expr` other than as the thing being indexed, so
`x[0] == 1 and x[1]` does not count as guarding `x[1]`."""
b = _dump(base)
inner = {id(n.value) for n in ast.walk(expr) if isinstance(n, ast.Subscript)}
return any(_dump(x) == b and id(x) not in inner for x in ast.walk(expr))
def _exits_on(st, base):
"""`if not x: return` (or continue/break/raise) - x is guarded below it."""
return (isinstance(st, ast.If) and _mentions(st.test, base) and st.body
and isinstance(st.body[-1], (ast.Return, ast.Continue, ast.Break, ast.Raise)))
def scan(src, census=None):
"""[(line, kind, text)] for every unguarded raising read in `src`."""
tree = ast.parse(src)
parent = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)}
def scope_node(n):
p = parent.get(n)
while p is not None and not isinstance(p, _FUNCS + _COMPS + (ast.Module,)):
p = parent.get(p)
return p
scopes = {tree: _Scope(tree, None)}
for n in ast.walk(tree):
if isinstance(n, _FUNCS + _COMPS):
scopes[n] = None
for n in list(scopes):
if n is not tree:
scopes[n] = _Scope(n, None)
for n, s in scopes.items():
if n is not tree:
s.parent = scopes[scope_node(n)]
def targets(t):
if isinstance(t, ast.Name):
yield t
elif isinstance(t, (ast.Tuple, ast.List)):
for e in t.elts:
yield from targets(e)
elif isinstance(t, ast.Starred):
yield from targets(t.value)
for n in ast.walk(tree):
if isinstance(n, _FUNCS):
a = n.args
for arg in a.posonlyargs + a.args + a.kwonlyargs + [a.vararg, a.kwarg]:
if arg is not None:
scopes[n].bound.add(arg.arg)
elif isinstance(n, ast.comprehension):
for t in targets(n.target):
scopes[scope_node(n)].bound.add(t.id)
elif isinstance(n, (ast.For, ast.AsyncFor, ast.With, ast.AugAssign, ast.NamedExpr)):
ts = ([n.target] if not isinstance(n, ast.With)
else [i.optional_vars for i in n.items if i.optional_vars])
for t0 in ts:
for t in targets(t0):
scopes[scope_node(n)].bound.add(t.id)
# `x = <default> if x is None else x` makes x not-None: a scope that
# normalises x this way does not get x marked by its `is None` tests.
normalised = set()
for n in ast.walk(tree):
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.IfExp)
and isinstance(n.value.test, ast.Compare)):
for t in n.targets:
if isinstance(t, ast.Name) and any(isinstance(x, ast.Name) and x.id == t.id
for x in ast.walk(n.value.test)):
normalised.add((scope_node(n), t.id))
# Two passes, so a function's return shape is known before its callers.
returns = {}
for _ in range(2):
for n in ast.walk(tree):
if isinstance(n, (ast.Assign, ast.AnnAssign)) and n.value is not None:
s = scopes[scope_node(n)]
for t0 in (n.targets if isinstance(n, ast.Assign) else [n.target]):
if isinstance(t0, ast.Name):
s.bound.add(t0.id)
if _filtered(n.value, returns):
s.listy.add(t0.id)
if _maybe_none(n.value, returns):
s.noney.add(t0.id)
elif (isinstance(t0, ast.Tuple) and isinstance(n.value, ast.Tuple)
and len(t0.elts) == len(n.value.elts)):
for e, v in zip(t0.elts, n.value.elts):
for t in targets(e):
s.bound.add(t.id)
if _filtered(v, returns):
s.listy.add(t.id)
if _maybe_none(v, returns):
s.noney.add(t.id)
elif (isinstance(t0, ast.Tuple) and isinstance(n.value, ast.Call)
and isinstance(n.value.func, ast.Name)):
for i, e in enumerate(t0.elts):
for t in targets(e):
s.bound.add(t.id)
got = returns.get((n.value.func.id, i), ())
if "listy" in got:
s.listy.add(t.id)
if "noney" in got:
s.noney.add(t.id)
else:
for t in targets(t0):
s.bound.add(t.id)
elif (isinstance(n, ast.Compare)
and any(isinstance(o, (ast.Is, ast.IsNot)) for o in n.ops)
and any(isinstance(c, ast.Constant) and c.value is None
for c in [n.left] + n.comparators)):
for x in [n.left] + n.comparators:
if isinstance(x, ast.Name) and (scope_node(n), x.id) not in normalised:
s = scopes[scope_node(n)]
while s.parent is not None and x.id not in s.bound:
s = s.parent
s.noney.add(x.id)
for fn in tree.body:
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
s = scopes[fn]
for r in ast.walk(fn):
if not isinstance(r, ast.Return) or r.value is None or scope_node(r) is not fn:
continue
vals = r.value.elts if isinstance(r.value, ast.Tuple) else [r.value]
for i, v in enumerate(vals):
key = (fn.name, i if isinstance(r.value, ast.Tuple) else None)
tags = returns.setdefault(key, set())
if isinstance(v, ast.Name) and v.id in s.listy or _filtered(v, returns):
tags.add("listy")
if isinstance(v, ast.Name) and v.id in s.noney or _maybe_none(v, returns):
tags.add("noney")
def resolve(name_node, attr):
s = scopes[scope_node(name_node)]
while s is not None:
if name_node.id in s.bound or name_node.id in getattr(s, attr):
return name_node.id in getattr(s, attr)
s = s.parent
return False
def guarded(node, base):
ch, p = node, parent.get(node)
while p is not None:
if isinstance(p, ast.BoolOp) and ch in p.values:
if any(_mentions(v, base) for v in p.values[:p.values.index(ch)]):
return True
elif isinstance(p, (ast.IfExp, ast.If, ast.While)):
if ch is not p.test and _mentions(p.test, base):
return True
elif isinstance(p, (ast.ListComp, ast.GeneratorExp, ast.SetComp)):
if ch is p.elt and any(_mentions(i, base) for g in p.generators for i in g.ifs):
return True
elif isinstance(p, ast.Try) and ch in p.body:
return True
for field_ in ("body", "orelse"):
block = getattr(p, field_, None)
if (isinstance(block, list) and ch in block
and any(_exits_on(st, base) for st in block[:block.index(ch)])):
return True
if isinstance(p, _FUNCS):
return False
ch, p = p, parent.get(p)
return False
out = []
for n in ast.walk(tree):
if (isinstance(n, ast.Subscript) and isinstance(n.ctx, ast.Load)
and isinstance(n.value, ast.Name)):
k = n.slice
if isinstance(k, ast.UnaryOp) and isinstance(k.op, ast.USub):
k = k.operand
if (isinstance(k, ast.Constant) and type(k.value) is int
and resolve(n.value, "listy") and not guarded(n, n.value)):
out.append((n.lineno, "index", ast.unparse(n)))
elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == "next" and len(n.args) == 1 and not n.keywords):
if not guarded(n, n.args[0]):
out.append((n.lineno, "next", ast.unparse(n)[:60]))
elif (isinstance(n, ast.FormattedValue) and n.format_spec is not None
and isinstance(n.value, ast.Name)):
spec = "".join(v.value for v in n.format_spec.values
if isinstance(v, ast.Constant))
if (spec and (spec[-1] in _NUMFMT or "," in spec)
and resolve(n.value, "noney") and not guarded(n, n.value)):
out.append((n.lineno, "format", f"{{{n.value.id}:{spec}}}"))
if census is not None:
_count_blind(tree, resolve, census)
return sorted(set(out))
def _count_blind(tree, resolve, census):
"""Count, inside check() and print() arguments, the forms part 2 skips."""
for call in ast.walk(tree):
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
and call.func.id in ("check", "print")):
continue
for n in (x for a in call.args for x in ast.walk(a)):
if isinstance(n, ast.Subscript) and isinstance(n.ctx, ast.Load):
k = n.slice.operand if isinstance(n.slice, ast.UnaryOp) else n.slice
if not isinstance(k, ast.Constant):
continue
if isinstance(k.value, str):
census["key"] += 1
elif isinstance(n.value, ast.Name):
if not resolve(n.value, "listy"):
census["name-list"] += 1
elif isinstance(n.value, ast.Attribute):
census["attr-list"] += 1
else:
census["other-index"] += 1
elif isinstance(n, ast.FormattedValue) and n.format_spec is not None:
if not isinstance(n.value, ast.Name):
census["format-expr"] += 1
elif not resolve(n.value, "noney"):
census["format-name"] += 1
elif isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Div, ast.FloorDiv)):
census["division"] += 1
# The scan must bite before its silence means anything: one planted site of
# each kind, and the guarded spelling of each, which must NOT be flagged.
_PLANTED = '''
def run():
seen = []
t_gate = None
return seen, t_gate
rows = [r for r in range(3) if r > 9]
seen, t_gate = run()
waits, n = [], 0
check(f"first {rows[0]}", bool(rows))
print(waits[0])
check("pair", rows[0] == 1 and rows[1] == 2)
print(seen[-1])
print(f"{t_gate:.2f}")
print(next(r for r in rows))
'''
_GUARDED = '''
def run():
seen = []
t_gate = None
return seen, t_gate
rows = [r for r in range(3) if r > 9]
seen, t_gate = run()
check("first", bool(rows) and rows[0] == 1)
print(seen[-1] if seen else "-")
check("pair", len(rows) == 2 and rows[0] < rows[1])
print(f"{t_gate:.2f}" if t_gate is not None else "-")
print(next((r for r in rows), None))
def show(rows):
print(rows[0])
def head():
if not rows:
return None
return rows[0]
'''
_hit = scan(_PLANTED)
print(f" planted: {_hit}")
check("the scan flags a planted site of each kind - a filtered list indexed in "
"a check NAME whose condition guards it, an accumulator bound in a tuple "
"and one returned from a function, a maybe-None formatted, and next() "
"without a default",
[(k, t) for _l, k, t in _hit] == [
("index", "rows[0]"), ("index", "waits[0]"), ("index", "rows[0]"),
("index", "rows[1]"), ("index", "seen[-1]"),
("format", "{t_gate:.2f}"),
("next", "next((r for r in rows))")])
check("...and flags none of the guarded spellings - `and`, a conditional, "
"an early return, a default - nor a parameter that shadows a filtered "
"name", scan(_GUARDED) == [])
_SUITES = sorted(HERE.glob("test_*.py"))
_found = {p.name: scan(p.read_text(encoding="utf-8")) for p in _SUITES}
_all = [(f, *h) for f, hs in _found.items() for h in hs]
for f, line, kind, text in _all:
print(f" {f}:{line} {kind:6} {text}")
check(f"no unguarded raising read in any of the {len(_SUITES)} test files "
f"({len(_all)} found) - read it through named_absence.py and conjoin "
f"present() in the check", _all == [] and len(_SUITES) >= 30)
# ==============================================================================
print()
print("3. what part 2 cannot see, counted - these can still raise")
# ==============================================================================
# The scan knows a list is filtered, or a name may be None, only from its own
# file's syntax. Each form below can still raise and is NOT graded; the counts
# are of every such site in check() and print() arguments, largest first.
_BLIND = {
"key": "`x['k']` - a dict key the code under test may omit",
"name-list": "`x[k]` on a list the scan cannot see is filtered - returned "
"by another module or a method, or of a fixed length",
"format-name": "a numeric format on a name the scan has no reason to think "
"is None - e.g. set from another module's function",
"format-expr": "a numeric format on an attribute, subscript or call",
"attr-list": "`obj.items[k]` - an attribute list",
"other-index": "`x[k]` on a call, a slice or a nested subscript",
"division": "`/` or `//` by a value that can be 0",
}
_census = Counter()
for p in _SUITES:
scan(p.read_text(encoding="utf-8"), _census)
for k, why in sorted(_BLIND.items(), key=lambda kv: -_census[kv[0]]):
print(f" {_census[k]:5d} {why}")
print(" never counted: these forms outside check()/print() arguments; "
"attribute access, iteration, int() or range() on a None or an ABSENT; "
"a raise inside a fixture function or inside app/.")
check("every counted form is in the printed table", set(_census) <= set(_BLIND))
print()
if fails:
print(f"{len(fails)} CHECK(S) FAILED")
sys.exit(1)
print("all checks passed")
+2 -2
View File
@@ -1320,8 +1320,8 @@ check("homewizard_local is enabled here too",
row = [s for s in SENSORS if s[0] == "p1_age"]
check("the age sensor is declared exactly once", len(row) == 1)
check("its object_id pins entity_id to sensor.p1_sample_age_s",
row[0][1] == "p1_sample_age_s")
check("it is published in seconds", row[0][3] == "s")
len(row) == 1 and row[0][1] == "p1_sample_age_s")
check("it is published in seconds", len(row) == 1 and row[0][3] == "s")
class _RecordingClient:
+6 -5
View File
@@ -36,6 +36,7 @@ from math import inf
from pathlib import Path
from app import arbitrage, packshare
from named_absence import first, nth, present
from app.allocator import plan
from app.arbiter import (
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
@@ -347,12 +348,12 @@ check("...and the shipped behaviour really was the other one, so the line "
"above is not vacuous: an equal split of 1500 W is 750/750, and 750 W is "
"what a pack at its cut-out was being told to produce",
_r_now.shares == {A: 750, Bee: 750})
_plan_a = next(p for p in _r_opt.allocation.plans if p.unit == A)
_plan_a = first((p for p in _r_opt.allocation.plans if p.unit == A), "no plan for A")
print(f" {_plan_a.explain()}")
check("...and the RECORD says why, in the line an operator is already reading. "
"`UnitPlan.within_envelope` is asked rather than assumed, and SAFETY-28's "
"write gate refuses a record that contradicts itself",
_plan_a.within_envelope and _plan_a.share_w == 0
present(_plan_a) and _plan_a.within_envelope and _plan_a.share_w == 0
and _plan_a.hi == 0.0 and _r_opt.allocated)
check("a pack at 100 % is not allocated a CHARGE either, and the sibling takes "
"the whole of it - the same sentence in the other direction",
@@ -477,12 +478,12 @@ check("...and the two halves are separated from EACH OTHER as well: on a pack "
"grades the ENVELOPE. Case three - both packs with room, nothing closed - "
"is the one that can only be the WEIGHTS",
_env_only.shares == {A: 0, Bee: 1500}
and _broken[2][1] != _broken[2][2])
and len(_broken) > 2 and _broken[2][1] != _broken[2][2])
check("...and the third case is the one that isolates the WEIGHTS from the "
"ENVELOPE: both packs have room, no interval is closed, nothing is "
"refused - and the split still moves from 500/500 to 97/903 because the "
"energy headroom says so",
_broken[2][1] == {A: 97, Bee: 903} and _broken[2][2] == {A: 500, Bee: 500})
nth(_broken, 2)[1] == {A: 97, Bee: 903} and nth(_broken, 2)[2] == {A: 500, Bee: 500})
# ==============================================================================
@@ -1156,7 +1157,7 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
# reads the allocation's own `unmet_w`, is EDGE-TRIGGERED against the previous
# cycle's value, and logs. What it does NOT grade is the sentence it logs or
# that a real cycle reaches it; that is stated here rather than implied.
_cycle_src = next(
_cycle_src = first(
f for cls in ast.walk(ast.parse(
(Path(__file__).resolve().parent / "app" / "main.py")
.read_text(encoding="utf-8")))
+85 -70
View File
@@ -115,6 +115,12 @@ from math import inf, isinf
from pathlib import Path
from app import peakguard
from named_absence import first, given, nth, present
def _src(node):
"""`ast.unparse(node)`, or what was there instead - never a raise."""
return ast.unparse(node) if isinstance(node, ast.AST) else repr(node)
# ⚠️ THE DROP AND REFUSAL REASONS ARE IMPORTED, NOT TRANSCRIBED. A check that
# matched the English would stay green against an allocator that renamed the
# case out from under it, which is this repository's own named failure mode.
@@ -265,18 +271,18 @@ def _schema_bound(key: str, which: int):
# Everything numeric below is read out of the shipped file, not typed here.
CAP_W = read_option("peak_cap_w")
MAX_HOLD_S = read_option("meter_max_age_s")
FREEZE_S = read_option("meter_freeze_s")
CYCLE_S = read_option("cycle_floor_s")
CAP_W = given(read_option("peak_cap_w"), "peak_cap_w not in config.yaml")
MAX_HOLD_S = given(read_option("meter_max_age_s"), "meter_max_age_s not in config.yaml")
FREEZE_S = given(read_option("meter_freeze_s"), "meter_freeze_s not in config.yaml")
CYCLE_S = given(read_option("cycle_floor_s"), "cycle_floor_s not in config.yaml")
# The live install runs max_w 5000 (NOTES.md, freeze.py). That is also the schema
# ceiling and the GW5000S-BP's rating, which is why the two agree - so the
# device figure is taken from the schema rather than typed.
LIVE_MAX_W = read_schema_max("max_w")
LIVE_MAX_W = given(read_schema_max("max_w"), "max_w not in config.yaml")
# SAFETY-24's two options, out of the shipped file for the same reason.
SOC_FLOOR = read_option("peak_soc_floor")
BMS_FLOOR = read_option("bms_soc_floor")
MAINT_FLOOR = read_option("maintenance_soc_floor")
SOC_FLOOR = given(read_option("peak_soc_floor"), "peak_soc_floor not in config.yaml")
BMS_FLOOR = given(read_option("bms_soc_floor"), "bms_soc_floor not in config.yaml")
MAINT_FLOOR = given(read_option("maintenance_soc_floor"), "maintenance_soc_floor not in config.yaml")
# A state of charge that comfortably permits a forced discharge, DERIVED from
# the floor rather than typed: every part of this file that is not about
# SAFETY-24 has to grade SAFETY-07's behaviour at a SoC where the new floor is
@@ -291,8 +297,8 @@ print(f" read from config.yaml: peak_cap_w {CAP_W:.0f} W, "
print(f" ...and peak_soc_floor {SOC_FLOOR:.0f} %, bms_soc_floor "
f"{BMS_FLOOR:.0f} %, maintenance_soc_floor {MAINT_FLOOR:.0f} %")
check("every threshold this check grades against came out of config.yaml",
None not in (CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W,
SOC_FLOOR, BMS_FLOOR, MAINT_FLOOR))
present(CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W,
SOC_FLOOR, BMS_FLOOR, MAINT_FLOOR))
check("...and the SoC these checks use as 'plenty of battery' is comfortably "
"clear of the floor, so nothing outside part 9 is silently grading "
"SAFETY-24 instead of SAFETY-07", SOC_OK > SOC_FLOOR + 10.0)
@@ -437,7 +443,7 @@ print(f" five cycles with the inverter not following: delivered-power floor "
check("a floor sized from DELIVERED battery power is stationary while the "
"inverter fails to follow; one sized from the standing command winds up",
len(set(f"{v:.3f}" for v in delivered_floor)) == 1
and intent_floor[-1] > intent_floor[0] * 4)
and nth(intent_floor, -1) > nth(intent_floor, 0) * 4)
# ==============================================================================
@@ -796,7 +802,7 @@ def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None,
"""
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
store = fresh_store(f"{path.stem}-{'g' if guarded else 'u'}"
f"{'' if pack is None else f'-p{soc_floor:.0f}'}")
f"{'' if pack is None else f'-p{given(soc_floor):.0f}'}")
tracker = PeakTracker(store, cap_w=cap_w)
batt = 0.0 # delivered discharge, W (positive)
next_cycle = None
@@ -1049,7 +1055,7 @@ c_off.auto, c_off.soc, c_off.batt, c_off.grid = True, 55.0, 0.0, -6000.0
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c_off.cycle())
off_rows = [r for r in c_off.checks() if "peak guard" in r["text"]]
print(f" meter_source off: guard={c_off.peak_guard}, wrote "
f"{hass_off.writes[-1]:+.0f} W")
f"{nth(hass_off.writes, -1):+.0f} W")
print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)"))
check("with no P1 the guard does not run at all - it files nothing, and the "
"legacy path can still charge", c_off.peak_guard is None
@@ -1242,7 +1248,7 @@ check("...once per episode, not once per cycle",
hass2.notifications.count("goodwe_peak_saturated") == 1)
check("...and the command is still bounded by the rating, so a saturated guard "
"never asks the hardware for more than it has",
abs(hass2.writes[-1]) <= LIVE_MAX_W + 1e-6)
bool(hass2.writes) and abs(hass2.writes[-1]) <= LIVE_MAX_W + 1e-6)
# ==============================================================================
# THE WIRING'S OWN MUTATIONS. Everything above this line grades `peakguard.py`,
@@ -1439,7 +1445,7 @@ check("...and the consequence is the wind-up part 1 demonstrates, now on the "
"REAL controller's own projection: the intent-sized floor re-adds its own "
"unmet ask every cycle and reaches the rail SATURATED, while the "
"delivered-power floor does not move at all",
_i_sat_at is not None and _i_track[-1] > _i_track[0]
_i_sat_at is not None and len(_i_track) > 1 and _i_track[-1] > _i_track[0]
and len({f"{v:.3f}" for v in _d_track}) == 1
and not b3.peak_guard.saturated)
# Graded on the parsed call syntax too, so a rename cannot quietly reintroduce
@@ -1456,9 +1462,9 @@ _peak_calls = [n for n in ast.walk(ast.parse(_mainsrc))
and n.func.attr == "decide"
and isinstance(n.func.value, ast.Name)
and n.func.value.id == "peakguard"]
_call = _peak_calls[0]
_batt_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "batt_w"))
_soc_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc"))
_call = nth(_peak_calls, 0, "peakguard.decide() call in main.py")
_batt_src = _src(first(k.value for k in _call.keywords if k.arg == "batt_w"))
_soc_src = _src(first(k.value for k in _call.keywords if k.arg == "soc"))
print(f" main.py's decide(batt_w=...) is `{_batt_src}`, decide(soc=...) is "
f"`{_soc_src}`")
check("...and the call site says so in the source: the argument is the measured "
@@ -1497,8 +1503,8 @@ check(f"...and main.py has exactly ONE `peakguard.decide(` call site (it has "
# no fixture separated them - which is exactly the coupling the card forbids and
# exactly the kind of survivor a value-based check cannot see. This grades the
# NAME, and part 9 below now also separates them behaviourally.
_floor_src = ast.unparse(next(k.value for k in _call.keywords
if k.arg == "soc_floor")).replace("'", '"')
_floor_src = _src(first(k.value for k in _call.keywords
if k.arg == "soc_floor")).replace("'", '"')
print(f" main.py's decide(soc_floor=...) is `{_floor_src}`")
check("...and the floor comes from `peak_soc_floor`, defaulted from the ONE "
"mirror - not from `maintenance_soc_floor` (a drain target, not a spend "
@@ -1535,8 +1541,9 @@ check("SAFETY-24 adds exactly the two keys it names and no more: §14's kW/W "
# Graded on the parsed call syntax, not on the text: the module docstring names
# `peak_cap_w` while arguing that it does not read it, so a grep would be
# satisfied by deleting the explanation and would fail on writing one.
_decide = next(n for n in ast.walk(ast.parse(src))
if isinstance(n, ast.FunctionDef) and n.name == "decide")
_decide = first((n for n in ast.walk(ast.parse(src))
if isinstance(n, ast.FunctionDef) and n.name == "decide"),
"def decide in peakguard.py")
_DECIDE_ARGS = [a.arg for a in _decide.args.args + _decide.args.kwonlyargs]
_getters = [n for n in ast.walk(ast.parse(src))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
@@ -2010,7 +2017,8 @@ check("...while one point above is accepted, so this refuses the disarmed case "
"only and not 'close to the limit'", above == [])
check("...and the refusal NAMES BOTH NUMBERS and the shipped default, so the "
"operator can act on it without reading the source",
f"{BMS_FLOOR:.0f} %" in below[0]
len(below) == 1
and f"{BMS_FLOOR:.0f} %" in below[0]
and f"{BMS_FLOOR - 5:.0f} %" in below[0]
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in below[0])
check("...and a non-numeric floor is refused rather than defaulted, for the "
@@ -2245,7 +2253,7 @@ def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH):
# measuring something else.
EVE = evening(19, 24)
EVE_W = [w for _, w in EVE]
print(f" the evening of 2026-08-20, {EVE[0][0]:%H:%M}-{EVE[-1][0]:%H:%M} UTC: "
print(f" the evening of 2026-08-20, {nth(EVE, 0)[0]:%H:%M}-{nth(EVE, -1)[0]:%H:%M} UTC: "
f"{len(EVE)} telegrams, mean {sum(EVE_W) / len(EVE_W):.0f} W, "
f"min {min(EVE_W):.0f} W, max {max(EVE_W):.0f} W")
check("the window really is a SUSTAINED-LOAD evening and not a quiet one - "
@@ -2324,7 +2332,7 @@ check("...and the sweep spans the whole schema range, rule 8's refused low end "
HOT = evening(15, 17)
HOT_R = replay_loop(HOT, start_soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR)
print(f" the same day's high-load block "
f"{HOT[0][0]:%H:%M}-{HOT[-1][0]:%H:%M} UTC, pack started one point BELOW "
f"{nth(HOT, 0)[0]:%H:%M}-{nth(HOT, -1)[0]:%H:%M} UTC, pack started one point BELOW "
f"the floor:")
print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, "
f"{HOT_R['limited_discharging']} of them with the loop simultaneously "
@@ -2492,13 +2500,14 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
# The guard's own line, which the banner has always carried and which is not
# this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter
# that is going over the ceiling.
_expl25 = rows25[0]["text"].split(". NOTE:")[0]
_expl25 = nth(rows25, 0)["text"].split(". NOTE:")[0]
check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
"under 90 characters of it - and NOT the row's whole paragraph repeated "
"at the top of the page. A headline that is a paragraph is a headline "
"nobody reads, and this one is followed three lines later by the same "
"text verbatim",
_pay25["level"] == "bad"
present(_expl25)
and _pay25["level"] == "bad"
and _pay25["banner"].startswith("NOT READY")
and _expl25 in _pay25["banner"]
and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper()
@@ -2507,7 +2516,8 @@ check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
check("...and shortening the banner did NOT shorten the row: the full AC 3 "
"sentence is still what the checks list carries, which is the surface the "
"operator reads to find out what the floor does not bound",
len(rows25[0]["text"]) > 300
len(rows25) == 1
and len(rows25[0]["text"]) > 300
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
@@ -2627,12 +2637,13 @@ CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"}
_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
_cycle_def = next(
_cycle_def = first((
f
for cls in ast.walk(_main_tree)
if isinstance(cls, ast.ClassDef) and cls.name == "Controller"
for f in cls.body
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle")
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle"),
"Controller.cycle in main.py")
def claim_source(node):
@@ -2957,9 +2968,7 @@ def append_actuator(node):
dynamic check in this file is blind to the swap at N=1.
"""
kw = [k for k in node.keywords if k.arg == "actuator"]
if len(kw) != 1:
return None
return ast.unparse(kw[0].value)
return ast.unparse(kw[0].value) if len(kw) == 1 else None
# The appends that BUILD their claim in place - the nine whose routing is
@@ -3959,8 +3968,8 @@ check("...and with the key absent entirely the one pack takes the SHIPPED "
# the floor comes from is a claim genuinely about the code's shape and is pinned
# on the source in part 4, where it already lives; what is graded here is the
# VALUE that reaches `decide()`.
_floor_call = ast.unparse(next(k.value for k in _call.keywords
if k.arg == "soc_floor"))
_floor_call = _src(first(k.value for k in _call.keywords
if k.arg == "soc_floor"))
print(f" main.py's decide(soc_floor=...) is `{_floor_call}`")
@@ -4243,12 +4252,12 @@ check("...and the baseline the table is measured against is the shipped call "
# rewritten as `self.inverters[:2]` leaves this expression identical, and it is
# the three-pack runs' `len(ctl.packs) == 3` that goes red on it. And it cannot
# see the reduction INSIDE `binding_soc_floor()` at all; that is 12c2's.
_floor_node = next(k.value for k in _call.keywords if k.arg == "soc_floor")
_floor_node = first(k.value for k in _call.keywords if k.arg == "soc_floor")
_floor_roster_arg = (_floor_node.args[0]
if isinstance(_floor_node, ast.Call) and _floor_node.args
else None)
print(f" the ROSTER argument main.py reduces is "
f"`{ast.unparse(_floor_roster_arg)}`, an AST "
f"`{_src(_floor_roster_arg)}`, an AST "
f"{type(_floor_roster_arg).__name__}")
check("⚠️ THE STRUCTURAL HALF, AND IT IS EXACT RATHER THAN A SUBSTRING. The "
"roster handed to `binding_soc_floor()` is the WHOLE property "
@@ -4744,28 +4753,31 @@ check("⚠️ THE NONE GUARD IS THE SAME FAMILY. One silent pack at ANY position
# killed by the roster set), it cannot see `inv.soc` become `inv.bms_soc_floor`
# (killed by the runs' held-flat cut-outs), and it cannot see a change inside
# `Inverter.soc` at all.
_pfs_fn = next(n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "pack_floor_soc")
_pfs_vals = next(n.value for n in _pfs_fn.body
if isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == ["vals"])
_pfs_fn = first((n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "pack_floor_soc"), "def pack_floor_soc in main.py")
_pfs_vals = first((n.value for n in _pfs_fn.body
if isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == ["vals"]),
"`vals = ...` in pack_floor_soc")
def _gen_iter_of(fn_node, name):
"""The iterable of the comprehension inside the single `name(...)` call."""
call = next(n for n in ast.walk(fn_node)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == name)
comp = call.args[0]
return comp.generators[0].iter if len(comp.generators) == 1 else None
call = first((n for n in ast.walk(fn_node)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == name), f"{name}() call")
comp = nth(call.args, 0)
gens = getattr(comp, "generators", [])
return gens[0].iter if len(gens) == 1 else None
_pfs_min_iter = _gen_iter_of(_pfs_fn, "min")
_pfs_vals_iter = nth(getattr(_pfs_vals, "generators", []), 0).iter
_pfs_any_iter = _gen_iter_of(_pfs_fn, "any")
print(f" pack_floor_soc reduces: list over `{ast.unparse(_pfs_vals.generators[0].iter)}`"
f", any() over `{ast.unparse(_pfs_any_iter)}`, min() over "
f"`{ast.unparse(_pfs_min_iter)}`")
print(f" pack_floor_soc reduces: list over `{_src(_pfs_vals_iter)}`"
f", any() over `{_src(_pfs_any_iter)}`, min() over "
f"`{_src(_pfs_min_iter)}`")
check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduces "
"the WHOLE list `vals` - a bare Name, not a Subscript, not a call, not a "
"second comprehension - and `vals` is built by ONE comprehension over the "
@@ -4777,7 +4789,7 @@ check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduce
"to length 12. This grades the code's SHAPE and nothing about the values; "
"what the expression MEANS is graded by the runs",
isinstance(_pfs_min_iter, ast.Name) and _pfs_min_iter.id == "vals"
and ast.unparse(_pfs_min_iter) == "vals"
and _src(_pfs_min_iter) == "vals"
and isinstance(_pfs_vals, ast.ListComp)
and len(_pfs_vals.generators) == 1
and not _pfs_vals.generators[0].ifs
@@ -4785,13 +4797,13 @@ check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduce
and isinstance(_pfs_vals.generators[0].iter.value, ast.Name)
and _pfs_vals.generators[0].iter.value.id == "self"
and _pfs_vals.generators[0].iter.attr == "inverters"
and ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters")
and _src(_pfs_vals_iter) == "self.inverters")
check("...and the NONE GUARD reduces that same whole list, pinned the same way. "
"`any(v is None for v in vals[:1])` is +4 bytes and leaves every other "
"line in this file identical; the sweep above reaches it up to length 12 "
"and this reaches it at every length",
isinstance(_pfs_any_iter, ast.Name) and _pfs_any_iter.id == "vals"
and ast.unparse(_pfs_any_iter) == "vals")
and _src(_pfs_any_iter) == "vals")
check("...and the counterfactual that shows these pins are not vacuous (habit "
"7): the very slices they exist for really do unparse to something else, "
"so the equalities above are doing work rather than being trivially true "
@@ -4813,8 +4825,8 @@ _arb_calls = [n for n in ast.walk(ast.parse(_mainsrc))
and n.func.attr == "decide"
and isinstance(n.func.value, ast.Name)
and n.func.value.id == "arbitrage"]
_arb_soc = ast.unparse(next(k.value for k in _arb_calls[0].keywords
if k.arg == "soc"))
_arb_soc = _src(first(k.value for k in nth(_arb_calls, 0).keywords
if k.arg == "soc"))
print(f" main.py's arbitrage.decide(soc=...) is `{_arb_soc}` "
f"({len(_arb_calls)} call site)")
check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()` "
@@ -5688,8 +5700,8 @@ MUT.append(("`binding_soc_floor()` reduced over a WINDOW - `max(floors[-2:])` -
# cut-out at index 0, 1 and 2 reach it.
MUT.append(("main.py grading the site against a WINDOW of roster positions - "
"`self.packs[:2]` - ROUND 3 SURVIVOR 2, +4 bytes",
_g3_runs[2][3].soc_floor == float(_G3_DEEP_SIM)
and binding_soc_floor(_g3_runs[2][2].packs[:2], SOC_FLOOR)
nth(_g3_runs, 2)[3].soc_floor == float(_G3_DEEP_SIM)
and binding_soc_floor(nth(_g3_runs, 2)[2].packs[:2], SOC_FLOOR)
== float(SOC_FLOOR)
and all(r[3].soc_floor == float(_G3_DEEP_SIM) for r in _g3_runs)
and all(_slice_kills.values()),
@@ -5711,7 +5723,7 @@ MUT.append(("main.py grading the site against a WINDOW of roster positions - "
MUT.append(("main.py grading the site against a THREE-wide window - "
"`self.packs[:3]` / `self.packs[-3:]`, found by THIS round's "
"battery, invisible to every behavioural run in this file",
ast.unparse(_floor_roster_arg) == "self.packs"
_src(_floor_roster_arg) == "self.packs"
and isinstance(_floor_roster_arg, ast.Attribute)
and all(binding_soc_floor(ctl.packs[:3], SOC_FLOOR) == dec.soc_floor
and binding_soc_floor(ctl.packs[-3:], SOC_FLOOR)
@@ -5758,7 +5770,7 @@ MUT.append(("`pack_floor_soc` reduced over a WINDOW - `vals[:2]` (+4 bytes) or "
MUT.append(("`pack_floor_soc` reduced over a THREE-wide window - `vals[:3]` / "
"`vals[-3:]` - invisible to every cycle in this file",
_s_head_kill[3] and _s_tail_kill[3]
and ast.unparse(_pfs_min_iter) == "vals",
and _src(_pfs_min_iter) == "vals",
"part 12i's LENGTH SWEEP (widths 1-11, on the REAL property over "
"rosters of 1 to 12 real inverters) and its STRUCTURAL PIN. The "
"six real cycles cannot see it - three packs is the longest roster "
@@ -5777,7 +5789,7 @@ MUT.append(("`pack_floor_soc` reduced with `max()` - the FULLEST pack, which is
MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
"`any(v is None for v in vals[:1])`, +4 bytes",
all(g is None for _n, _p, g in _s_none_rows)
and ast.unparse(_pfs_any_iter) == "vals",
and _src(_pfs_any_iter) == "vals",
"part 12i's NONE-GUARD SWEEP - one silent pack at every position of "
"every length 1..12 - and the same structural pin. ⚠️ 12d's "
"two-pack fixture silences the SECOND pack only, so a guard "
@@ -5786,7 +5798,7 @@ MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
# 32. and the roster the list itself is built from.
MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - "
"`[inv.soc for inv in self.inverters[:2]]`, +4 bytes",
ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters"
_src(_pfs_vals_iter) == "self.inverters"
and _s_head_kill[2] and not _SLICED,
"part 12i's sweep reads the REAL property on real rosters so it "
"sees this too, its pin is on the comprehension's own iterable, "
@@ -5818,7 +5830,7 @@ check("...and the seventh is honestly reported as not caught, because it cannot
"the expression rather than by a check. THAT is why the ceiling is a max "
"and not a subtraction - `headroom = cap - peak` would have turned the "
"same zero into FULL freedom, which part 3 shows is why headroom_w() is "
"not consumed here", not MUT[6][1]
"not consumed here", present(nth(MUT, 6)) and not MUT[6][1]
and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W)
check("...and SAFETY-32's own eight defeats are each caught too - the flat "
"cut-out inherited, the site floor graded in place of the pack's, the "
@@ -5851,7 +5863,7 @@ check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row
"pin would refuse honest refactors, so the hole is reported with its size "
"instead of hidden. THIS is the row that a fifth review should argue "
"with, and it exists to be argued with",
not MUT[27][1]
present(nth(MUT, 27)) and not MUT[27][1]
and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N)))
check("...⚠️ AND ROUND 4's SIX, WHICH ARE THE SAME FAMILY AT THE SIBLING "
"ARGUMENT OF THE SAME CALL. `pack_floor_soc` is the MINIMUM over the "
@@ -5873,7 +5885,7 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF "
"and a `lo` limit cannot cap a discharge. Saying so here is worth more "
"than a green line that would be untrue - and part 10 now says it with a "
"number instead of a sentence, which is worth more again",
not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
present(nth(MUT, 12)) and not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
# ==============================================================================
@@ -5953,7 +5965,8 @@ check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), re
check("...and the row is green and un-warned, which is what makes the "
"transposed version dangerous rather than merely untidy: an OK row "
"carrying a projection 2713 W OVER its own ceiling is the one an "
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
"operator scrolls past", len(_rows38) == 1
and _rows38[0]["ok"] and not _rows38[0]["warn"]
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
# ==============================================================================
@@ -6211,8 +6224,9 @@ check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused "
# been inverted.
_H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
_H45_TREE = ast.parse(_H45_SRC)
_h45_fn = next(n for n in ast.walk(_H45_TREE)
if isinstance(n, ast.FunctionDef) and n.name == "decide")
_h45_fn = first((n for n in ast.walk(_H45_TREE)
if isinstance(n, ast.FunctionDef) and n.name == "decide"),
"def decide in peakguard.py")
_H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If)
and ast.unparse(n.test) == "wanted <= 0.0"]
check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that "
@@ -6223,8 +6237,9 @@ check("...and it still DISENGAGES rather than merely reporting: the fixture "
"13a is built on reaches it and files no claim, so the pin above is not "
"grading a rung that has been inverted under it",
peakguard.claim(_h45_idle) is None)
_h45_head_fn = next(n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
and n.name == "charge_headroom_w")
_h45_head_fn = first((n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
and n.name == "charge_headroom_w"),
"def charge_headroom_w in peakguard.py")
check("...and charge_headroom_w() is a PURE READ of the decision - one "
"positional parameter, no projection, no clock and no options - so it "
"cannot become a second answer to the question decide() already answers",
+36 -15
View File
@@ -60,6 +60,8 @@ import os
import re
import shutil
import sys
from named_absence import first, nth
import tempfile
from datetime import date, datetime, timedelta, timezone
from textwrap import dedent
@@ -310,8 +312,10 @@ check("...and every one of them is in the schema block too", _unschemad == [])
# it. So the schema is deliberately WIDER than the registry, by exactly
# RETIRED_PROVIDERS and by nothing else. Both halves are graded: an id that is
# in neither set, and a retired id that has gone missing.
_line = next(ln for ln in _schema_block if ln.startswith(" price_provider:"))
_schema_ids = set(_line.split("list(", 1)[1].rstrip(")").split("|"))
_line = first((ln for ln in _schema_block if ln.startswith(" price_provider:")),
"price_provider in the schema")
_schema_ids = (set(_line.split("list(", 1)[1].rstrip(")").split("|"))
if "list(" in _line else set())
_wanted = set(PROVIDERS) | set(RETIRED_PROVIDERS) | {"off"}
check(f"the schema offers exactly the registered plugins, the retired ids and "
f"'off' {sorted(_schema_ids)}",
@@ -954,7 +958,7 @@ def asked_url(since, until, extra=None):
t = canned(_REAL[_TD])
o = opts(price_provider="generic_dayahead", **(extra or {}))
run(load_prices(o, since, until, transport=t))
return t.calls[0][0]
return nth(t.calls, 0)[0]
# The expectation is built HERE, from the window, by different arithmetic than
@@ -2069,7 +2073,7 @@ check(f"every networked plugin declares at least one host, and manual_csv "
check("the timeout the plugin passes to the transport is the configured one, "
"not a hardcoded default",
(lambda t: (load("generic_dayahead", {"price_timeout_s": 7},
transport=t), t.calls[0][1] == 7.0)[1])(
transport=t), nth(t.calls, 0)[1] == 7.0)[1])(
canned(columnar_feed(EPEX_EUR_MWH))))
@@ -2477,9 +2481,9 @@ def _started(sites, opts):
_MAIN_TREE = derived(lambda: ast.parse(open(os.path.join(_APP, "main.py"),
encoding="utf-8").read()))
_AMAIN = derived(lambda: next(n for n in ast.walk(_MAIN_TREE)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "amain"))
_AMAIN = derived(lambda: next((n for n in ast.walk(_MAIN_TREE)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "amain"), None))
_SITES = derived(lambda: _task_sites(_AMAIN))
_GRAPH = derived(lambda: _call_graph(_APP))
_FETCH_ENTRY = "resolve_tariff"
@@ -3491,8 +3495,8 @@ _cf_t = canned(_REAL[_TD])
run(load_prices(opts(price_provider="custom_formula", **_CF_OPTS),
_w0, _w0 + timedelta(hours=48), transport=_cf_t))
check(f"...and asks for the same Brussels range generic_dayahead asks for "
f"{_cf_t.calls[0][0]!r}",
_cf_t.calls[0][0] == asked_url(_w0, _w0 + timedelta(hours=48)))
f"{nth(_cf_t.calls, 0)[0]!r}",
nth(_cf_t.calls, 0)[0] == asked_url(_w0, _w0 + timedelta(hours=48)))
# The price at each instant is the document's own index through the card's
# arithmetic - computed HERE from the captured bytes, by different arithmetic.
@@ -4684,9 +4688,25 @@ _T17_TAB_OUT = derived(lambda: assemble(_T17_CAPS, _T17_RAW, MARKUP,
_T17_TABLE))
check("the table parses, through static_tariff's OWN grammar",
not isinstance(_T17_TABLE, _Raised))
# Graded on this file's own two calls: the arms may differ only in `taxes`.
_T17_ARMS = {t.id: [ast.unparse(a) for a in n.value.args[0].body.args]
for n in ast.parse(open(__file__, encoding="utf-8").read()).body
if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call)
and isinstance(n.value.func, ast.Name)
and n.value.func.id == "derived" and n.value.args
and isinstance(n.value.args[0], ast.Lambda)
and isinstance(n.value.args[0].body, ast.Call)
for t in n.targets if isinstance(t, ast.Name)
and t.id in ("_T17_FLAT_OUT", "_T17_TAB_OUT")}
print(f" arms: {_T17_ARMS}")
check(f"the two arms are the same feed: same caps, same {len(_T17_RAW)} "
f"RawPoints and the same markup {MARKUP}, by IDENTITY and not by value",
_T17_CAPS is _T17_CAPS and _T17_RAW is _T17_RAW)
f"RawPoints and the same markup {MARKUP} - the two `assemble()` calls "
f"share their first three arguments by NAME and differ only in the fourth",
sorted(_T17_ARMS) == ["_T17_FLAT_OUT", "_T17_TAB_OUT"]
and [len(v) for v in _T17_ARMS.values()] == [4, 4]
and _T17_ARMS["_T17_FLAT_OUT"][:3] == _T17_ARMS["_T17_TAB_OUT"][:3]
== ["_T17_CAPS", "_T17_RAW", "MARKUP"]
and _T17_ARMS["_T17_FLAT_OUT"][3] != _T17_ARMS["_T17_TAB_OUT"][3])
_T17_BANDS = derived(lambda: sorted({p.eur_per_kwh
for p in _T17_TABLE.periods}))
check(f"...and the table is genuinely hour-varying: {_T17_BANDS} EUR/kWh, two "
@@ -5562,10 +5582,11 @@ print("17f. the SCALAR path is unchanged - arithmetically, not merely in "
# ⚠️ THE ACCEPTANCE CRITERION TARIFF-17 GRADED THE SAME WAY (16f). A site with
# a flat injection price is every site that exists today, and "we did not mean
# to change it" is not a measurement.
_T16_OLD_SCALAR = [ast.unparse(n) for n in ast.walk(next(
n for n in ast.parse(open(os.path.join(HERE, "app", "price.py"),
encoding="utf-8").read()).body
if isinstance(n, ast.FunctionDef) and n.name == "export_price_eur_kwh"))
_T16_OLD_SCALAR = [ast.unparse(n) for n in ast.walk(first(
(n for n in ast.parse(open(os.path.join(HERE, "app", "price.py"),
encoding="utf-8").read()).body
if isinstance(n, ast.FunctionDef) and n.name == "export_price_eur_kwh"),
"def export_price_eur_kwh in price.py"))
if isinstance(n, ast.Return)]
check(f"`export_price_eur_kwh()`'s BODY is still the single expression it has "
f"always been, pinned on the AST rather than on the source text: "
+6 -3
View File
@@ -62,6 +62,8 @@ import inspect
import itertools
import os
import sys
from named_absence import first, nth
from datetime import datetime, timedelta, timezone
from app import price as _price
@@ -638,7 +640,7 @@ _late = fill_from_yesterday(
series_of(DAY, lambda u: t_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead", provisional=False),
yesterday=YESTERDAY, since=D0, until=D1, stamp=D1)
_h20 = DAY[20]
_h20 = nth(DAY, 20)
_before = derived(lambda: FILLED.at(_h20))
_after = derived(lambda: _late.at(_h20))
check("before the print, hour 20 is provisional and carries YESTERDAY's number",
@@ -728,7 +730,8 @@ check("an action that commits nothing is not an irreversible one and cannot be "
# prices that were published for the morning and guessed for the evening.
_m = maint()
_gate_bad = derived(lambda: begin_phase(_m, START, FILLED, MAINTENANCE_DRAIN))
_first_prov = derived(lambda: next(u for u in _hours if FILLED.at(u).provisional))
_first_prov = derived(lambda: first((u for u in _hours if FILLED.at(u).provisional),
"a carried hour in the window"))
check("the drain's window really does reach past the published hours while "
"staying INSIDE the series, so the refusal below is about provenance and "
"not about a short series",
@@ -743,7 +746,7 @@ check("...and the phase machine did not move: it is still idle, so the "
check("...and the reason names §8.3, the offending hour, and when the real "
"price is expected",
"§8.3" in _gate_bad.reason
and _first_prov.isoformat() in _gate_bad.reason
and bool(_first_prov) and _first_prov.isoformat() in _gate_bad.reason
and "14-16" in _gate_bad.reason)
# The positive arm, so the block above is discriminating rather than universal.
+11 -8
View File
@@ -104,6 +104,8 @@ import json
import os
import shutil
import sys
from named_absence import first, nth
import tempfile
from datetime import datetime, timedelta, timezone
@@ -239,7 +241,8 @@ YDAY_H = bday_hours(YDAY_D)
def at_local(bdate: str, hh: int) -> datetime:
"""The UTC instant whose Brussels wall clock reads hh:00 on `bdate`."""
return next(u for u in bday_hours(bdate) if brussels(u).hour == hh)
return first((u for u in bday_hours(bdate) if brussels(u).hour == hh),
f"{bdate} {hh:02d}:00 Brussels")
# ⚠️ AND IT IS NOT ON THE HOUR. A controller's `now` is
@@ -791,7 +794,7 @@ print("2b. a QUARTER-HOURLY feed keeps every print, and its spread with them")
_QSTEP_MIN = 15
_qrows = []
for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
_qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n)
_qu = nth(TODAY_H, 0) + timedelta(minutes=_QSTEP_MIN * _n)
# A spiky within-the-hour profile, so decimation is visible in the SPREAD
# and not only in a count.
_qrows.append((_qu,
@@ -2009,9 +2012,9 @@ check("...so calling it without one is a TypeError, not a quiet regression",
_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read()
_main_tree = ast.parse(_MAIN)
_refresh = next(n for n in ast.walk(_main_tree)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "run_tariff_refresh")
_refresh = first((n for n in ast.walk(_main_tree)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py")
_kw = [k.arg for n in ast.walk(_refresh) if isinstance(n, ast.Call)
for k in n.keywords]
check(f"the shipped refresh task passes a history {_kw} - the wiring is what "
@@ -2033,9 +2036,9 @@ check("...and `words` reaches the STATUS PAGE as its own row, so what the fill "
"has to carry from is a fact an operator can read rather than one that "
"only exists in a log line",
'("Price history", self.price_history.words)' in _MAIN)
_rows_fn = next(n for n in ast.walk(_main_tree)
if isinstance(n, ast.FunctionDef)
and n.name == "status_payload")
_rows_fn = first((n for n in ast.walk(_main_tree)
if isinstance(n, ast.FunctionDef)
and n.name == "status_payload"), "status_payload in main.py")
_row_labels = [e.elts[0].value for e in ast.walk(_rows_fn)
if isinstance(e, ast.Tuple) and len(e.elts) == 2
and isinstance(e.elts[0], ast.Constant)
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -75,9 +75,10 @@ print("1. the harness's own preconditions")
sys.path.insert(0, str(HERE / "tools"))
import rehearse_roster as RR # noqa: E402
check("the tool refuses rather than passing when it cannot run: its three exit "
"codes are distinct and 2 is not 0",
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN) == (0, 1, 2))
check("the tool refuses rather than passing when it cannot run: its four exit "
"codes are distinct and only 0 is a pass",
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN,
RR.EXIT_DEGRADED) == (0, 1, 2, 3))
# ⚠️ THE ANCHOR. `app/mqtt.py` records `sensor.goodwe_p1_sample_age` as VERIFIED
# against the live system on 2026-08-26, and the ESP32's ingest guard subscribes
@@ -204,6 +205,10 @@ def mutant_tree(edits) -> str:
shutil.copytree(FIX, dest / "tools" / "rehearsal", ignore=ignore)
shutil.copyfile(HERE / "tools" / "rehearse_roster.py",
dest / "tools" / "rehearse_roster.py")
# The input-shape gate (SAFETY-62) and the option names it checks against.
shutil.copyfile(HERE / "tools" / "predeploy_check.py",
dest / "tools" / "predeploy_check.py")
shutil.copyfile(HERE / "config.yaml", dest / "config.yaml")
for rel, old, new in edits:
path = dest / rel
text = path.read_text(encoding="utf-8")
@@ -356,8 +361,9 @@ check("omitting --live-entities REFUSES with exit 2 rather than reporting a "
_no_code == RR.EXIT_COULD_NOT_RUN and "REFUSED" in _no_out + _no_err)
_deg_code, _deg_out, _ = raw("--no-live-entities")
check("...and the explicit opt-out RUNS, so the escape hatch exists",
_deg_code in (RR.EXIT_OK, RR.EXIT_FINDING))
check("...and the explicit opt-out RUNS, so the escape hatch exists - but a "
"clean degraded run is exit 3, never 0 (SAFETY-62)",
_deg_code == RR.EXIT_DEGRADED and "NO FINDINGS" in _deg_out)
check("⚠️ ...carrying a standing banner in the report HEADER and again in "
"the closing block, because a report is skimmed from the bottom",
_deg_out.count("DEGRADED RUN") >= 2
+5 -2
View File
@@ -63,6 +63,8 @@ import ast
import gzip
import json
import sys
from named_absence import first
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -1213,8 +1215,9 @@ check("...and the pin is not vacuous: each of those names really is a "
def _t17_sig(module, name):
tree = ast.parse((HERE / "app" / module).read_text(encoding="utf-8"))
fn = next(n for n in tree.body
if isinstance(n, ast.FunctionDef) and n.name == name)
fn = first((n for n in tree.body
if isinstance(n, ast.FunctionDef) and n.name == name),
f"def {name} in {module}")
return [a.arg for a in fn.args.args]
+27 -23
View File
@@ -90,9 +90,9 @@ guard has to improve on, which is the useful thing a 2-day replay can say.
The two days are committed here as `scenarios/p1-net-<date>.jsonl.gz`
(`{"t": iso8601, "w": signed_watts}` per line, derived from the HA recorder
export) for the reason `scenarios/p1-age-2026-08-26.jsonl.gz` already is: the
infra repo is private, CI's token is minted for this repository only, and a
regression fixture that CI cannot see is not a regression fixture.
export), as `scenarios/p1-age-2026-08-26.jsonl.gz` already is. When they were
committed, CI could not see the infra repo; since CI-12 it checks admin/goodwe
out at `.gitea/infra-pin`, but these fixtures stay here with the tests.
The recorder's window rolls: 2026-08-01 leaves it around 2026-09-15, which is
why the committed copy is the artefact and not the API call.
@@ -114,6 +114,8 @@ import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from named_absence import given, nth, present
from app.main import Controller
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
from app.tariff import (
@@ -260,7 +262,7 @@ deg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
deg_closed = []
for i in range(0, QUARTER_S + 1, 5):
deg_closed += deg.add(sample(B + timedelta(seconds=i), HELD_W))
d_block = deg_closed[0].offtake_avg_w
d_block = nth(deg_closed, 0).offtake_avg_w
deg2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
LAST_S = QUARTER_S - 5
for i in range(0, LAST_S + 1, 5):
@@ -311,7 +313,7 @@ def feed(avg, t0_s, t1_s, w=RUN_W):
# (a) the control: no restart at all.
ctrl = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
ctrl_closed = feed(ctrl, 0, QUARTER_S)
CTRL_W = ctrl_closed[0].offtake_avg_w
CTRL_W = nth(ctrl_closed, 0).offtake_avg_w
# (b) restarted at minute 10, checkpoint restored through a real SQLite file.
st = fresh_store("restart")
@@ -332,8 +334,8 @@ a3 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
n_closed = feed(a3, RESTART_AT_S + STEP_S, QUARTER_S)
NORESTORE_W = n_closed[0].offtake_avg_w if n_closed else None
print(f" uninterrupted {CTRL_W:.2f} W | restored {RESTART_W:.2f} W | "
f"restart with no restore {NORESTORE_W:.2f} W")
print(f" uninterrupted {CTRL_W:.2f} W | restored {given(RESTART_W):.2f} W | "
f"restart with no restore {given(NORESTORE_W):.2f} W")
check(f"a restart at {RESTART_AT_S} s lands on the SAME billed block as an "
f"uninterrupted run", near(RESTART_W, CTRL_W, 1e-6))
check("...and without the restore it lands LOWER - so 'the restore works' is "
@@ -357,18 +359,18 @@ o_closed = feed(a4, RESTART_AT_S + OUTAGE_S, QUARTER_S)
OUTAGE_UNCOVERED_S = OUTAGE_S - MAX_HOLD_S
print(f" a {OUTAGE_S} s outage inside the quarter, of which "
f"{OUTAGE_UNCOVERED_S:.0f} s is past the {MAX_HOLD_S:.0f} s hold: "
f"{o_closed[0].offtake_avg_w:.2f} W")
f"{nth(o_closed, 0).offtake_avg_w:.2f} W")
check(f"...reads lower than the uninterrupted block by exactly the uncovered "
f"seconds ({RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S:.2f} W) - a gap is "
f"never held forward across the whole outage",
near(CTRL_W - o_closed[0].offtake_avg_w,
near(CTRL_W - nth(o_closed, 0).offtake_avg_w,
RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S, 1e-6))
# (e) double counting is answered by the primary key, not by care.
st5 = fresh_store("dedupe")
st5.record_block(ctrl_closed[0])
st5.record_block(ctrl_closed[0])
st5.record_block(ctrl_closed[0])
if ctrl_closed:
for _ in range(3):
st5.record_block(ctrl_closed[0])
rows = st5.blocks_in(month_key(B))
check("banking the same quarter three times leaves ONE row - quarter_start is "
"the primary key, so a replay or a restart cannot double-count",
@@ -376,7 +378,9 @@ check("banking the same quarter three times leaves ONE row - quarter_start is "
# ...and a later pass with LESS coverage may not lower a banked figure.
from app.tariff import _ClosedBlock as _CB
st5.record_block(_CB(start=B, offtake_avg_w=CTRL_W / 2, per_phase_offtake_avg_w=None))
if present(CTRL_W):
st5.record_block(_CB(start=B, offtake_avg_w=CTRL_W / 2,
per_phase_offtake_avg_w=None))
after = st5.blocks_in(month_key(B))
check(f"...and re-banking the same quarter at half the figure leaves it at "
f"{CTRL_W:.0f} W - a restart's partial re-run must never lower a "
@@ -665,10 +669,10 @@ u = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
u_closed = []
for i in range(0, QUARTER_S + 1, 5):
u_closed += u.add(sample(B + timedelta(seconds=i), NET, phases=3, phase_net=PH))
blk = u_closed[0]
blk = nth(u_closed, 0)
vals = {p: billed_w(blk, p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
print(f" phases {PH} (net {NET:+.0f} W) -> "
+ ", ".join(f"{k}={v:.0f} W" for k, v in vals.items()))
+ ", ".join(f"{k}={given(v):.0f} W" for k, v in vals.items()))
check(f"the NET policy bills the connection's net offtake ({NET:.0f} W)",
near(vals[POLICY_NET], NET, 1e-6))
check(f"the SUM policy clips each phase first, so the exporting phase does not "
@@ -687,7 +691,7 @@ b_closed = []
for i in range(0, QUARTER_S + 1, 5):
b_closed += bal.add(sample(B + timedelta(seconds=i), 3000.0, phases=3,
phase_net=(1000.0, 1000.0, 1000.0)))
b_vals = {p: billed_w(b_closed[0], p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
b_vals = {p: billed_w(nth(b_closed, 0), p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
check(f"...and on a BALANCED load NET and SUM coincide ({b_vals[POLICY_NET]:.0f} W), "
f"so a balanced fixture could not have told them apart",
near(b_vals[POLICY_NET], b_vals[POLICY_SUM], 1e-6))
@@ -746,7 +750,7 @@ check(f"...and record_block() with NO policy argument banks the NET figure "
# fixture change ever made two of these coincide, the triple above would start
# passing under a hardcoded policy and would be silently back where it started.
print(f" banked through the store: "
+ ", ".join(f"{k}={v:.0f} W" for k, v in _banked.items()))
+ ", ".join(f"{k}={given(v):.0f} W" for k, v in _banked.items()))
check("the three BANKED figures are three distinct numbers, so this triple is "
"not itself degenerate - it can only pass if the argument is read",
len({round(v, 6) for v in _banked.values() if v is not None}) == 3)
@@ -855,16 +859,16 @@ for path in REPLAY:
print(f" {path.name}: {span_h:.1f} h, {len(qs)} closed quarters, "
f"month peak {peak:.0f} W, "
f"{ok_q}/{len(qs)} = {100.0 * ok_q / max(len(qs), 1):.1f} % under the "
f"{SHIPPED_CAP_W:.0f} W soft limit")
f"{given(SHIPPED_CAP_W):.0f} W soft limit")
check(f"{path.name}: the month's peak equals its own largest quarter",
near(st9.peak(month=sorted(months)[0]).peak_w, peak, 1e-9))
bool(months) and near(st9.peak(month=sorted(months)[0]).peak_w, peak, 1e-9))
check(f"{path.name}: a full day of real telegrams produces ~96 quarters, "
f"got {len(qs)}", 90 <= len(qs) <= 96)
PCT = 100.0 * under / max(total_q, 1)
print()
print(f" AC 3 MEASURED: {under}/{total_q} quarters = {PCT:.1f} % under the "
f"{SHIPPED_CAP_W:.0f} W soft limit, over {replay_days:.1f} DAYS of "
f"{given(SHIPPED_CAP_W):.0f} W soft limit, over {replay_days:.1f} DAYS of "
f"replay, UNCONTROLLED (no peak shaving was running on either day).")
print(f" AC 3 ASKS FOR 30 DAYS and this run is {replay_days:.1f}, so it "
f"does not settle it either way. The 30 days DO now exist - "
@@ -928,7 +932,7 @@ def built(db_path, opts=None):
c = built(TMP / "wired.db")
check(f"a Controller built with peak_cap_w ABSENT takes the SHIPPED "
f"{SHIPPED_CAP_W:.0f} W, so config.yaml and main.py's fallback cannot "
f"{given(SHIPPED_CAP_W):.0f} W, so config.yaml and main.py's fallback cannot "
f"drift apart unnoticed", c.peak.cap_w == SHIPPED_CAP_W)
# Drive one real quarter through the real ingest, then the real cycle's sync.
@@ -981,7 +985,7 @@ check("the status page carries exactly one capacity-tariff peak row",
check("...naming the peak, the soft limit and a coverage figure, all derived "
"from the controller that was actually built",
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
and f"{SHIPPED_CAP_W:.0f} W soft limit" in peak_rows[0]["text"]
and f"{given(SHIPPED_CAP_W):.0f} W soft limit" in peak_rows[0]["text"]
and "quarter(s) banked" in peak_rows[0]["text"])
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
@@ -1006,7 +1010,7 @@ check("the row's coverage figure is the LIVE month's tally - graded against a "
bool(peak_rows)
and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
f"on a {SHIPPED_CAP_W:.0f} W limit",
f"on a {given(SHIPPED_CAP_W):.0f} W limit",
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
# The half that matters: the same page with a store that cannot answer.
+262
View File
@@ -0,0 +1,262 @@
"""SAFETY-60/62: the operator tools refuse input they cannot read. `python3 test_tool_inputs.py`
Each tool is driven as the operator runs it, first on good input (the accepted
baseline, MUTATION.md habit 7) and then on the shapes that used to pass.
"""
import contextlib
import copy
import importlib.util
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
TOOLS = HERE / "tools"
FIX = TOOLS / "rehearsal"
CAPTURE = HERE / "scenarios" / "safety57-2026-09-21" / \
"bus-a-2026-09-21-1030-1110.log.gz"
TMP = tempfile.mkdtemp(prefix="test_tool_inputs.")
ENV = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
fails = []
def check(name, ok):
print(f" {'ok ' if ok else 'FAIL'} {name}")
if not ok:
fails.append(name)
def dump(name, obj=None, text=None):
p = Path(TMP) / name
p.write_text(text if text is not None else json.dumps(obj, indent=1),
encoding="utf-8")
return str(p)
def tool(script, *args):
r = subprocess.run([sys.executable, str(TOOLS / script), *args],
capture_output=True, text=True, env=ENV)
return r.returncode, r.stdout + r.stderr
def ha_apps_info(options):
"""What `ha apps info <slug> --raw-json` prints: the options sit under data.options."""
text = {"en": ("Setpoint entity", "The ESPHome number carrying the setpoint."),
"nl": ("Setpoint-entiteit", "Het ESPHome-getal met het setpoint.")}
return {"result": "ok", "data": {
"name": "GoodWe RS485 Controller", "slug": "local_goodwe_controller",
"version": "0.12.0", "version_latest": "0.12.0", "state": "started",
"boot": "auto", "ingress": True, "hassio_api": True,
"options": copy.deepcopy(options),
"schema": [{"name": k, "required": True, "optional": False,
"type": "string"} for k in options],
"translations": {lang: {"configuration": {
"setpoint_entity": {"name": name, "description": desc},
"inverters": {"name": "Inverters", "description": "Roster.",
"fields": {"setpoint_entity": {
"name": name, "description": f"{desc} (per unit)"}}}}}
for lang, (name, desc) in text.items()}}}
# --------------------------------------------------------------------------- #
print("1. predeploy_check.py --options (SAFETY-60)")
# --------------------------------------------------------------------------- #
# A deployed version that differs from the shipped one, so only the options half decides.
BARE = {"setpoint_entity": "number.goodwe_setpoint_w", "inverters": [],
"max_w": 5000, "slew_w": 0, "gain": 0.6, "target_grid_w": -10,
"deadband_w": 15, "meter_source": "off", "log_level": "info"}
NOREPO = tempfile.mkdtemp(prefix="norepo.", dir=TMP)
def gate(opts_path):
return tool("predeploy_check.py", "--deployed-version", "0.0.0-test",
"--repo", NOREPO, "--options", opts_path)
code, out = gate(dump("bare.json", BARE))
print(f" bare options -> exit {code}")
check("baseline: the bare options object is CLEARED (exit 0)",
code == 0 and "CLEARED for deploy" in out)
code, out = gate(dump("bare-bad.json", dict(BARE, slew_w=5000)))
check("baseline: the bare object with slew_w 5000 is REFUSED by the validator",
code == 1 and "slew_w is 5000 W" in out)
for label, doc in (("wrapped", ha_apps_info(BARE)),
("wrapped, slew_w 5000", ha_apps_info(dict(BARE, slew_w=5000)))):
code, out = gate(dump("wrapped.json", doc))
print(f" {label} -> exit {code}")
check(f"the `ha apps info` capture ({label}) is REFUSED, exit 1",
code == 1 and "CLEARED" not in out)
check("...naming the shape and the fix, not a validator rule",
"0 of 2 top-level key(s) recognised" in out and ".data.options" in out
and "VALIDATOR" not in out)
from app.validate import actuator_writers, validate_startup_config # noqa: E402
_w = ha_apps_info(dict(BARE, slew_w=5000))
print(f" (for reference: the validator alone, handed that capture, finds "
f"{len(validate_startup_config(_w))} problem(s) and "
f"{len(actuator_writers(_w))} 'actuators' - the gate has to refuse it)")
code, out = gate(dump("list.json", [BARE]))
check("a list-wrapped options object is REFUSED, naming the type",
code == 1 and "is a JSON list, not an object" in out)
code, out = gate(dump("mostly-foreign.json",
{"setpoint_entity": "number.x", "a": 1, "b": 2}))
check("an object with fewer than half its keys recognised is REFUSED",
code == 1 and "1 of 3 top-level key(s) recognised" in out)
# --------------------------------------------------------------------------- #
print("\n2. rehearse_roster.py --state/--before/--after (SAFETY-62)")
# --------------------------------------------------------------------------- #
sys.path.insert(0, str(TOOLS))
import rehearse_roster as RR # noqa: E402
STATE = FIX / "state-legacy.json"
LIVE = ["--live-entities", str(FIX / "live-entities-recorded.txt"),
"--reference", str(FIX / "announced-reference.txt")]
def rehearse(state=str(STATE), before=str(FIX / "flat.yaml"),
after=str(FIX / "roster.yaml"), extra=LIVE):
return tool("rehearse_roster.py", "--state", state, "--before", before,
"--after", after, *extra)
code, out = rehearse()
print(f" control -> exit {code}")
check("baseline: the control run reports its 2 findings, exit 1",
code == RR.EXIT_FINDING and "2 FINDING(S)" in out)
code, out = rehearse(state=str(FIX / "state-fresh.json"))
check("baseline: a fresh (comment-only) state file still runs clean, exit 0",
code == RR.EXIT_OK and "NO FINDINGS" in out)
raw = STATE.read_text(encoding="utf-8")
state = json.loads(raw)
cases = {
"wrapped": (dump("s-wrapped.json", {"result": "ok", "data": state}),
"0 of 2 top-level key(s) recognised"),
"truncated by 20 bytes": (dump("s-trunc.json", text=raw[:-20]),
"not a readable JSON state file"),
"list-wrapped": (dump("s-list.json", [state]),
"is a JSON list, not an object"),
}
for label, (path, words) in cases.items():
code, out = rehearse(state=path)
print(f" state {label} -> exit {code}")
check(f"state {label}: REFUSED with exit 2 and the reason",
code == RR.EXIT_COULD_NOT_RUN and words in out
and "NO FINDINGS" not in out)
try:
_auto = RR.load_state(dump("s-auto.json", {"auto": True}))
except RR.Refused as err:
_auto = str(err)
check("a state holding only the Automatic toggle (`auto`, app/main.py) is "
"recognised as a store", _auto == {"auto": True})
flat = RR.load_options(str(FIX / "flat.yaml"))
roster = RR.load_options(str(FIX / "roster.yaml"))
for side, kw in (("--before", {"before": dump("b.json", ha_apps_info(flat))}),
("--after", {"after": dump("a.json", ha_apps_info(roster))})):
code, out = rehearse(**kw)
print(f" {side} wrapped -> exit {code}")
check(f"{side} as an `ha apps info` capture: REFUSED with exit 2",
code == RR.EXIT_COULD_NOT_RUN and ".data.options" in out
and "NO FINDINGS" not in out)
code, out = rehearse(after=str(FIX / "roster-inverter-a.yaml"),
extra=["--no-live-entities", "--reference",
str(FIX / "announced-reference.txt")])
check("a clean --no-live-entities run is exit 3, not 0",
code == RR.EXIT_DEGRADED and "NO FINDINGS" in out)
# --------------------------------------------------------------------------- #
print("\n3. alloc_sim.py - the split-invariance verdict reaches the exit code")
# --------------------------------------------------------------------------- #
spec = importlib.util.spec_from_file_location("_alloc_sim", TOOLS / "alloc_sim.py")
AS = importlib.util.module_from_spec(spec)
spec.loader.exec_module(AS)
class _Run:
"""One rig run, reduced to what `splits()` reads."""
def __init__(self, grid):
self.samples = [{"shares": [1000.0, 1000.0]}]
self._grid = grid
def grids(self):
return self._grid
def targets(self):
return [2000.0] * len(self._grid)
class _Sitesim:
def load_addon(self, *_):
return None
def alloc_main(same):
"""`main(--splits)` with the rig stubbed; the real `splits()` grades the traces."""
seen = []
def fake(_sitesim, _control, mode, *a, **kw):
seen.append(mode)
return _Run([0.0, 1.0] if same or len(seen) == 1 else [0.0, 2.0])
AS.find_sim = lambda: Path("/nonexistent-rig")
AS.load = lambda _sim: (None, _Sitesim(), None, None)
AS.run_site_feedback = fake
os.environ["GOODWE_ADDON_REPO"] = str(HERE.parent)
sys.argv = ["alloc_sim.py", "--splits"]
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = AS.main()
return rc, buf.getvalue()
rc, out = alloc_main(same=True)
check("baseline: identical traces HOLD, exit 0",
rc == 0 and "split-invariance HOLDS" in out)
rc, out = alloc_main(same=False)
print(f" differing traces -> exit {rc}")
check("differing traces FAIL, exit 1",
rc == 1 and "split-invariance FAILS" in out)
# --------------------------------------------------------------------------- #
print("\n4. decode_bus_capture.py - a file that is not a capture")
# --------------------------------------------------------------------------- #
code, out = tool("decode_bus_capture.py", str(CAPTURE))
check("baseline: the committed capture decodes, exit 0",
code == 0 and "writes 1200" in out and "REFUSED" not in out)
junk = dump("junk.log", text="hello\nthis is not a capture\n")
code, out = tool("decode_bus_capture.py", junk)
print(f" junk -> exit {code}")
check("a junk file is REFUSED, exit 1",
code == 1 and "REFUSED" in out and "0 of 2 line(s) decoded" in out)
code, out = tool("decode_bus_capture.py", dump("empty.log", text=""))
check("an empty file is REFUSED, exit 1",
code == 1 and "0 of 0 line(s) decoded" in out)
code, out = tool("decode_bus_capture.py", str(CAPTURE), junk)
check("several paths, as the docstring's glob passes them: each is decoded and "
"one bad file makes the run exit 1",
code == 1 and f"== {CAPTURE}" in out and f"== {junk}" in out)
code, out = tool("decode_bus_capture.py", str(CAPTURE), str(CAPTURE))
check("...and two good files exit 0", code == 0 and out.count("REFUSED") == 0)
shutil.rmtree(TMP, ignore_errors=True)
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all tool-input checks passed")
+8 -2
View File
@@ -371,8 +371,14 @@ print("\n7. no behaviour and no value moved")
# test_watchdog.py's six, test_price.py's seven, test_freeze.py's, and
# test_peakguard.py's. This line is the cheap one that says the reorder did not
# lose or duplicate a key on the way.
check("no option was lost, gained or duplicated by the reordering: the two "
"halves of config.yaml hold the same 72 keys, once each",
# ⚠️ THE COUNT IS READ OFF THE TREE, NOT TYPED. It was typed - "the same 72
# keys" - and was 75 by the time TEL-11 read it, because three cards had added
# an option each and none of them was looking at this sentence. A number in a
# check's own message is a claim like any other (MUTATION.md habit 22) and this
# one had been false for three cards without a single run going red, because
# nothing asserts it. Derived, it cannot rot.
check(f"no option was lost, gained or duplicated by the reordering: the two "
f"halves of config.yaml hold the same {len(ORDER)} keys, once each",
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
check("...and `inverters: []` is still shipped in the options half, which is "
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
+37 -11
View File
@@ -20,6 +20,8 @@ Read MUTATION.md before you report a survivor.
import sys
from datetime import datetime, timedelta, timezone
from named_absence import nth, present
from app.watchdog import (
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
@@ -1053,8 +1055,8 @@ async def _circ_restart():
await a.read_inputs()
await a.cycle()
await asyncio.sleep(0.25)
# THE RESTART. Same store, brand new Controller and Hass - which is exactly
# what a Home Assistant restart does to this add-on. The house has stopped
# THE RESTART. Same store, brand new Controller and Hass - an ADD-ON
# restart. A Core-only restart keeps the process; graded below (SAFETY-34). The house has stopped
# circulating in the meantime, so nothing in the new process's own
# measurements could re-derive the trip.
h2 = _CircHass(a_w=0.0, b_w=0.0)
@@ -1102,7 +1104,7 @@ check("...under a fixed notification_id of its own, so a second episode "
all(c[2]["notification_id"] == "goodwe_circulating_power"
for c in _notes1 + _notes2)
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
_msg = _notes2[0][2]["message"]
_msg = nth(_notes2, 0)[2]["message"]
check("...and the message tells the operator the three things only this alarm "
"can: that the meter cannot see it, that control has been stopped, and "
"that if the grid reading does not move afterwards one inverter is no "
@@ -1114,6 +1116,28 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
"this' a different question from 'is this site tripped'",
len(_notes2) == 1)
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
import app.main as _main_mod # noqa: E402
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
try:
_h3 = _CircHass(a_w=0.0, b_w=0.0)
_b.hass = _h3
asyncio.run(_b.check_circulating())
_early = [c for c in _h3.calls if c[0] == "persistent_notification"]
_clock[0] += _main_mod.REANNOUNCE_S
asyncio.run(_b.check_circulating())
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
finally:
_main_mod._mono = _real_mono
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
"storm on the event bus", len(_early) == 0)
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
"survives it, so after REANNOUNCE_S the SAME process re-asserts it",
len(_late) == 1
and _late[0][2]["notification_id"] == "goodwe_circulating_power")
print()
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
@@ -1706,7 +1730,7 @@ print(f" {_b30.sign_wd.reason!r}")
# the objects - and not off the fixture's constants, so the check cannot be
# satisfied by two numbers the fixture happens to hold anyway (habit 2).
_preb30 = [x for x in _s30 if not x["b"]]
_midb30 = _preb30[-1]
_midb30 = nth(_preb30, -1, "no cycle before B latched")
print(f" last cycle before B latched: B stood "
f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W")
check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A "
@@ -1840,7 +1864,7 @@ _c31, _h31, _s31 = asyncio.run(_site_reversed())
# stopped. This is `Run.p2p_between`'s lesson from the rig, in a unit test: name
# the window rather than inferring it.
_pre31 = [x for x in _s31 if not x["site"]]
_mid31 = _pre31[-1]
_mid31 = nth(_pre31, -1, "no cycle before the trip")
print(f" last cycle before the trip: stood "
f"{[round(x) for x in _mid31['stood']]} "
f"measured {[round(x) for x in _mid31['meas']]} "
@@ -1849,7 +1873,8 @@ check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
"agrees in sign with its own share - and the site is still charging into "
"an importing house. Without this the site trip below could be a "
"per-unit fault in disguise",
all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"]))
present(_mid31)
and all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"]))
and sum(_mid31["stood"]) * _mid31["grid"] < 0)
check("⚠️ AC 3. THE SITE WATCHDOG TRIPS AND NOT ONE PER-UNIT WATCHDOG DOES. "
"That asymmetry is the card: the fault is in the number the site was "
@@ -2053,7 +2078,7 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
# consecutive commands IDENTICAL; at a rail-following slew the loop ramps and
# they differ by exactly one step, which is how this was found.
_first1s = [x for x in _seen1s if x["tripped"]]
_mid1s = _first1s[0]
_mid1s = nth(_first1s, 0, "no tripped cycle")
print(f" the cycle the latch happened on: stood {_mid1s['stood']:+.0f} W, "
f"meter {_mid1s['grid']:+.0f} W")
print(f" {_inv1.sign_wd.reason!r}")
@@ -2501,13 +2526,13 @@ _c38, _s38seen = asyncio.run(_s38_site())
# The last sample BEFORE the trip, named rather than indexed - part 5's lesson.
# After the trip every command is 0 W, so a fixed index reads the REMEDY.
_pre38 = [x for x in _s38seen if not x["site"]]
_mid38 = _pre38[-1]
_mid38 = nth(_pre38, -1, "no cycle before the trip")
_GATE38 = _c38.site_sign_wd.cfg.min_w
_share38 = list(_mid38["stood"])
_red38 = {"sum": sum(_share38),
"mean": sum(_share38) / max(1, len(_share38)),
"max(key=abs)": max(_share38, key=abs),
"[0]": _share38[0]}
"max(key=abs)": max(_share38, key=abs, default=nth([], 0)),
"[0]": nth(_share38, 0)}
print(f" last cycle before the trip: stood "
f"{[round(x) for x in _share38]} measured "
f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W")
@@ -2538,7 +2563,8 @@ check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
"agrees in sign with its own share - and the site is charging into an "
"importing house. Without this the trip below could be a per-unit fault "
"in disguise rather than the site fault this grades",
all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
present(_mid38)
and all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
and sum(_mid38["stood"]) * _mid38["grid"] < 0
and _mid38["following"] is True)
check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "
+6 -3
View File
@@ -6,6 +6,8 @@
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
python3 tools/alloc_sim.py --all
Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig.
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
# sensing change that closed it. The two rows are the evidence.
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
@@ -279,13 +281,14 @@ def main():
if a.table or a.all:
table(sitesim, control, watchdog, a.cycles, a.load)
print()
held = True
if a.splits or a.all:
splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
a.feedback)
held = splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
a.feedback)
print()
if a.sweep or a.all:
sweep(sitesim, control, watchdog, a.cycles, a.load)
return 0
return 0 if held else 1
if __name__ == "__main__":
+26 -3
View File
@@ -195,14 +195,37 @@ def _hms(t):
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
def unreadable(stats):
"""Why a capture decoded to too little to trust, or None: most lines must be frames."""
framed = sum(stats[k] for k in ("requests", "responses", "writes",
"write_echoes"))
if framed > 0 and stats["lines"] - framed <= framed:
return None
return (f"{framed} of {stats['lines']} line(s) decoded as frames "
f"(unparsed {stats['unparsed']}, bad_crc {stats['bad_crc']}, "
f"unknown {stats['unknown']}) - not a capture this decoder can read")
def main(argv):
if len(argv) != 2:
"""Decode each capture named; exit 1 if any is unreadable, 2 on no argument."""
if len(argv) < 2:
print(__doc__.splitlines()[2].strip())
return 2
events, stats = decode(argv[1])
print(f"== {argv[1]}")
rc = 0
for path in argv[1:]:
rc |= _report(path)
return rc
def _report(path):
events, stats = decode(path)
print(f"== {path}")
for k, v in stats.items():
print(f" {k:14} {v}")
bad = unreadable(stats)
if bad:
print(f"REFUSED: {path}: {bad}")
return 1
for kind in ("setpoint_w", "batt_w", "soc_pct"):
rows = series(events, kind)
if not rows:
+48 -3
View File
@@ -39,8 +39,10 @@ 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.
the operator exports it and passes `--options` - the bare options object
(`.data.options` of `ha apps info <slug> --raw-json`); any other shape is
refused. 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
@@ -79,6 +81,43 @@ def shipped_version(tree=ADDON):
return m.group(1) if m else None
def option_keys(addon=ADDON):
"""Top-level option names in `addon`'s config.yaml `options:`/`schema:` blocks."""
keys, section = set(), None
with open(os.path.join(addon, "config.yaml"), encoding="utf-8") as fh:
for line in fh:
if not line.strip() or line.startswith("#"):
continue
if not line[0].isspace():
section = line.split(":", 1)[0].strip()
continue
m = re.match(r" ([A-Za-z_][A-Za-z0-9_]*):", line)
if m and section in ("options", "schema"):
keys.add(m.group(1))
return keys
def shape_problem(doc, known, what, allow_empty=False):
"""Why `doc` is not a document of `known` keys, or None (SAFETY-60/62).
Positive property: a JSON object, at least half of whose top-level keys
(`_`-prefixed comments aside) `known(key)` recognises.
"""
if not isinstance(doc, dict):
return f"{what} is a JSON {type(doc).__name__}, not an object"
keys = [k for k in doc if not str(k).startswith("_")]
hits = [k for k in keys if known(k)]
if (keys or allow_empty) and len(hits) * 2 >= len(keys):
return None
msg = (f"{what} is not the expected document: {len(hits)} of {len(keys)} "
f"top-level key(s) recognised {sorted(map(str, keys))[:8]}")
inner = doc.get("data")
if isinstance(inner, dict) and isinstance(inner.get("options"), dict):
msg += ("; this is `ha apps info --raw-json` output - pass its "
"`.data.options` instead")
return msg
def is_runtime(path):
return any(path == p or path.startswith(p) for p in RUNTIME)
@@ -256,6 +295,12 @@ def main():
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")
# SAFETY-60: the validator accepts any dict, so the shape is checked here.
bad = (shape_problem(opts, option_keys().__contains__, "--options")
if opts is not None else None)
if bad:
problems.append(f"{bad}; the config was NOT checked")
opts = None
if a.options and opts is not None:
try:
from app.validate import validate_startup_config
@@ -281,7 +326,7 @@ def main():
print(f" - {p}")
else:
print(" live options pass this version's validator")
else:
elif not a.options:
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.")
+38 -8
View File
@@ -46,8 +46,11 @@ EXIT CODES
1 ⭐ AT LEAST ONE OF THOSE HAPPENED. This is the code that must be
reachable: `test_rehearse.py` mutates the shipped migration nine
different ways and requires this exit code for each.
2 the rehearsal could not be performed (bad file, unparseable config, the
entity-id model failed its own anchor). ⚠️ NOT A PASS.
2 the rehearsal could not be performed (bad file, unparseable config, a
state or options file of the wrong shape, the entity-id model failed
its own anchor). ⚠️ NOT A PASS.
3 no findings, but --no-live-entities: the orphan check was not graded
against the install. ⚠️ NOT A PASS.
"""
import argparse
@@ -64,10 +67,12 @@ from app.inverter import UNIT_A, UNIT_STORE_KEYS, Inverter, roster_names # noqa
from app.main import Controller # noqa: E402
from app import mqtt # noqa: E402
from app.p1 import is_enabled # noqa: E402
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, # noqa: E402
unit_key)
from app.store import (DEFAULTS, MAINT_SCHEMA_KEY, MAINT_UNIT_KEYS, # noqa: E402
SCHEMA_KEY, Store, unit_key)
from predeploy_check import option_keys, shape_problem # noqa: E402
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN = 0, 1, 2
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN, EXIT_DEGRADED = 0, 1, 2, 3
ADDON = Path(__file__).resolve().parent.parent
class Refused(Exception):
@@ -184,8 +189,30 @@ def load_options(path: str) -> dict:
obj = _mini_yaml(text)
else:
obj = yaml.safe_load(text)
if not isinstance(obj, dict):
raise Refused(f"{path}: options must be a mapping, got {type(obj).__name__}")
# SAFETY-62: a wrapped export is a dict too, so assert the keys are options.
bad = shape_problem(obj, option_keys(str(ADDON)).__contains__, path)
if bad:
raise Refused(bad)
return obj
_UNIT_PREFIX = unit_key("\0", "").split("\0")[0]
# "auto" is the Automatic toggle, written by app/main.py outside DEFAULTS.
_STORE_KEYS = {*DEFAULTS, *MAINT_UNIT_KEYS, SCHEMA_KEY, MAINT_SCHEMA_KEY, "auto"}
def load_state(path: str) -> dict:
"""Refuse a state file `Store.load()` would silently start fresh on (SAFETY-62)."""
try:
obj = json.loads(Path(path).read_text(encoding="utf-8"))
except ValueError as err:
raise Refused(f"{path}: not a readable JSON state file ({err}); "
"Store.load() would start fresh and grade an empty store")
bad = shape_problem(
obj, lambda k: k in _STORE_KEYS or str(k).startswith(_UNIT_PREFIX),
path, allow_empty=True)
if bad:
raise Refused(bad)
return obj
@@ -1061,6 +1088,7 @@ def main(argv=None) -> int:
if args.live_entities else None)
if not Path(args.state).is_file():
raise Refused(f"{args.state}: not a file")
load_state(args.state)
except (OSError, Refused) as err:
print(f"REFUSED: {err}", file=sys.stderr)
return EXIT_COULD_NOT_RUN
@@ -1179,7 +1207,9 @@ def main(argv=None) -> int:
print(" the install's own entity list. This result does not cover "
"orphaned entities.")
print("=" * 78)
return EXIT_FINDING if findings else EXIT_OK
if findings:
return EXIT_FINDING
return EXIT_DEGRADED if degraded else EXIT_OK
if __name__ == "__main__":
@@ -0,0 +1,300 @@
"""SAFETY-56's behaviour-invariance instrument. `tools/safety56_behaviour_trace.py`
SAFETY-56 rewrote three operator-facing sentences and nothing else. This is what
says so: it drives a fixed stimulus set through the SHIPPED modules and prints
two documents -
`behaviour` every verdict, every `reading()`, every `releases`, every
`inputs_ok` and every readiness row's (ok, warn). NO SENTENCE
REACHES IT, so a tree whose only change is wording must produce
this document byte for byte.
`sentences` the four operator-facing surfaces, from the same stimulus.
Run it in two trees and compare:
python3 tools/safety56_behaviour_trace.py > /tmp/after.json
python3 tools/safety56_behaviour_trace.py --compare /tmp/before.json /tmp/after.json
⚠️ THE CONTROL IS PART OF THE TOOL AND NOT A SEPARATE STEP, WHICH IS MUTATION.md
HABIT 4 IN AN INSTRUMENT. `--compare` REFUSES to report "behaviour unchanged"
unless the `sentences` halves DIFFER. Without that, comparing a tree with itself
- which this card's first run did, by archiving a branch whose edits were not
committed yet - gives a perfect result that means nothing. An identity is
evidence only when the two sides could have differed, and ARCH-02's rehearsal
harness is the worked example of what happens when nobody checks.
⚠️ IT GRADES BEHAVIOUR, NEVER WORDING. Two trees that agree here can still print
different sentences - that is the point. `test_freshness.py` part 11 grades the
sentences themselves.
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13): anything outside `freshness.py`'s
verdict, `_read_unit()`'s gating and `checks()`'s rows; a roster of more than
one; the MQTT and ingress surfaces; and wall-clock behaviour, since every clock
here is the caller's monotonic argument. It is a pin on the three things this
card is forbidden to move, not a full behavioural equivalence.
⚠️ PURGE BEFORE USE on a tree you have just edited. `tools/mutation_run.sh`
does, and MUTATION.md says why `python3 -B` alone does not.
"""
import asyncio
import hashlib
import importlib.util
import json
import logging
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent # the add-on directory
sys.path.insert(0, str(HERE))
BOUNDS = Freshness = Controller = DEFAULTS = None
def _load():
"""The add-on's modules, imported LAZILY so that `--compare` runs anywhere.
⚠️ NOT A STYLE CHOICE. `--compare` is the half a reviewer runs on a laptop,
against two JSON files, in a directory that has no `app/` in it - and a
top-level import would make the comparison impossible exactly where it is
most useful.
"""
global BOUNDS, Freshness, Controller, DEFAULTS
from app.freshness import BOUNDS, Freshness # noqa: E402,F401
from app.main import Controller # noqa: E402,F401
from app.store import DEFAULTS # noqa: E402,F401
A = 5000.0
ONE = dict(meter_source="off", meter_entity="sensor.grid",
setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
batt_entity="sensor.batt_a", max_w=A, gain=0.6, slew_w=0,
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
stale_input_s=15, inverters=[], bms_soc_floor=10)
class MemStore:
def __init__(self, d=None):
self.data = dict(DEFAULTS, **(d or {}))
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Hass:
def __init__(self, numbers=None, limits=None):
self.numbers = dict(numbers or {})
self.limits_by = dict(limits or {})
self.served = []
self.writes = []
async def number(self, entity, invert=False):
v = self.numbers.get(entity)
self.served.append((entity, v))
return None if v is None else (-v if invert else v)
async def limits(self, entity):
return self.limits_by.get(entity, (None, None, None))
async def set_number(self, entity, value):
self.writes.append((entity, value))
return True
async def call(self, domain, service, data):
return True
def one_site(soc=50.0):
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": soc,
"sensor.batt_a": -1200.0},
{"number.sp_a": (-A, A, 1.0)})
c = Controller(dict(ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -A, A
return c, hass
async def _world(name, soc, reg_of, cmd_of, n):
c, hass = one_site(soc)
seen = []
for t in range(n):
hass.numbers["sensor.batt_a"] = reg_of(t)
c.inverters[0].last_write = cmd_of(t)
await c.read_inputs(now_mono=float(t))
seen.append([c.inverters[0].batt, c.inputs_ok,
c.inverters[0].batt_fresh.stale])
return {"name": name, "reads": seen,
"rows": [[r["ok"], r["warn"]] for r in c.checks()],
"n_rows": len(c.checks()), "events": len(c.events),
"inputs_bad": c.inputs_bad_since is not None,
"releases": [i.batt_fresh.releases for i in c.inverters]}
# The command and register schedules. Each of the conjunction's three terms is
# exercised alone and with the others, and the last two schedules take a verdict
# all the way through SAFETY-57's release and back into a re-trip.
CMDS = [lambda t: 0.0, lambda t: 1300.0, lambda t: -1200.0,
lambda t: 1300.0 if t >= 1 else -1200.0,
lambda t: 249.0 if t >= 1 else 0.0,
lambda t: 251.0 if t >= 1 else 0.0,
lambda t: 1300.0 if t < 40 else 0.0,
lambda t: 5.0 if t >= 1 else 1300.0,
lambda t: (t % 7) * 200.0 - 600.0,
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else (
0.0 if t < 90 else 1300.0),
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else 100.0]
REGS = [lambda t: -3.0, lambda t: -1200.0, lambda t: -1200.0 + (t % 3),
lambda t: None if t % 5 == 0 else -1200.0,
lambda t: 0.0, lambda t: float((t * 37) % 11) - 5.0]
WORLDS = [
("DEAD", 50.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
("REFUSED", 11.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
("ABSENT", 50.0, lambda t: None, lambda t: 0.0, 3),
("HEALTHY", 50.0, lambda t: -1200.0 + (t % 3),
lambda t: 1300.0 if t % 2 else -1200.0, 60),
("RELEASE", 50.0, lambda t: -1200.0,
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else 0.0, 60),
("RETRIP", 50.0, lambda t: -1200.0,
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else
(0.0 if t < 80 else 1300.0), 130),
]
def behaviour():
"""Everything this card is forbidden to move. No sentence may enter it."""
_load()
logging.disable(logging.CRITICAL)
out = {}
# 1. the committed live capture, read by read.
spec = importlib.util.spec_from_file_location(
"_s56decode", HERE / "tools" / "decode_bus_capture.py")
dec = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dec)
cap_file = (HERE / "scenarios" / "safety57-2026-09-21"
/ "bus-a-2026-09-21-1030-1110.log.gz")
events, _census = dec.decode(cap_file)
sp, bt = dec.series(events, "setpoint_w"), dec.series(events, "batt_w")
f = Freshness(BOUNDS["batt"])
t = float(int(max(sp[0][0], bt[0][0])) + 1)
t1 = int(min(sp[-1][0], bt[-1][0]))
cap = []
while t <= t1:
f.submit(dec.held_at(bt, t), dec.held_at(sp, t), t)
cap.append([t, f.stale, f.reading(), f.releases, f.held,
f.rested_since, f.graded_from, f.standing_at_grade,
f.released_at])
t += 1.0
out["capture_reads"] = len(cap)
out["capture_trace"] = hashlib.sha256(
json.dumps(cap, sort_keys=True).encode()).hexdigest()
out["capture_edges"] = [r[:4] for i, r in enumerate(cap)
if i == 0 or r[1] != cap[i - 1][1]]
# 2. the swept grid over the policy object.
sweep = []
for ci, cf in enumerate(CMDS):
for ri, rf in enumerate(REGS):
g = Freshness(BOUNDS["batt"])
row = []
for n in range(120):
g.submit(rf(n), cf(n), float(n))
row.append([g.stale, g.reading(), g.releases])
sweep.append([ci, ri, row])
out["sweep_cells"] = len(sweep)
out["sweep_trace"] = hashlib.sha256(
json.dumps(sweep, sort_keys=True).encode()).hexdigest()
out["sweep_stale_reads"] = sum(1 for _c, _r, row in sweep
for s, _v, _n in row if s)
out["sweep_missing_reads"] = sum(1 for _c, _r, row in sweep
for _s, v, _n in row if v is None)
out["sweep_releases"] = sum(row[-1][2] for _c, _r, row in sweep)
# 3. the shipped read path and every readiness row's verdict.
loop = asyncio.new_event_loop()
out["worlds"] = [loop.run_until_complete(_world(*w)) for w in WORLDS]
loop.close()
return out
def sentences():
"""The four operator-facing surfaces, from the REFUSED world."""
_load()
recs = []
class _H(logging.Handler):
def emit(self, record):
recs.append(record.getMessage())
logging.disable(logging.NOTSET)
log = logging.getLogger("goodwe")
log.addHandler(_H())
log.setLevel(logging.DEBUG)
async def go():
c, hass = one_site(11.0)
for t in range(40):
hass.numbers["sensor.batt_a"] = -3.0
c.inverters[0].last_write = 800.0 if t < 1 else 1600.0
await c.read_inputs(now_mono=float(t))
return c
loop = asyncio.new_event_loop()
c = loop.run_until_complete(go())
loop.close()
return {
"reason": c.inverters[0].batt_fresh.reason,
"log": [m for m in recs if "SAFETY-37" in m],
"stale_note": c.inverters[0].stale_note(),
"not_ready_row": [r["text"] for r in c.checks()
if r["text"].startswith("battery power")],
}
def compare(a_path, b_path):
a = json.loads(Path(a_path).read_text(encoding="utf-8"))
b = json.loads(Path(b_path).read_text(encoding="utf-8"))
beh_same = a["behaviour"] == b["behaviour"]
sent_same = a["sentences"] == b["sentences"]
ha = hashlib.sha256(json.dumps(a["behaviour"], sort_keys=True).encode())
hb = hashlib.sha256(json.dumps(b["behaviour"], sort_keys=True).encode())
print(f"behaviour A {ha.hexdigest()}")
print(f"behaviour B {hb.hexdigest()}")
if sent_same:
# ⚠️ THE REFUSAL, AND IT IS THE POINT OF THE TOOL. Two runs whose
# sentences agree are either the same tree twice or a card that changed
# nothing an operator reads. Either way "behaviour unchanged" is not a
# result, and reporting it would be an identity measured against itself.
print("REFUSED: the `sentences` halves are IDENTICAL, so these two runs "
"cannot be evidence that behaviour survived a WORDING change - "
"they may be the same tree twice. Compare a tree against one "
"whose sentences differ.")
return 2
if not beh_same:
for k in sorted(set(a["behaviour"]) | set(b["behaviour"])):
if a["behaviour"].get(k) != b["behaviour"].get(k):
print(f"BEHAVIOUR MOVED at {k!r}")
return 1
print("OK: sentences differ, behaviour is byte-identical.")
return 0
if __name__ == "__main__":
if len(sys.argv) == 4 and sys.argv[1] == "--compare":
sys.exit(compare(sys.argv[2], sys.argv[3]))
print(json.dumps({"behaviour": behaviour(), "sentences": sentences()},
sort_keys=True, indent=1))
@@ -65,7 +65,7 @@ import voluptuous as vol
from voluptuous.humanize import humanize_error
from deepmerge import Merger
from supervisor.apps.validate import SCHEMA_APP_CONFIG
from supervisor.apps.options import AppOptions
from supervisor.apps.options import AppOptions, UiOptions
from pathlib import Path
from supervisor.utils.yaml import read_yaml_file
@@ -86,13 +86,24 @@ print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema[
# this line must not crash the probe before section B gets to REFUSE it.
print(" options ship inverters ->", json.dumps(defaults.get("inverters")))
def validate(options):
v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"], "local_goodwe_controller")))
def validate_with(sch, options):
"""`(ok, result-or-refusal)` for ONE schema and ONE options blob.
⚠️ TEL-12 §G VARIES THE SCHEMA rather than only the options, which is what a
question about a KEY'S SHAPE requires. `validate()` below is this with the
shipped schema and is what every earlier section calls, unchanged.
"""
v = vol.Schema(vol.All(dict, AppOptions(None, sch, cfg["name"],
"local_goodwe_controller")))
try:
return True, v(copy.deepcopy(options))
except vol.Invalid as e:
return False, str(e)
def validate(options):
return validate_with(schema, options)
FAILED = []
def show(t, ok, d=""):
@@ -554,6 +565,190 @@ for lang in ("en", "nl"):
show(f"...and the coverage audit is clean against the real loader's view too. "
f"Problems: {_probs}", not _probs)
print("\n== G. TEL-12: the SHAPE of a several-inverter PV option ==")
# ⚠️ WHY THIS SECTION EXISTS. TEL-12 had a real choice of surface and the two
# candidates differ on ONE question nobody could answer by reading: does
# Supervisor accept a stored STRING for a key whose schema has become a LIST?
# 0.11.0 shipped `pv_entity: str?`, the reference install HAS a string persisted
# in it, and an upgrade that refuses that string leaves an add-on which will not
# start - CFG-04 §B's trap, in the other direction. So it was measured.
#
# ⚠️ AND IT IS AN ISOLATED COUNTERFACTUAL LIKE §B AND §D. One key's schema
# changes at a time, from a baseline that is ACCEPTED on the line above, and each
# refusal is asserted ON THE KEY'S NAME rather than on `not ok` (habit 6).
PV_STORED = {"pv_entity": "sensor.pv_power"} # what 0.11.0 persisted
_pv_base = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV_STORED))
ok, out = validate(_pv_base)
show("the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED "
"- the baseline every refusal below is varied from", ok,
f"pv_entity={out.get('pv_entity')!r} pv_entities={out.get('pv_entities')!r}"
if ok else out)
# --- OPTION 1: `pv_entity` becomes a list. THE ONE THAT LOSES. ---------------
_s_list = dict(schema, pv_entity=["str"])
ok_l, out_l = validate_with(_s_list, _pv_base)
show("⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as "
"a LIST, the string an 0.11.0 install has persisted is REFUSED, and the "
"refusal names the key - so that shape would leave the live add-on unable "
"to start on upgrade",
(not ok_l) and "Invalid list for option 'pv_entity'" in str(out_l), out_l)
# ⚠️ AND THE `[]` DEFAULT DOES NOT RESCUE IT, which is the half somebody would
# reach for. `_OPTIONS_MERGER`'s list strategy is OVERRIDE (CFG-04 §4), so the
# operator's string simply stands.
_d_list = dict(defaults, pv_entity=[])
_m_list = MERGER.merge(copy.deepcopy(_d_list), copy.deepcopy(PV_STORED))
ok_l2, out_l2 = validate_with(_s_list, _m_list)
show("...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's "
"list strategy is `override`, so the operator's string survives the merge "
"and is refused anyway",
_m_list["pv_entity"] == "sensor.pv_power" and not ok_l2, out_l2)
ok_l3, out_l3 = validate_with(_s_list, dict(_pv_base, pv_entity=["sensor.a"]))
show("...(the list shape itself is fine - it is only the STORED STRING it "
"refuses, which is what makes this a migration and not a schema question)",
ok_l3, out_l3 if not ok_l3 else "a list value validates")
# --- OPTION 1b: one key, both shapes. ACCEPTED, AND STILL WRONG. -------------
# ⚠️ REPORTED EVEN THOUGH IT PASSES, because "no single spelling accepts both"
# would have been a FALSE reason to reject option 1 - and MUTATION.md's habit
# about inventing justifications applies to a probe as much as to a card.
_s_match = dict(schema, pv_entity="match(.*)?")
try:
SCHEMA_APP_CONFIG(dict(raw, schema=dict(raw["schema"],
pv_entity="match(.*)?")))
_manifest_ok = True
except vol.Invalid as e:
_manifest_ok, _me = False, str(e)
_ok_s, _ = validate_with(_s_match, _pv_base)
_ok_li, _ = validate_with(_s_match, dict(_pv_base, pv_entity=["a", "b"]))
_ok_num, _ = validate_with(_s_match, dict(_pv_base, pv_entity=417))
_ui_match = [e for e in UiOptions(None)(_s_match)
if e.get("name") == "pv_entity"]
show("⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR "
"A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, "
"a list passes, and so does the integer 417; and the Configuration tab "
"renders it as a plain text box rather than a repeatable field, so an "
"operator cannot enter a second inverter in the UI at all",
_manifest_ok and _ok_s and _ok_li and _ok_num
and _ui_match and _ui_match[0].get("multiple") is not True,
f"string={_ok_s} list={_ok_li} integer-417={_ok_num} "
f"ui={json.dumps(_ui_match)}")
# --- OPTION 2: `pv_entities` beside `pv_entity`. WHAT SHIPPED. ---------------
show("⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no "
"`pv_entities` key at all, because it did not exist when that blob was "
"written - validates against THIS manifest, with the new key defaulted to "
"the empty list. The live install upgrades without touching its options",
ok and out.get("pv_entity") == "sensor.pv_power"
and out.get("pv_entities") == [])
_three = ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
ok3, out3 = validate(MERGER.merge(copy.deepcopy(defaults),
dict(PV_STORED, pv_entities=_three)))
show("...and a three-entity roster validates, per-entry, with both keys set - "
"which the add-on resolves by REPLACEMENT and says so on its status page",
ok3 and out3.get("pv_entities") == _three,
json.dumps(out3.get("pv_entities")) if ok3 else out3)
# ⚠️ §B's LOAD-BEARING MEASUREMENT, FOR THIS KEY. Exactly one key varies.
_no_pve = {k: v for k, v in defaults.items() if k != "pv_entities"}
ok_np, out_np = validate(_no_pve)
show("⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is "
"REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the "
"`pv_entities: []` default is load-bearing, exactly as `inverters: []` is: "
"Supervisor treats a nested list as required unless its element is an "
"OPTIONAL scalar, and `- str` is not one",
(not ok_np) and "Missing option 'pv_entities' in root" in str(out_np),
out_np)
_ui_pv = [e for e in UiOptions(None)(schema)
if e.get("name") in ("pv_entity", "pv_entities")]
show("...and the Configuration tab renders it as a REPEATABLE string field "
"(`multiple: true`), so an operator adds their second and third inverter "
"in the UI rather than in YAML",
[e["name"] for e in _ui_pv] == ["pv_entity", "pv_entities"]
and _ui_pv[1].get("multiple") is True
and _ui_pv[0].get("multiple") is not True, json.dumps(_ui_pv))
# --- the ROLLBACK direction, as §E does for `inverters` ----------------------
_flat = {k: v for k, v in schema.items() if k != "pv_entities"}
try:
_rolled = vol.Schema(vol.All(dict, AppOptions(
None, _flat, cfg["name"], "local_goodwe_controller")))(
MERGER.merge(copy.deepcopy(defaults),
dict(PV_STORED, pv_entities=_three)))
show("a DOWNGRADE to a schema without `pv_entities` drops the key with a "
"warning rather than refusing, and leaves `pv_entity` standing - so "
"rolling back to 0.11.0 returns the install to its single entity "
"rather than bricking it",
"pv_entities" not in _rolled
and _rolled.get("pv_entity") == "sensor.pv_power")
except vol.Invalid as e:
show("a DOWNGRADE to a schema without `pv_entities` drops the key", False,
str(e))
print("\n== H. TEL-14: the SHAPE of a per-entity PV sign option ==")
# Four candidate shapes, each against a 0.12.0 blob (`pv_entities` set, no new
# key). Transcript and decision: DECISIONS/TEL-14-pv-sign.md.
PV12 = {"pv_entity": "", "pv_entities": ["sensor.pv_inverter", "sensor.pv_meter"]}
_pv12 = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV12))
_nk = "pv_invert_entities"
def _ui_of(sch, name):
return [e for e in UiOptions(None)(sch) if e.get("name") == name]
# (a) a `-` prefix on a list item, under the shipped `- str`.
ok_a, out_a = validate(dict(_pv12, pv_entities=["sensor.pv_inverter",
"-sensor.pv_meter"]))
show("(a) PREFIX: `-sensor.pv_meter` validates under the shipped `- str` - "
"Supervisor does not look inside the string, so the sign would live in a "
"syntax that only the description text explains",
ok_a and out_a.get("pv_entities") == ["sensor.pv_inverter",
"-sensor.pv_meter"],
"" if ok_a else out_a)
# (b) a parallel list - THE SHIPPED SHAPE.
ok_b, out_b = validate(_pv12)
show("(b) ⭐ PARALLEL LIST, SHIPPED: the 0.12.0 blob (no `pv_invert_entities` "
"key) validates unchanged and the new key defaults to []",
ok_b and out_b.get(_nk) == []
and out_b.get("pv_entities") == PV12["pv_entities"],
"" if ok_b else out_b)
ok_b2, out_b2 = validate(dict(_pv12, **{_nk: ["sensor.pv_meter"]}))
show("...and one entity marked inverted validates",
ok_b2 and out_b2.get(_nk) == ["sensor.pv_meter"], "" if ok_b2 else out_b2)
ok_b3, out_b3 = validate({k: v for k, v in defaults.items() if k != _nk})
show("⭐ ...and the shipped `options:` block with ONLY `pv_invert_entities` "
"removed is REFUSED BY NAME - its `[]` default is load-bearing, as "
"`pv_entities: []` is",
(not ok_b3) and f"Missing option '{_nk}' in root" in str(out_b3), out_b3)
_ui_b = _ui_of(schema, _nk)
show("...and the Configuration tab renders it as a REPEATABLE string field "
"with its own top-level (translated) label",
len(_ui_b) == 1 and _ui_b[0].get("multiple") is True
and _ui_b[0].get("type") == "string", json.dumps(_ui_b))
# (c) a list of dicts - on `pv_entities` itself, and as a new key.
_s_dicts = dict(schema, pv_entities=[{"entity": "str", "invert": "bool?"}])
ok_c, out_c = validate_with(_s_dicts, _pv12)
show("(c) LIST OF DICTS ON `pv_entities`: the STRINGS a 0.12.0 install stored "
"are REFUSED - that shape would stop the add-on starting on upgrade",
(not ok_c) and "pv_entities" in str(out_c), out_c)
_s_src = dict(schema, pv_sources=[{"entity": "str", "invert": "bool?"}])
ok_c2, out_c2 = validate_with(_s_src, dict(_pv12, pv_sources=[]))
_ui_c = _ui_of(_s_src, "pv_sources")
show("(c2) ...as a THIRD key it validates, but renders as a type-schema "
"repeatable section whose per-entry keys reach the screen RAW "
"(`entity`, `invert`) on this frontend - see section F",
ok_c2 and len(_ui_c) == 1 and _ui_c[0].get("type") == "schema"
and _ui_c[0].get("multiple") is True,
json.dumps(_ui_c)[:200] if ok_c2 else out_c2)
# (d) one global bool.
_s_bool = dict(schema, pv_invert="bool")
ok_d, out_d = validate_with(_s_bool, dict(_pv12, pv_invert=False))
show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
# and still succeeds, and a transcript is only evidence if a wrong answer is
# distinguishable from a right one by something other than reading it.
+711
View File
@@ -0,0 +1,711 @@
"""TEL-11/TEL-12's behaviour-invariance instrument. `tools/tel11_pv_trace.py`
⚠️ THE FILE NAME STILL SAYS TEL-11 ON PURPOSE. It is quoted by
`DECISIONS/TEL-11-pv-production.md` §5 and by `test_pv.py`'s mutation table, and
a renamed instrument makes both of those references dangle for no gain. TEL-12
EXTENDED it rather than writing a second one, which is also the honest thing: the
two cards' claims are the same claim at two roster sizes, and one document set
graded by one `--compare` cannot drift apart the way two tools would.
TEL-11 added a PV production channel and claims two things a reviewer cannot
check by reading a diff:
1. with `pv_entity` UNSET - which is every install today and most installs
ever - the add-on behaves exactly as `release/1.0` does. Not "the control
law is untouched": the whole observable surface, commands and telemetry
alike.
2. with `pv_entity` SET, the CONTROL decisions are still exactly those. The
channel is instrumentation and must never become a control input, because
a loop that reacted to production would destroy the very measurement the
card exists to make.
TEL-12 makes the channel a ROSTER (`pv_entities`, several inverters, one site
total) and adds two more, in the same shape:
3. with `pv_entity` set to ONE entity, the add-on behaves exactly as 0.11.0
does - CONTROL AND TELEMETRY BOTH. That is a STRONGER claim than TEL-11
could make, because TEL-11's baseline had no channel at all to be identical
to, and it is the acceptance criterion for not breaking the live install:
`pv.total()` passes a single entity's answer through verbatim for exactly
this reason.
4. with SEVERAL entities configured, and with one of several ABSENT, the
control decisions are still exactly those. A PARTIAL total is a different
number from a full one, so "a partial reaches no decision" is a separate
claim rather than a corollary.
⚠️ AND TEL-12 HAD TO CHANGE `--compare`'s REFUSALS, BECAUSE TEL-11's WERE WRITTEN
AGAINST A BASELINE THAT NO LONGER EXISTS. TEL-11's rule was "exactly ONE side's
`set` document may differ from its own `unset`, and that side is the HEAD" - true
when `release/1.0` predated `pv_entity` entirely. `release/1.0` is now 0.11.0 and
BOTH sides react to `pv_entity`, so that refusal fires on every honest run. The
discriminator is the ROSTER instead: only the HEAD reacts to `pv_entities`, and
that is asserted rather than assumed.
This is what says so. It drives a fixed stimulus set through the SHIPPED
`Controller` and prints two documents per setting -
`control` every command written to an actuator, every `self.target`, every
`inputs_ok`, the loop's own three fields and the arbiter's
resolution, tick by tick. NO TEL-11/TEL-12 MATERIAL REACHES IT.
`telemetry` the published frames, the status rows and the announcement -
i.e. everything the card is ALLOWED to move.
- and a `tree` fingerprint: sha256 and byte count of every `app/*.py` it
imported, so the comparison can say WHICH TREES it graded.
python3 tools/tel11_pv_trace.py > /tmp/pv-head.json
# …in a release/1.0 checkout, with this file copied in…
python3 tools/tel11_pv_trace.py > /tmp/pv-base.json
python3 tools/tel11_pv_trace.py --compare /tmp/pv-base.json \\
/tmp/pv-head.json
⚠️ THREE DOCUMENT SETS PER TREE, AND THE THIRD IS WHY THE FIRST TWO STILL HASH
THE SAME AS THEY DID UNDER TEL-11. `unset` and `one` drive exactly the stimulus
cells TEL-11 drove, so their `control` hashes are comparable across the two
cards; the roster cells live in `multi`, a separate document, rather than being
appended to those. Adding cells to an existing document would have moved a hash
whose stability is itself the evidence.
⚠️ THE CONTROL IS PART OF THE TOOL, WHICH IS MUTATION.md HABIT 4 IN AN
INSTRUMENT - AND IT IS A DIFFERENT CONTROL FROM `safety56_behaviour_trace.py`'s,
ON PURPOSE. That card CHANGED sentences, so its `--compare` refuses to report
"behaviour unchanged" unless the sentences differ. THIS card's headline claim is
that with the option unset NOTHING differs at all, so "the documents are
identical" is the result rather than the warning sign - and the thing that has
to be proved instead is that the two runs came from DIFFERENT TREES and that the
instrument can see this card's material when it is switched on. So `--compare`
refuses unless:
* the two `tree` fingerprints DIFFER (otherwise it is one tree twice, which
is the trap ARCH-02's rehearsal harness walked into and SAFETY-56 names);
* exactly ONE side's `set` document differs from its own `unset` document.
That side is the HEAD; the other is the baseline, which has no such option
and cannot react to it. Both, or neither, is ambiguous and is refused.
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13):
* a roster of more than one INVERTER (the battery kind - CFG-04's
`inverters:`), and any per-unit surface. The PV roster IS driven, at three;
* the real MQTT transport, Home Assistant, and the recorder. The publication
is graded as the dict `Controller.publish()` hands the publisher plus the
announcement `discovery_payloads()` returns - not as bytes on a broker;
* wall-clock behaviour and the 1 s tick: every clock here is an argument;
* the ingress HTML, the action log and the firmware;
* anything a `pv_entity` whose sensor is HEALTHY does not exercise - the
stimulus set drives a good read, a refused unit and a dead sensor, and
nothing else;
* ⚠️ and it is an IDENTITY over a fixed stimulus, so a divergence that needs
an input this set never produces is invisible to it by construction. The
stimulus is printed with the documents for exactly that reason.
⚠️ PURGE `__pycache__` BEFORE USE on a tree you have just edited.
`tools/mutation_run.sh` does, and MUTATION.md says why `python3 -B` alone does
not.
"""
import asyncio
import hashlib
import json
import logging
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent # the add-on directory
sys.path.insert(0, str(HERE))
Controller = mqtt = None
def _load():
"""The add-on's modules, imported LAZILY so `--compare` runs anywhere.
⚠️ NOT A STYLE CHOICE - `safety56_behaviour_trace.py`'s note is this one's
too. `--compare` is the half a reviewer runs against two JSON files, in a
directory with no `app/` in it, and a top-level import would make the
comparison impossible exactly where it is most useful.
"""
global Controller, mqtt
from app.main import Controller # noqa: E402,F401
from app import mqtt # noqa: E402,F401
A = 5000.0
TUNING = {"max_w": A, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
"deadband_w": 15, "step_w": 10}
ONE = dict(TUNING, meter_source="off", meter_entity="sensor.grid",
setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
batt_entity="sensor.batt_a", heartbeat_s=10, stale_input_s=15,
inverters=[], bms_soc_floor=10)
PV_ENTITY = "sensor.pv_power"
class MemStore:
def __init__(self):
self.data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class Pub:
enabled = False
def __init__(self):
self.frames = []
def publish(self, values):
self.frames.append(dict(values))
def close(self):
pass
class Hass:
"""A scripted meter, a following inverter, and a scripted PV sensor."""
def __init__(self, grid_of, soc, pv_of):
self.grid_of, self.soc, self.pv_of = grid_of, soc, pv_of
self.batt = 0.0
self.n = 0
self.writes = []
async def limits(self, entity):
return (-A, A, 1.0)
async def number(self, entity, invert=False):
v = {"sensor.grid": self.grid_of(self.n), "sensor.soc": self.soc,
"sensor.soc_a": self.soc, "sensor.batt_a": self.batt,
"sensor.batt": self.batt}.get(entity)
return None if v is None else (-v if invert else v)
async def state(self, entity):
return self.pv_of(self.n)
async def set_number(self, entity, value):
self.writes.append((entity, float(value)))
self.batt = float(value)
return True
async def call(self, domain, service, data):
return True
def _pv_state(watts, unit="W"):
return {"state": str(watts),
"attributes": {"unit_of_measurement": unit}}
def _by_entity(mapping):
"""A `hass.state()` that answers each entity its OWN scripted state."""
async def state(entity):
return mapping.get(entity)
return state
# ⚠️ THE STIMULUS SET, PRINTED WITH THE DOCUMENTS. An identity is only as wide
# as what was driven through it, so the worlds and the PV schedules are part of
# the output rather than a fact about the source.
#
# Each PV schedule is exercised against each grid world. A schedule that only
# ever answered healthily could not separate "the loop ignores production" from
# "the loop ignores a production channel that is always saying the same thing".
GRIDS = {
"EXPORT-6K": lambda t: -6000.0,
"IMPORT-3K": lambda t: 3000.0,
"REST": lambda t: -10.0,
"SWINGING": lambda t: (-4000.0 if (t // 5) % 2 else 4000.0),
"METER-GAPS": lambda t: (None if t % 7 == 3 else -1500.0),
}
# --- TEL-12: THE ROSTER, AND THE VALUES ARE THE DESIGN ------------------------
# Three entities, distinct non-zero contributions in two different units, so the
# site total is separable from every partial of itself AND from max/min/first/
# last/mean over the three. ⚠️ NOT ONE OF THEM IS ZERO: a member contributing 0 W
# could go absent without moving the sum of the rest, and a stimulus like that
# cannot tell "published the absence" from "published the partial sum" - which is
# MUTATION.md habit 23 with a coinciding value in the fixture rather than in the
# plant.
PV_ENTITIES = ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
_R = {"sensor.pv_east": (1200.0, "W"), "sensor.pv_west": (2.1, "kW"),
"sensor.pv_garage": (450.0, "W")}
ROSTER_TOTAL_W = 3750.0 # 1200 + 2100 + 450, in watts
def _roster(dead=(), zero=()):
"""`{entity: state}` for the three - `dead` answers nothing, `zero` says 0."""
return {e: (None if e in dead
else _pv_state(0, "W") if e in zero else _pv_state(v, u))
for e, (v, u) in _R.items()}
# ⚠️ EACH CELL CARRIES BOTH HALVES OF THE CLAIM, AS LITERALS: the number that
# must NEVER appear on the PV row, and the number that MUST. `--compare` asserts
# against these rather than re-deriving anything from the code under test
# (habit 2), and a cell with no dead member has no partial to leak - which is why
# `never` is `None` there rather than the true total. That distinction cost a run:
# labelling WEST-ZERO's true total as its "partial" made 5/5 refuse the shipped
# tree.
#
# dead members zero members never / always (W)
ROSTERS = {
"ALL-THREE": ((), (), None, ROSTER_TOTAL_W),
"EAST-DEAD": (("sensor.pv_east",), (), 2550.0, None),
"WEST-DEAD": (("sensor.pv_west",), (), 1650.0, None),
"GARAGE-DEAD": (("sensor.pv_garage",), (), 3300.0, None),
# ⚠️ 0.0 IS THE NUMBER `sum([])` PRODUCES, and it is the one a roster with
# nothing reporting must never publish: it is the "there was no sun" the
# channel exists to test.
"ALL-DEAD": (tuple(_R), (), 0.0, None),
# ⚠️ AND THE MIRROR OF THIS CARD'S DEFECT: a roster that blanked the site
# whenever one member honestly read 0 W would report an absence on every
# clear evening. All three ANSWER here, so 1650 W MUST be published.
"WEST-ZERO": ((), ("sensor.pv_west",), None, 1650.0),
}
PVS = {
"none": lambda t: None,
"zero": lambda t: _pv_state(0),
"noon": lambda t: _pv_state(8000),
"ramp": lambda t: _pv_state(t * 250),
"kilowatts": lambda t: _pv_state(4.25, "kW"),
"bad-unit": lambda t: _pv_state(1234, "%"),
"flapping": lambda t: (None if t % 3 else _pv_state(3300)),
"negative": lambda t: _pv_state(-4200),
}
CYCLES = 24
async def _run(grid_of, pv_of, pv_set, soc=50.0, stop_at=None, roster=None):
hass = Hass(grid_of, soc, pv_of)
if roster is not None:
# ⚠️ PER-ENTITY ANSWERS, which a roster needs and `pv_of` cannot give:
# a scripted function of the tick alone answers every entity the same
# thing, and three equal numbers cannot separate a sum from any other
# reduction over three, nor drive ONE member absent.
hass.state = _by_entity(roster)
pub = Pub()
opts = dict(ONE)
if roster is not None:
opts["pv_entities"] = list(PV_ENTITIES)
elif pv_set:
opts["pv_entity"] = PV_ENTITY
c = Controller(opts, hass, MemStore(), pub, stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -A, A
c.inverters[0].dev_min, c.inverters[0].dev_max = -A, A
ctrl = []
for t in range(CYCLES):
if stop_at is not None and t == stop_at:
c.auto = False
await c.read_inputs()
await c.cycle()
c.publish()
# ⚠️ THE CONTROL DOCUMENT, AND NOTHING FROM THIS CARD IS IN IT. Every
# field here existed on release/1.0 and is a DECISION: what was
# commanded, what the site is aiming at, whether the inputs were good
# enough to act on, what the loop did and which claim won.
ctrl.append([hass.writes[-1] if hass.writes else None,
c.target, c.inputs_ok, round(c.i_w, 6),
round(c.loop_i_w, 6), round(c.loop_i_lead, 6),
c.loop_reason, c.reason, c.following,
c.maint.phase, len(c.events)])
hass.n += 1
return c, hass, pub, ctrl
def documents(pv_set: bool, roster: bool = False) -> dict:
"""`{"control": …, "telemetry": …}` for one setting of the PV channel.
⚠️ `roster=True` DRIVES ITS OWN STIMULUS CELLS AND LANDS IN ITS OWN DOCUMENT
(TEL-12). It does NOT append cells to the `unset`/`one` documents, because
those two documents' `control` hashes are the evidence TEL-11 recorded and a
new cell would move them for a reason that is not behaviour. Same reason
`_supports_include()` lives in `tree` rather than in a document.
"""
_load()
logging.disable(logging.CRITICAL)
loop = asyncio.new_event_loop()
control, tele, partials = {}, {}, {}
_cells = ([(f"{g}/{r}", gof, None, _roster(dead, zero), (never, always))
for g, gof in GRIDS.items()
for r, (dead, zero, never, always) in ROSTERS.items()]
if roster else
[(f"{g}/{pn}", gof, pof, None, None)
for g, gof in GRIDS.items() for pn, pof in PVS.items()])
for key, gof, pof, rmap, partial in _cells:
c, hass, pub, ctrl = loop.run_until_complete(
_run(gof, pof, pv_set, roster=rmap))
control[key] = {
"ticks": ctrl,
"writes": hass.writes,
"sha": hashlib.sha256(
json.dumps(ctrl, sort_keys=True).encode()).hexdigest(),
}
rows = c.checks()
tele[key] = {
"frames": pub.frames,
"rows": [[r["ok"], r["warn"], r["text"]] for r in rows],
"n_rows": len(rows),
"page": c.status_payload()["rows"],
"level": c.status_payload()["level"],
"banner": c.status_payload()["banner"],
}
if partial is not None:
# ⚠️ TEL-12. THE NUMBER THAT MUST NEVER APPEAR ON THE PV ROW,
# carried in the output so `--compare` asserts against a literal
# rather than re-deriving it from the code under test (habit 2).
# `wire` is what `str()` of it looks like on the topic, because that
# is the comparison that matters.
#
# ⚠️ IN ITS OWN TOP-LEVEL MAP AND *NOT* INSIDE THE TELEMETRY CELL.
# It is a fact about the STIMULUS, produced by this tool and
# identical on both trees, so putting it in the telemetry document
# made every roster cell differ from its unset reference for a
# reason that is not behaviour - and the HEAD/baseline discriminator
# then fired on both sides. Measured, on the first run.
never, always = partial
partials[key] = {
"never": None if never is None else str(round(never, 1)),
"always": None if always is None else str(round(always, 1))}
# A stopped run, because the "stopped or blind" branch writes its own rows.
c, hass, pub, ctrl = loop.run_until_complete(
_run(GRIDS["EXPORT-6K"], PVS["noon"], pv_set, stop_at=8,
roster=dict(_roster()) if roster else None))
control["STOPPED"] = {"ticks": ctrl, "writes": hass.writes,
"sha": hashlib.sha256(
json.dumps(ctrl, sort_keys=True).encode()
).hexdigest()}
tele["STOPPED"] = {"frames": pub.frames,
"rows": [[r["ok"], r["warn"], r["text"]]
for r in c.checks()]}
loop.close()
# The announcement is telemetry too, and it is the surface an install's
# entity registry is keyed on.
tele["ANNOUNCEMENT"] = _announcement(pv_set, roster)
out = {"control": control, "telemetry": tele}
if partials:
out["partials"] = partials
return out
def _supports_include() -> bool:
"""Does THIS tree's `discovery_payloads()` take TEL-11's `include`?
⚠️ A PROPERTY OF THE TREE, AND IT IS RECORDED IN `tree` RATHER THAN IN A
DOCUMENT. `release/1.0` has neither the argument nor `optional_keys()`, so
the tool has to run on a tree that predates both or it cannot produce a
baseline at all. Putting the answer in the documents instead would make
`unset` differ between the two trees for a reason that is not behaviour -
which is the "check that goes red for the wrong reason" MUTATION.md keeps
finding - so it goes beside the file hashes, where a reader comparing two
trees is already looking.
"""
import inspect
return ("include"
in inspect.signature(mqtt.discovery_payloads).parameters
and hasattr(mqtt, "optional_keys"))
def _supports_roster() -> bool:
"""Does THIS tree know `pv_entities` at all? TEL-12.
⚠️ IN `tree` FOR `_supports_include()`'s OWN REASON. A tree that predates
TEL-12 answers False; a tree that predates TEL-11 has no `app/pv.py` either,
so the import is guarded rather than assumed.
"""
try:
from app import pv as _p
except ImportError:
return False
return callable(getattr(_p, "entities", None))
def _announcement(pv_set: bool, roster: bool = False) -> list:
"""The retained discovery set, in announcement order.
⚠️ AT `pv_set` FALSE THIS IS THE BARE CALL ON BOTH TREES, which is the
point: an install that never set the option must receive the SAME
announcement from both, and that identity is graded rather than argued.
⚠️ AND AT `roster` TRUE THE OPTS CARRY `pv_entities` AND NOTHING ELSE. On a
tree that predates TEL-12 `optional_keys()` reads only `pv_entity`, so it
answers `()` and no PV row is announced - which is exactly how a baseline is
supposed to fail to react to a key it has never heard of, and is what
`--compare` 4/5 asserts rather than assumes.
"""
if (pv_set or roster) and _supports_include():
payloads = mqtt.discovery_payloads(
include=mqtt.optional_keys({"pv_entities": list(PV_ENTITIES)}
if roster
else {"pv_entity": PV_ENTITY}))
else:
payloads = mqtt.discovery_payloads()
return [[t, json.dumps(cfg, sort_keys=True)] for t, cfg in payloads]
def tree() -> dict:
"""sha256 and byte count of every `app/*.py` this run imported from."""
_load()
out = {"supports_include": _supports_include(),
# TEL-12. Does THIS tree know the roster at all? A property of the
# tree, recorded beside the file hashes for `_supports_include()`'s
# own reason: putting it in a document would make `multi` differ
# between trees for something that is not behaviour.
"supports_roster": _supports_roster()}
for f in sorted((HERE / "app").glob("*.py")):
raw = f.read_bytes()
out[f"app/{f.name}"] = [hashlib.sha256(raw).hexdigest(), len(raw)]
return out
def _h(obj) -> str:
return hashlib.sha256(json.dumps(obj, sort_keys=True).encode()).hexdigest()
# ⚠️ WHY `multi` IS GRADED PER GRID WORLD AND `one` IS NOT. `one` drives the same
# stimulus cells `unset` does - `GRID/pv-schedule` - so the two documents have the
# same keys and compare directly. `multi`'s cells are `GRID/roster-state`, a
# different naming over a different stimulus, so a whole-document comparison
# would report a difference that is only the key names. The comparable thing is
# the GRID: for one grid world, `unset` produces the SAME control ticks for every
# PV schedule (nothing is read), so that grid has ONE control document and each
# roster cell in the same grid must equal it. That per-grid collapse is asserted
# as a precondition before it is used - without it "equal to the unset document
# for this grid" names nothing.
def _grid(key: str) -> str:
return key.split("/")[0]
def _collapse(doc_part: dict):
"""`{grid: value}` if every cell in a grid agrees, else `None`.
⚠️ RETURNS `None` RATHER THAN A BEST GUESS. A caller that got a dict has a
well-defined per-grid reference; a caller that got `None` has to say so and
refuse, which is what `compare()` does.
"""
out = {}
for key, value in doc_part.items():
if "/" not in key:
continue
if _grid(key) in out and out[_grid(key)] != value:
return None
out[_grid(key)] = value
return out
def _reduces_to_unset(doc: dict, what: str) -> bool:
"""Is this side's `multi` document its own `unset` one, grid for grid?
True of a tree that has never heard of `pv_entities`: it reads nothing,
announces nothing and shows no row, so the roster cells are the unset cells.
False of a tree that implements TEL-12. This is the HEAD/baseline
discriminator, and it is MEASURED rather than taken from
`tree.supports_roster`, which is the tree describing itself.
"""
ref = _collapse(doc["unset"][what])
if ref is None:
return False
return all(value == ref.get(_grid(key))
for key, value in doc["multi"][what].items() if "/" in key)
def compare(a_path, b_path) -> int:
a = json.loads(Path(a_path).read_text(encoding="utf-8"))
b = json.loads(Path(b_path).read_text(encoding="utf-8"))
# --- the control, first: two DIFFERENT trees, and one of them reacts -----
if a["tree"] == b["tree"]:
print("REFUSED: the two runs have the SAME `app/` fingerprint, so they "
"are one tree twice. An identity is evidence only when the two "
"sides could have differed.")
return 2
# ⚠️ TEL-12 HAD TO CHANGE THIS DISCRIMINATOR AND HERE IS WHY. TEL-11's rule
# was "exactly ONE side's `set` document differs from its own `unset`" -
# true while `release/1.0` predated `pv_entity` entirely. `release/1.0` is
# 0.11.0 now, BOTH sides react to `pv_entity`, and that refusal would fire
# on every honest run. The ROSTER is the key only the HEAD has heard of.
for side, doc in (("A", a), ("B", b)):
for which in ("unset", "one", "multi"):
if which not in doc:
print(f"REFUSED: side {side} has no {which!r} document. Both "
f"sides must be produced by THIS version of the tool - "
f"copy it into the baseline checkout rather than using "
f"the one that is committed there.")
return 2
for side, doc in (("A", a), ("B", b)):
for what in ("control", "telemetry"):
if _collapse(doc["unset"][what]) is None:
print(f"REFUSED: on side {side} the `unset` {what} document "
f"does NOT collapse per grid world - two PV schedules in "
f"one grid gave different answers with the channel off. "
f"Nothing is read in that state, so this is the "
f"instrument being wrong rather than the tree.")
return 2
moved = [side for side, doc in (("A", a), ("B", b))
if not _reduces_to_unset(doc, "telemetry")]
if len(moved) != 1:
print(f"REFUSED: `multi` fails to reduce to `unset` on "
f"{moved or 'NEITHER'} side. Exactly one side may react to "
f"`pv_entities` - the HEAD. Neither means the instrument cannot "
f"see TEL-12's material at all; both means the baseline is not a "
f"baseline.")
return 2
head, base = (b, a) if moved == ["B"] else (a, b)
hlabel = "B" if moved == ["B"] else "A"
blabel = "A" if hlabel == "B" else "B"
print(f" HEAD is {hlabel}; baseline is {blabel} - its roster cells ARE its "
f"unset cells, which is what a tree that has never heard of "
f"`pv_entities` does with them.")
print(f" supports_roster {blabel} "
f"{base['tree'].get('supports_roster')} / {hlabel} "
f"{head['tree'].get('supports_roster')} (self-reported; the verdict "
f"above is MEASURED)")
print(f" tree {blabel} {_h(base['tree'])}")
print(f" tree {hlabel} {_h(head['tree'])}")
for what in ("control", "telemetry"):
for side, doc, which in ((blabel, base, "unset"),
(hlabel, head, "unset"),
(blabel, base, "one"), (hlabel, head, "one"),
(blabel, base, "multi"),
(hlabel, head, "multi")):
print(f" {what:<9} {side} {which:<5} {_h(doc[which][what])}")
rc = 0
# 1. TEL-11's acceptance criterion: unset is the baseline, in full.
if head["unset"] == base["unset"]:
print("OK 1/5 UNSET: control AND telemetry are byte-identical to the "
"baseline tree. An install that configures no PV entity sees no "
"change of any kind.")
else:
rc = 1
for what in ("control", "telemetry"):
for k in sorted(set(head["unset"][what]) | set(base["unset"][what])):
if head["unset"][what].get(k) != base["unset"][what].get(k):
print(f"FAIL 1/5 UNSET {what} MOVED at {k!r}")
# 2. ⚠️ TEL-12's OWN ACCEPTANCE CRITERION, AND IT IS STRONGER THAN ANYTHING
# TEL-11 COULD ASSERT. The baseline is 0.11.0, which HAS the channel, so
# "one entity behaves exactly as 0.11.0 does" is a real cross-tree
# identity over BOTH documents rather than an argument about f-strings.
# The install that reported this defect has `pv_entity` set; this is the
# line that says the upgrade does not move it.
if head["one"] == base["one"]:
print("OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to "
"0.11.0's single-entity path - the same commands, the same "
"payloads, the same status rows, the same announcement.")
else:
rc = 1
for what in ("control", "telemetry"):
for k in sorted(set(head["one"][what]) | set(base["one"][what])):
if head["one"][what].get(k) != base["one"][what].get(k):
print(f"FAIL 2/5 ONE-ENTITY {what} MOVED at {k!r}")
print(f" base {base['one'][what].get(k)!r}")
print(f" head {head['one'][what].get(k)!r}")
# 3. the card's other half at ONE entity - TEL-11's 2/3, kept verbatim in
# substance because it is the claim that must not decay.
if head["one"]["control"] == head["unset"]["control"]:
print("OK 3/5 CONTROL, ONE ENTITY: the control document is "
"byte-identical with the channel switched on - PV reaches no "
"decision.")
else:
rc = 1
for k in sorted(head["one"]["control"]):
if head["one"]["control"][k] != head["unset"]["control"].get(k):
print(f"FAIL 3/5 CONTROL MOVED at {k!r} with one entity set")
# 4. ⚠️ AND AT A ROSTER OF THREE, AND WITH A HOLE IN IT. A PARTIAL total is
# a DIFFERENT number from a full one, so "a partial reaches no decision"
# is a separate claim and not a corollary of 3/5.
_cref = _collapse(head["unset"]["control"])
_bad = [k for k, v in head["multi"]["control"].items()
if "/" in k and v != _cref.get(_grid(k))]
# ⚠️ AND THE SAME DOCUMENT ACROSS THE TWO TREES, which is the strongest form
# of the claim available: the baseline runs the roster cells with a key it
# has never heard of, so its control document for them is 0.11.0's. If
# HEAD's matches it byte for byte, the control path under a roster IS
# 0.11.0's control path.
_xtree = head["multi"]["control"] == base["multi"]["control"]
if not _xtree:
rc = 1
print("FAIL 4/5 the roster control document is NOT the baseline "
"tree's. A roster changed a decision.")
if not _bad and _xtree and (head["multi"]["control"].get("STOPPED")
== head["unset"]["control"].get("STOPPED")):
print(f"OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control "
f"document equals its grid world's `unset` one - "
f"{len(head['multi']['control']) - 1} cells over "
f"{len(_cref)} grid worlds, covering three healthy entities, one "
f"dead at EVERY position, all three dead and one honestly "
f"reporting 0 W. Neither the total nor any PARTIAL total reaches "
f"a decision, and the document is byte-identical to the BASELINE "
f"tree's for the same cells.")
else:
rc = 1
for k in _bad or ["STOPPED"]:
print(f"FAIL 4/5 CONTROL MOVED at {k!r} under a roster")
# 5. …and the instrument could have seen it, twice over: the telemetry
# document DOES move, and the partial sum a defect would have published
# is on no PV row anywhere in it.
n = sum(1 for k, v in head["multi"]["telemetry"].items()
if "/" in k
and v != _collapse(head["unset"]["telemetry"]).get(_grid(k)))
leaks, missing = [], []
for key, spec in head["multi"].get("partials", {}).items():
rows = [str(f["pv"]) for f in head["multi"]["telemetry"][key]["frames"]
if "pv" in f]
if spec["never"] is not None and spec["never"] in rows:
leaks.append((key, spec["never"]))
# ⚠️ AND THE OTHER DIRECTION, because "never publishes 1650" is also
# satisfied by a channel that publishes nothing ever. A cell whose
# members all ANSWER must publish its total, including the one where a
# member honestly reports 0 W.
if spec["always"] is not None and set(rows) != {spec["always"]}:
missing.append((key, spec["always"], sorted(set(rows))[:4]))
if not head["multi"].get("partials"):
print("REFUSED 5/5: the HEAD run carries no `partials` map, so the "
"partial-sum assertion quantified over nothing.")
return 2
if n and not leaks and not missing:
print(f"OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES "
f"move under a roster ({n} stimulus cells differ from their grid "
f"world's unset telemetry), so 1/5-4/5 are identities this "
f"instrument could have broken. AND in no cell with a dead member "
f"does the PV row carry the partial sum of the survivors, while "
f"every cell whose members all ANSWER publishes its total - "
f"including the one where a member honestly reports 0 W.")
else:
rc = 2
if leaks:
print(f"REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at "
f"{leaks[:6]}{' …' if len(leaks) > 6 else ''} - a smaller "
f"number indistinguishable from a real one, which is the "
f"defect this card exists to prevent.")
if missing:
print(f"REFUSED 5/5: a cell whose members ALL ANSWERED did not "
f"publish its total: {missing[:6]}. An absence published for "
f"a healthy roster defeats the channel from the other end - "
f"'no PV reported' on every clear evening.")
if not leaks and not missing:
print("REFUSED 5/5: the telemetry document did NOT move under a "
"roster. This instrument cannot see TEL-12's material, so "
"nothing above it means anything.")
return rc
if __name__ == "__main__":
if len(sys.argv) == 4 and sys.argv[1] == "--compare":
sys.exit(compare(sys.argv[2], sys.argv[3]))
print(json.dumps({
"tree": tree(),
"stimulus": {"grids": sorted(GRIDS), "pv": sorted(PVS),
"rosters": sorted(ROSTERS), "entities": PV_ENTITIES,
"roster_total_w": ROSTER_TOTAL_W, "cycles": CYCLES},
"unset": documents(False),
# ⚠️ `one` WAS CALLED `set` UNDER TEL-11 AND IS THE SAME STIMULUS SET,
# renamed because there are now two ways to switch the channel on and
# "set" no longer says which. Its `control` hashes are comparable to the
# ones TEL-11 recorded; nothing about the cells moved.
"one": documents(True),
"multi": documents(False, roster=True),
}, sort_keys=True, indent=1))
+9
View File
@@ -84,6 +84,15 @@ configuration:
estop_fitted:
name: "Hardware · RS485 emergency stop is fitted"
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
pv_entity:
name: "Hardware · PV production entity (W)"
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING - if yours reads negative while producing, list it under 'sign is reversed' below. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
pv_entities:
name: "Hardware · PV production entities, one per inverter (W)"
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page names which entity is missing, why, and what the others reported. Leave it empty and nothing is read, nothing is published and nothing is shown."
pv_invert_entities:
name: "Hardware · PV entities whose sign is reversed"
description: "Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a power clamp in the PV circuit - read NEGATIVE while producing and a few watts POSITIVE at night. Add each such entity here, spelled exactly as it is above, and its sign is flipped before it is added up; leave the others out. The status page marks every flipped entity. History recorded before you set this keeps the old sign."
meter_source:
name: "Meter · Where the meter reading comes from"
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
+9
View File
@@ -64,6 +64,15 @@ configuration:
estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst"
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
pv_entity:
name: "Hardware · Zonneproductie-entiteit (W)"
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN - leest de uwe negatief tijdens productie, zet hem dan ook bij 'teken omgekeerd' hieronder. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
pv_entities:
name: "Hardware · Zonneproductie-entiteiten, één per omvormer (W)"
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina noemt welke entiteit ontbreekt, waarom, en wat de andere doorgaven. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
pv_invert_entities:
name: "Hardware · Zonneproductie-entiteiten met omgekeerd teken"
description: "Optioneel, UITSLUITEND EEN METING. Sommige sensoren - meestal een kWh-meter of een stroomtang in het PV-circuit - lezen NEGATIEF tijdens productie en 's nachts enkele watt POSITIEF. Zet elke zulke entiteit hier, exact gespeld zoals hierboven, en haar teken wordt omgedraaid vóór het optellen; laat de andere weg. De statuspagina markeert elke omgedraaide entiteit. Geschiedenis van vóór deze instelling behoudt het oude teken."
meter_source:
name: "Meter · Waar de meterstand vandaan komt"
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."