"""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}")