checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.
⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:
roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
clean -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
SAFETY-02 sign-disagreement watchdog can never fire"
min(caps[:1]) +4 B -> 0 problems. The site STARTS.
caps_ix[:1] +4 B -> 0 problems.
the SAME roster with the small cap FIRST (2000, 5000)
clean -> 1 problem, naming inverter-a
min(caps[-1:]) +5 B -> 0 problems.
⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.
THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)
Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.
183 roster reductions across 9 modules, out of 23 files parsed:
main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
peakguard 4, inverter 3, store 3. 14 modules reduce over no roster at all.
⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.
The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.
THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE
All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:
145 of 183 a behavioural fixture separates the site from a slice of itself
38 of 183 ONLY the structural claim. Behaviourally UNGRADED.
All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.
THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning
test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.
⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:
two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
The site started, the status page said `armed`, and the per-inverter watchdog could
not trip on EITHER unit. Identical sentence to the blocker, one branch over.
two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
never wrote, for a threshold both units can reach.
It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.
MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)
B1 validate min(caps) -> min(caps[:1]) +4 B RED test_validate.py
B2 validate min(caps) -> min(caps[-1:]) +5 B RED test_validate.py
B3 validate caps_ix -> caps_ix[:1] +4 B RED test_validate.py
B4 validate rule 6 min -> min(caps_ix[-1:]) +5 B RED test_validate.py
B5 validate rule 5 msg -> min(caps_ix[:1]) +4 B RED test_validate.py
B6 validate rule 5b msg -> min(caps_ix[-1:]) +5 B RED test_validate.py
B7 CONTROL: weakest -> max(0.0, weakest) +10 B GREEN, as intended
R3a peakguard max(floors) -> min(floors) 0 B RED test_peakguard.py
R3b peakguard max(floors) -> max(floors[-2:]) +5 B RED test_peakguard.py
R3c main call site packs -> self.packs[:2] +4 B RED test_peakguard.py
R4a main pack_floor_soc -> vals[:2] +4 B RED test_peakguard.py
R4b main pack_floor_soc -> vals[-2:] +5 B RED test_peakguard.py
R4c main pack_floor_soc -> vals[:3] +4 B RED test_peakguard.py
R4d main cycle() any(...) -> self.inverters[:1] +4 B RED test_watchdog.py
B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.
R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
binding_soc_floor(...) = 30.0 the 0-byte mutant would answer 15.0 (+15.0 pp)
-> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.
⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.
REBASED onto 67e649a. Two conflicts with SPIKE-04a, both in the blocks this card had
rewritten (config.yaml's roster example and the probe's C/D sections). Resolved to carry
BOTH claims, and the probe was RE-RUN rather than hand-merged: `all cases passed` on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, transcript updated with the
date. MUTATION.md habit 8 - a committed transcript stitched together out of two runs is
not a measurement.
Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0
before and after. CI-06: test_peakguard.py run four times in isolation, green each time
(288 ok lines); test_validate.py three times, green each time.
MUTATION.md gains habits 12 and 13: build the instrument once the same shape has been
found twice AND make it discover its own scope, and say what the instrument cannot see
with sizes in the same breath.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
285 lines
16 KiB
Python
285 lines
16 KiB
Python
"""CFG-04: does Supervisor accept a list-of-objects option? MEASURED, not assumed.
|
|
|
|
⚠️ WHY THIS SCRIPT EXISTS AND WHY IT IS COMMITTED. config.yaml had no
|
|
list-of-objects option anywhere before CFG-04, and `static_profile` carries a
|
|
structured value as a JSON STRING in a `str?` precisely because nobody knew
|
|
whether the Supervisor's options validator supported a nested one. That is a
|
|
question about a specific version of a specific piece of somebody else's
|
|
software, and the honest way to answer it is to run that software.
|
|
|
|
WHAT IT RUNS. Supervisor's OWN `AppOptions` validator and its OWN
|
|
`SCHEMA_APP_CONFIG`, out of the release image, against THIS repository's
|
|
config.yaml. Nothing here is a re-implementation of the validation rules; if
|
|
Supervisor changes them, this script changes its answer.
|
|
|
|
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
|
|
2026.08.0 is what 192.168.2.158 ran when CFG-04 landed):
|
|
|
|
docker run --rm \
|
|
-v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" -v /tmp/out:/out \
|
|
--entrypoint python3 \
|
|
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
|
/probe/supervisor_shape_probe.py
|
|
|
|
⚠️ IT TOUCHES NOTHING. It reads config.yaml and runs a validator in a throwaway
|
|
container. It does not talk to the live Supervisor, does not POST options and
|
|
does not install anything - the schema conflict in docs/deploy-rollback.md §4 is
|
|
exactly what you do NOT want to discover on a running site.
|
|
|
|
WHAT IT ANSWERED, 2026-09-04, on 2026.08.0 - the full output is in
|
|
DECISIONS/CFG-04-inverter-roster.md:
|
|
|
|
A. The manifest, list-of-objects schema and all, is ACCEPTED.
|
|
B. The shipped `options:` block validates as it stands, and the SAME block
|
|
with ONLY `inverters` removed is refused BY NAME - `Missing option
|
|
'inverters' in root`. That is the one measurement the `inverters: []`
|
|
default rests on, and it is an ISOLATED counterfactual: exactly one key
|
|
varies between the two runs, so the refusal cannot be about anything else.
|
|
B2. A reconstructed pre-0.6.0 options blob, merged with those defaults,
|
|
validates. Its RAW form is refused - but for whichever required key the
|
|
reconstruction happens to lack FIRST, which is an artefact of the
|
|
reconstruction and NOT evidence about `inverters`. B is that evidence.
|
|
C. A two-inverter blob validates, per-entry types enforced.
|
|
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
|
|
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
|
|
own name asserted in the refusal - and a JSON string where the list belongs
|
|
is refused outright.
|
|
|
|
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
|
|
per-entry key. That change broke §C and, worse, left §D printing PASS on a
|
|
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
|
|
now carries the key and §D is an isolated counterfactual asserting the key name.
|
|
|
|
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
|
|
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
|
|
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
|
|
together by hand from two runs is not a measurement, so this one was measured:
|
|
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
|
|
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
|
|
differs from A's 10 so that the per-entry number is exercised at all, while
|
|
production is expected to have BOTH packs at 10 %.
|
|
"""
|
|
import sys, json, copy
|
|
sys.path.insert(0, "/usr/src/supervisor")
|
|
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 pathlib import Path
|
|
from supervisor.utils.yaml import read_yaml_file
|
|
|
|
MERGER = Merger(type_strategies=[(dict, ["merge"])],
|
|
fallback_strategies=["override"], type_conflict_strategies=["override"])
|
|
|
|
raw = read_yaml_file(Path("/w/config.yaml"))
|
|
print("== A. the whole config.yaml through SCHEMA_APP_CONFIG ==")
|
|
try:
|
|
cfg = SCHEMA_APP_CONFIG(raw)
|
|
print(" PASS Supervisor accepts the add-on manifest, version", cfg["version"])
|
|
except vol.Invalid as e:
|
|
print(" FAIL ", humanize_error(raw, e)); sys.exit(1)
|
|
|
|
schema, defaults = cfg["schema"], cfg["options"]
|
|
print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema["inverters"]))
|
|
# .get, not [], deliberately: if someone deletes the `inverters: []` default
|
|
# 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")))
|
|
try:
|
|
return True, v(copy.deepcopy(options))
|
|
except vol.Invalid as e:
|
|
return False, str(e)
|
|
|
|
FAILED = []
|
|
|
|
def show(t, ok, d=""):
|
|
print(f" {'PASS' if ok else 'FAIL'} {t}")
|
|
if d: print(" " + str(d)[:300])
|
|
if not ok: FAILED.append(t)
|
|
|
|
# The live install's persisted options, reconstructed from HANDOVER.md §1 and
|
|
# docs/deploy-rollback.md §0/§B.5. No `inverters` key: it did not exist.
|
|
LIVE = {
|
|
"meter_entity": "sensor.p1_meter_active_power", "meter_invert": False,
|
|
"soc_entity": "sensor.goodwe_battery_soc", "batt_entity": "sensor.goodwe_battery_power",
|
|
"batt_invert": False,
|
|
"setpoint_entity": "number.kelder_goodwe_master_stage1_goodwe_setpoint_w",
|
|
"meter_source": "homewizard_local", "meter_phases": 1, "meter_max_age_s": 30,
|
|
"meter_poll_s": 5, "p1_host": "192.168.2.250",
|
|
"max_w": 5000, "gain": 0.6, "slew_w": 1000, "deadband_w": 15, "target_grid_w": -10,
|
|
"step_w": 10, "saturation_w": 500, "saturation_cycles": 3, "integrator_max_w": 0,
|
|
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 15, "auto_start": False,
|
|
"maintenance_enabled": False, "estop_fitted": True, "log_level": "info",
|
|
}
|
|
|
|
print("\n== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==")
|
|
# ⚠️ THIS IS AN ISOLATED COUNTERFACTUAL AND IT HAS TO STAY ISOLATED. The claim
|
|
# the `inverters: []` default rests on is about ONE key, so exactly ONE key
|
|
# varies between these two runs: the full shipped `options:` block, and the same
|
|
# block with `inverters` removed and nothing else touched. Feeding the validator
|
|
# some other, INCOMPLETE blob would also produce a refusal - but about whichever
|
|
# required key that blob lacks first, which is a different measurement wearing
|
|
# this one's label. §B2 below is that other blob, and it is labelled as such.
|
|
ok, out = validate(dict(defaults))
|
|
show("the shipped `options:` block validates AS IT STANDS - so anything refused "
|
|
"on the next line is refused for the one key that was removed", ok,
|
|
"" if ok else out)
|
|
_no_inv = {k: v for k, v in defaults.items() if k != "inverters"}
|
|
ok_ni, out_ni = validate(_no_inv)
|
|
# ⚠️ THE ASSERTION IS ON THE KEY NAME, NOT ON `not ok`, AND THAT IS THE POINT.
|
|
# A refusal is only evidence for THIS key if it NAMES this key. Asserted as a
|
|
# bare `not ok`, this line would go on printing PASS after someone deleted
|
|
# `inverters: []` from config.yaml - blind to the exact regression it is the
|
|
# committed evidence for. Asserted on the string, it goes red: with the default
|
|
# gone the line above fails too, because then nothing distinguishes the two runs.
|
|
_names_it = (not ok_ni) and "Missing option 'inverters' in root" in str(out_ni)
|
|
show("...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - "
|
|
"which is every options blob written before 0.6.0, since the key did not "
|
|
"exist when they were written", _names_it, out_ni)
|
|
|
|
print("\n== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==")
|
|
# ⚠️ WHAT THIS SECTION IS AND IS NOT. LIVE is an INCOMPLETE reconstruction - it
|
|
# carries the keys HANDOVER.md and deploy-rollback.md record and no others, so
|
|
# it is missing many required keys that have nothing to do with this card. Its
|
|
# raw refusal is therefore about whichever of THOSE the validator reaches first
|
|
# (it has printed `meter_freeze_s` and `p1_phase_export_entities` on different
|
|
# runs), NOT about `inverters`. It is kept because the MERGED half is a real
|
|
# demonstration that `deepmerge(options:, stored)` is what App.options is; the
|
|
# raw half is printed only so the merge has something to be contrasted with.
|
|
# The `inverters` measurement is §B, where one key and only one key varies.
|
|
ok, out = validate(LIVE)
|
|
show("the reconstructed raw blob is REFUSED - for an unrelated missing key, "
|
|
"because the reconstruction is incomplete; see the refusal printed below "
|
|
"and §B for the `inverters` measurement", not ok, out)
|
|
merged = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(LIVE))
|
|
ok, out = validate(merged)
|
|
show("the same blob MERGED with these defaults - what App.options actually is", ok,
|
|
f"inverters={out.get('inverters')!r} setpoint_entity={out.get('setpoint_entity')!r} "
|
|
f"max_w={out.get('max_w')!r}" if ok else out)
|
|
|
|
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
|
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
|
|
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
|
|
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
|
|
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
|
|
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
|
|
# nameplate is wrong for this purpose too, because nothing will be connected to
|
|
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
|
|
#
|
|
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
|
|
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
|
|
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
|
|
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
|
|
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
|
|
# read off the unit. So this blob is what an operator would POST TODAY, not a
|
|
# claim about what inverter B will turn out to do.
|
|
#
|
|
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
|
|
# This file's own §B is the record of what an instrument that cannot distinguish
|
|
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
|
|
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
|
|
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
|
|
# pair instead of the real one.
|
|
#
|
|
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
|
|
# blob is also what proves an operator can still POST a two-inverter config
|
|
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
|
|
# not exercise a per-entry number at all, for the same reason the two ratings
|
|
# must stay different.
|
|
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
|
|
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
|
|
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
|
|
# - there is no second pack on this site, and in production BOTH packs are
|
|
# expected to floor at 10 %, which is why the differing pair lives here and in
|
|
# the fixtures rather than in config.yaml's example.
|
|
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
|
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
|
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
|
"max_w": 5000, "bms_soc_floor": 10},
|
|
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
|
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
|
|
"max_w": 4600, "bms_soc_floor": 15}])
|
|
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
|
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
|
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
|
# whole job is the verdicts above, and a missing /out must not fail it.
|
|
try:
|
|
json.dump(out if ok else {}, open("/out/two_inverter_options.json", "w"), indent=1)
|
|
except OSError:
|
|
pass
|
|
|
|
print("\n== D. what the schema itself refuses, per entry ==")
|
|
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
|
|
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
|
|
# section. These entries used to omit several keys at once and assert a bare
|
|
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
|
|
# on printing PASS while the refusal it printed said `Missing option
|
|
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
|
|
# anything, so the baseline below is COMPLETE and each case deletes exactly one
|
|
# key - or, for the range case, moves exactly one value out of range.
|
|
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
|
|
"bms_soc_floor": 10}
|
|
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
|
|
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
|
|
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
|
|
# is the provenance problem §B2 is kept as a warning about.
|
|
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
|
|
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
|
|
show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere "
|
|
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
|
|
for title, entry, expect in (
|
|
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
|
|
("no setpoint_entity",
|
|
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"),
|
|
("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
|
|
("no bms_soc_floor (SAFETY-32)",
|
|
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
|
|
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
|
|
("bms_soc_floor over the range (SAFETY-32)",
|
|
dict(D_FULL, bms_soc_floor=99), "at most 50")):
|
|
ok, out = validate(dict(D_BASE, inverters=[entry]))
|
|
show(f"an entry with {title} is refused, and the refusal NAMES it",
|
|
(not ok) and expect in str(out), out)
|
|
ok, out = validate(dict(D_BASE, inverters="[]"))
|
|
show("a JSON STRING where the list belongs is refused", not ok, out)
|
|
|
|
|
|
print("\n== E. the ROLLBACK direction, and the operator's UI ==")
|
|
# The old (flat) schema, i.e. what an operator would be running after a
|
|
# downgrade, meeting options that carry an `inverters` list.
|
|
_flat_schema = {k: v for k, v in schema.items() if k != "inverters"}
|
|
_v = vol.Schema(vol.All(dict, AppOptions(None, _flat_schema, cfg["name"],
|
|
"local_goodwe_controller")))
|
|
try:
|
|
_rolled = _v(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
|
show("a schema WITHOUT `inverters` drops the key with a warning rather "
|
|
"than refusing - so a downgrade is not the §B trap in reverse",
|
|
"inverters" not in _rolled, sorted(_rolled)[:6])
|
|
except vol.Invalid as e:
|
|
show("a schema WITHOUT `inverters` drops the key", False, str(e))
|
|
|
|
from supervisor.apps.options import UiOptions # noqa: E402
|
|
try:
|
|
_ui = [e for e in UiOptions(None)(schema) if e.get("name") == "inverters"]
|
|
show("Supervisor's UI renderer turns it into a repeatable section rather "
|
|
"than choking - an operator can add the second inverter in the "
|
|
"Configuration tab, which the JSON-string alternative could not offer",
|
|
bool(_ui) and _ui[0].get("multiple") is True, json.dumps(_ui))
|
|
except Exception as e: # noqa: BLE001
|
|
show("Supervisor's UI renderer survives the shape", False, f"{type(e).__name__}: {e}")
|
|
|
|
|
|
# ⚠️ 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.
|
|
print()
|
|
if FAILED:
|
|
print(f"== {len(FAILED)} CASE(S) FAILED ==")
|
|
for t in FAILED:
|
|
print(" - " + t)
|
|
sys.exit(1)
|
|
print("== all cases passed ==")
|