Files
goodwe-addon/goodwe_controller/tools/supervisor_shape_probe.py
T
adminandClaude Opus 5 6b85acb0f1
checks / unit (pull_request) Successful in 4m30s
CFG-04: an operator can declare a second inverter, and the shape was measured
DEV-02 made every per-device value per-instance - rating, SoC, watchdogs,
heartbeat, store keys - so the code stopped assuming one inverter. The options
schema did not. `build_roster()` returned a list of exactly one and said so in
its own docstring, and there was no `inverters:` key for an operator to write,
so the roster could only ever be a roster of one.

THE SHAPE, AND WHY IT WAS MEASURED RATHER THAN CHOSEN

config.yaml had no list-of-objects anywhere, and `static_profile` carries a
whole tariff structure as a JSON string in a `str?` precisely because a nested
schema was doubted. That is a question about somebody else's software, so it was
answered by running it: `tools/supervisor_shape_probe.py` (committed) runs
Supervisor 2026.08.0's OWN `AppOptions` validator and `SCHEMA_APP_CONFIG`, out
of the release image, against this repository's config.yaml. 2026.08.0 is the
version the live install reports. The full transcript and the argument are in
DECISIONS/CFG-04-inverter-roster.md.

It accepts the list, enforces per-entry types, refuses a missing `name` /
`setpoint_entity` / `max_w` BY NAME, renders the option in the UI as a
repeatable section, and drops the key with a warning under an older schema - so
a downgrade is not the forward trap in reverse.

⚠️ AND THE `inverters: []` DEFAULT IS LOAD-BEARING, WHICH IS THE MEASUREMENT
THAT MATTERED MOST. Supervisor answers `Missing option 'inverters' in root` for
an options blob that has no such key - which every existing install's has, since
the key did not exist when they were written. `App.options` is the shipped
`options:` block deep-merged with the operator's stored one, so the empty
default is the difference between an upgrade nobody notices and an add-on that
cannot start with a schema pinned to the version that would fix it. That is
docs/deploy-rollback.md §4's trap in the FORWARD direction, found before it was
sprung. The merger was checked too: lists override rather than concatenate, so a
non-empty default could not have grown a roster behind the operator's back.

NOTHING IS INHERITED, WHICH IS DEV-02 RESTATED AS A CONFIG RULE

`name`, `setpoint_entity` and `max_w` are required of every entry. An entry that
omits `soc_entity` has no SoC sensor; it does not borrow the flat one. A
three-line fallback would have rebuilt the sibling-grading defect DEV-02 spent a
card removing, in YAML. Defaults still live in exactly one place (CFG-03):
there is no per-entry default anywhere, because there is no optional per-entry
number. A flat `setpoint_entity` left set beside a declared roster is refused
rather than ignored - it names an actuator nothing will write.

TWO PERMISSIVE READERS IN SHIPPED CODE, AND THREE MORE IN THE CHECKS

  validate.py rules 5 and 6 compared their thresholds against the FLAT `max_w`.
  At a roster of two 2000 W units with `max_w: 5000` left over from a
  single-inverter install, `sign_disagree_w: 3000` passed rule 5 while
  SAFETY-02's watchdog could fire on NEITHER unit - rule 5's own failure mode,
  reached through rule 5. They read `unit_caps()` now, which at one inverter is
  `[max_w]` exactly.

  main.py's `run_control()` re-ran the device-rating check behind
  `if self.o.get("setpoint_entity")` - the flat key, which a roster leaves blank
  - so on a two-inverter site no inverter's `max_w` was graded against the
  device it is written to, and the skip was silent. `validate_unit_ranges()`
  grades each unit against its own ESPHome and runs unconditionally.

  test_peakguard.py's `read_option()` and `_schema_bound()` and
  test_inverter.py's `read_schema_max()` matched a STRIPPED line and took the
  first hit, so the nested per-inverter `max_w:` began outranking the flat one -
  silently, and correctly, because the two bounds are the same int(100,5000)
  today. A reader that is right by coincidence is this project's named failure
  mode. All three are anchored at exactly two spaces now; the file's two halves
  disambiguate `peak_cap_w` instead of first-match, and an ambiguous key raises
  rather than picking one. test_tariff.py's copy of the idiom went with them.

THE AUDIT IS A CHECK, NOT A PROMISE

test_inverter.py part 11 finds every config.yaml-reading function in the suite -
transitively, because two of the readers CFG-04 had to change never name the
file - EXECUTES each against a synthetic two-inverter config.yaml whose
per-inverter numbers differ from its flat ones, and fails if any answers with
the per-inverter value. The table is not maintained by hand: the signature
decides, so the eleventh reader is named on its first run. config.yaml declares
`inverters:` BEFORE `max_w:` on purpose and part 11 asserts that ordering, so
the trap stays baited.

SAFETY-28's `actuator_writers()` needed no change - it was already a structural,
case-folding census - but until now there was no configuration in which two
writers on one actuator could be WRITTEN DOWN. There is now, and it is refused.

VERIFIED BY BREAKING IT

Ten mutations run through tools/mutation_run.sh and observed red, listed in
test_inverter.py part 12 with the check that bites each. Two survived the first
run and the CHECKS were changed, not the table: the inheritance check had blank
flat keys, so "inherited" and "not inherited" were the same empty string; and
the `run_control()` check asserted a SOURCE LINE was present, which re-indenting
it does not remove. Both are recorded in MUTATION.md's "the check's data cannot
reach the failure it names" section, which now carries two more habits: assert
on behaviour rather than on source text, and give a check whose failure mode is
a hang a deadline.

19/19 green in the goodwe-addon-ci image. The one-inverter path is byte-
identical: the whole existing suite runs unmodified against it.

Version bumped to 0.6.0 - the Supervisor keys the built image by `version:`, so
a schema change without a bump is a schema nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 04:29:45 +02:00

156 lines
7.9 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 live install's options blob MERGED WITH THIS FILE'S DEFAULTS validates,
and the same blob RAW does not - which is why `inverters: []` is shipped.
C. A two-inverter blob validates, per-entry types enforced.
D. An entry missing `name`, `setpoint_entity` or `max_w` is refused BY NAME,
and a JSON string where the list belongs is refused outright.
"""
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"]))
print(" options ship inverters ->", json.dumps(defaults["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)
def show(t, ok, d=""):
print(f" {'PASS' if ok else 'FAIL'} {t}")
if d: print(" " + str(d)[:300])
# 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. AC3: the live install upgrades into this version ==")
ok, out = validate(LIVE)
# ⚠️ EXPECTED TO BE REFUSED, AND THAT IS THE WHOLE POINT OF THE LINE. This is
# what `persist` holds on its own; the Supervisor never validates that, it
# validates it merged with `options:` below. Printed so the trap is visible
# rather than described.
show("the live blob RAW is REFUSED - this is the forward schema conflict, and "
"the next line is what stops it happening", not ok, out)
merged = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(LIVE))
ok, out = validate(merged)
show("the live 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 ==")
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},
{"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": 3000}])
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 ==")
for title, entry in (
("no name", {"setpoint_entity": "number.b", "max_w": 3000}),
("no setpoint_entity", {"name": "inverter-b", "max_w": 3000}),
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
ok, out = validate(dict(TWO, inverters=[entry]))
show(f"an entry with {title} is refused", not ok, out)
ok, out = validate(dict(TWO, 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}")