"""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, UiOptions 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_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=""): 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}") print("\n== F. UX-01: the TRANSLATED Configuration tab, rendered ==") # ⚠️ WHAT THIS SECTION MEASURES, AND WHY IT IS NOT AN ASSERTION ABOUT A FILE. # UX-01 adds translations/en.yaml and translations/nl.yaml. Whether they reach # an operator depends on two pieces of somebody else's software, not on us: # # 1. SUPERVISOR decides whether a file is loaded at all. # `supervisor/store/data.py:_read_app_translations()` globs this directory, # keys each file by its STEM, and runs it through `SCHEMA_APP_TRANSLATIONS`. # ⚠️ A FILE THAT FAILS THAT SCHEMA IS DROPPED WITH A LOG WARNING AND # NOTHING ELSE: `except (ConfigurationFileError, vol.Invalid): _LOGGER. # warning(...); continue`. The add-on then renders raw keys and the only # trace is one line in the Supervisor log. So the loader is RUN here, out # of the release image, rather than the schema being re-implemented. # 2. THE FRONTEND decides what it does with what Supervisor loaded. The # resolution below is not invented: it is transcribed from the shipped # bundle in THIS image, `api/panel/frontend_latest/5680.*.js` # (frontend 20250925.1), which reads # # _getTranslationEntry(lang,s,o){let e=this.addon.translations[lang]?. # configuration; if(e){if(o?.path)for(const t of o.path) # if(e=e[t]?.fields,!e)return; return e[s.name]}} # computeLabel =(s,_,o)=>this._getTranslationEntry(this.hass.language,s,o)?.name # ||this._getTranslationEntry("en",s,o)?.name||s.name # computeHelper=(s,o) =>this._getTranslationEntry(this.hass.language,s,o)?.description # ||this._getTranslationEntry("en",s,o)?.description||"" # _filteredSchema=(opts,schema)=>schema.filter(e=>e.name in opts||e.required) # # i.e. the fallback is PER KEY, en is the second chance, and the raw key # name is the third. from supervisor.apps.validate import SCHEMA_APP_TRANSLATIONS # noqa: E402 from supervisor.store.data import _read_app_translations # noqa: E402 TRANSLATIONS = _read_app_translations(Path("/w")) show("Supervisor's OWN translation loader accepts both files - a file that " "fails SCHEMA_APP_TRANSLATIONS is DROPPED with a log warning and the tab " "silently goes back to raw keys, so 'it is on disk' is not the question", set(TRANSLATIONS) == {"en", "nl"}, sorted(TRANSLATIONS)) ui = UiOptions(None)(schema) print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order " "(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE " "SCHEMA BLOCK'S and not the options block's)") # --------------------------------------------------------------------------- # # ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN. # # UX-01 AC 3 said the visible count would drop to ~15, with the rest behind # HA's "show unused optional configuration options" toggle. The line above has # printed the refutation since the day it was written - 71, not 15 - and PRINTED # IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while # displaying the number that contradicted the criterion it was evidence for. # That is the defect, not the count. # # THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of # this section and replayed rather than paraphrased: # # _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts # || e.required) # # `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's # stored blob after `options:` has been deep-merged into it - so a key is # hideable only when it is BOTH absent from `options:` AND not required. # # WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements: # # 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks # `required` on every non-`?` key, and the filter above shows a required # key UNCONDITIONALLY. So the floor - what the tab renders with `options:` # EMPTIED ALTOGETHER, which is the most hiding that is even expressible - # is the required count below. It is nowhere near 15. No arrangement of # `options:` can reach the target; the criterion was never satisfiable. # # 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key # absent from `options:` has nothing for the Supervisor to merge into an # existing box's stored blob. That is exactly the `delivery_hold_s` # mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob # cannot contain resolves to its `options:` default instead of failing # validation, and moving it out of `options:` is precisely how you get the # exit 78 that mechanism exists to prevent. Worse, it would promote the 27 # unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4 # ledgers into the LOAD-BEARING source of those defaults on a live install # - which is the CFG-03 defect the same card's AC 4 exists to police. # # So: four fewer clicks, bought with the upgrade path and with the defaults # pin. The decision is written up in # DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md # and the three assertions below are what keeps it a decision rather than an # omission - if anyone empties `options:` to shorten the form, this goes red. _hideable = sorted(set(schema) - set(defaults)) _required = [n["name"] for n in ui if n.get("required")] def _filtered(opts): """The frontend's own `_filteredSchema`, replayed on this manifest.""" return [n for n in ui if n["name"] in opts or n.get("required")] show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so " f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 " f"is not partially met, it is unstarted. Hideable: {_hideable}", not _hideable and len(schema) == len(defaults)) show(f"...so replaying the frontend's own _filteredSchema on the served options " f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render", len(_filtered(defaults)) == len(ui)) # ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read # `len(_required) > 15` while the prose said 59 - so a manifest change dropping # the required count to 20 would have passed while the sentence beside it stayed # wrong. That is the same shape as the count this repo already fixed in # `test_translations.py`: a number displayed but not graded. # # So the expected value is computed from config.yaml by Supervisor's OWN rule - # a key is optional iff its schema string ends in `?`, plus `inverters`, which # `UiOptions` marks optional because it is a list - and the assertion is an # EQUALITY against that. Typing 59 here would rot the first time a key is added. _OPTIONAL = {k for k, v in schema.items() if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"} _EXPECT_REQ = len(schema) - len(_OPTIONAL) show(f"...and that floor is DERIVED: {len(schema)} schema keys minus " f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is " f"{_EXPECT_REQ}, and Supervisor's own required flag agrees", _EXPECT_REQ == len(_required)) # ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is # deliberately NOT enough on its own: both sides read the same `?` convention, # so marking a key optional moves BOTH and it stays green. That is correct - it # makes the printed number impossible to leave stale - but it grades Supervisor # agreeing with us, not the thing AC 3 was withdrawn for. # # AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If # enough keys ever became optional that the floor approached 15, the withdrawal # stops being true and this file should say so rather than keep printing a # sentence that has quietly become wrong. _AC3_ASKED = 15 show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} " f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever " f"goes red the withdrawal needs re-arguing, not re-asserting", _EXPECT_REQ > 2 * _AC3_ASKED) # ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required` # flag rather than a count typed here. `options:` is emptied and NOTHING else # varies, so the answer cannot be about anything but the merge. show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is " f"expressible - the tab still renders {len(_filtered({}))} required " f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is " f"withdrawn as unsatisfiable, not deferred.", len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ) def _entry(lang, name, path=()): node = TRANSLATIONS.get(lang, {}).get("configuration") if not node: return None for step in path: node = (node.get(step) or {}).get("fields") if not node: return None return node.get(name) def _label(lang, name, path=()): for lg in (lang, "en"): e = _entry(lg, name, path) if e and e.get("name"): return e["name"], lg return name, "RAW KEY" def _helper(lang, name, path=()): for lg in (lang, "en"): e = _entry(lg, name, path) if e and e.get("description"): return e["description"], lg return "", "NONE" # ⚠️ EVERY FIELD, PRINTED. This is the transcript the card asks for: what the # owner's Supervisor puts on the screen, not a claim that it would look nice. for lang in ("en", "nl"): print(f"\n --- as rendered at hass.language == {lang!r} " f"(the owner's HA runs Europe/Brussels) ---") for node in ui: lbl, src = _label(lang, node["name"]) hlp, hsrc = _helper(lang, node["name"]) flag = "" if src == lang else f" [name from {src}]" print(f" {node['name']:28s} -> {lbl}{flag}") print(f" {'':28s} {hlp[:150]}{'...' if len(hlp) > 150 else ''}") for sub in node.get("schema", ()): slbl, ssrc = _label(lang, sub["name"], (node["name"],)) print(f" · {sub['name']:24s} -> {slbl}" + ("" if ssrc == lang else f" [{ssrc}]")) _raw = [n["name"] for n in ui if _label("en", n["name"])[1] == "RAW KEY"] show("NOT ONE top-level field falls through to its raw key name in `en` - " f"which is what the tab rendered for all {len(ui)} of them before this " f"card, because there was no translations/ directory at all. Raw: {_raw}", not _raw) _nohelp = [n["name"] for n in ui if not _helper("en", n["name"])[0]] show("...and not one is left without a description, which is the half that " f"says WHETHER TO TOUCH IT. Missing: {_nohelp}", not _nohelp) _nl_missing = [n["name"] for n in ui if _label("nl", n["name"])[1] != "nl"] show("...and every one of them is translated in `nl` too, so nothing on the " f"owner's own screen falls back to English. Falling back: {_nl_missing}", not _nl_missing) # ⚠️ THE HONEST HALF, AND IT IS A NEGATIVE RESULT. `inverters` is a # list-of-objects, so UiOptions gives it type "schema" WITH multiple: True, and # `_convertSchemaElement` sends exactly that case to a SELECTOR rather than to a # nested ha-form: # # if("schema"===t.type&&!t.multiple) return {...type:"expandable"...} <- not us # if("schema"===t.type){const o={};for(const a of t.schema) # o[a.name]={required:a.required,selector:...}; return {object:{multiple:t.multiple,fields:o}}} # # and ha-selector-object labels those fields with # # computeLabel=e=>{const t=this.selector.object?.translation_key; # if(this.localizeValue&&t){...} # return this.selector.object?.fields?.[e.name]?.label||e.name} # # `_convertSchemaElementToSelector` sets neither `label` nor `translation_key`, # so ON THIS FRONTEND the per-entry keys inside the repeatable section render as # `setpoint_entity`, `max_w`, `bms_soc_floor` - RAW - however well they are # translated here. The `fields:` block is still shipped and still validated, # because it is the shape Supervisor's own schema defines for exactly this and # it is what a frontend that wires `label` through would consume; but this card # must not claim it reaches the operator today, and the count below is asserted # so that the day it changes, this transcript says so. _multi = [n for n in ui if n.get("multiple") and n.get("type") == "schema"] show("`inverters` is rendered as a REPEATABLE SECTION (type schema, multiple), " "which is CFG-04's measurement re-confirmed", [n["name"] for n in _multi] == ["inverters"], json.dumps(_multi)[:200]) show("⚠️ MEASURED AND NEGATIVE: this frontend gives that section's per-entry " "keys to ha-selector-object, which labels them `fields[name].label || " "name` - and the converter sets no `label` and no `translation_key`. So " "the per-entry translations below are SHIPPED AND VALIDATED but do NOT " "reach the screen on 20250925.1. The top-level `inverters` label and " "description DO.", bool(_multi) and all("label" not in s and "translation_key" not in s for n in _multi for s in n.get("schema", ())), "per-entry keys as rendered today: " + ", ".join(s["name"] for n in _multi for s in n.get("schema", ()))) # ⚠️ AND THE HAND PARSER CI USES IS GRADED AGAINST THIS LOADER, KEY BY KEY. # test_translations.py runs in the add-on's CI image, which has no PyYAML, so it # parses these files with tools/translations_lint.py. A hand parser that # disagrees with the real loader is a check whose data cannot reach the failure # it names, so the two are compared here - where both are available - rather # than assumed to agree. sys.path.insert(0, "/probe") import translations_lint as _tl # noqa: E402 _mismatch = [] for lang in ("en", "nl"): hand = _tl.parse_translation_file(f"/w/translations/{lang}.yaml") real = {"configuration": TRANSLATIONS[lang].get("configuration", {})} # SCHEMA_APP_TRANSLATIONS fills `description: None` for an absent one; the # hand parser simply has no key. Normalise that ONE difference and nothing # else, so a real disagreement cannot hide behind the normalisation. def _norm(d): return {k: {kk: vv for kk, vv in v.items() if vv is not None and kk != "fields"} | ({"fields": _norm(v["fields"])} if v.get("fields") else {}) for k, v in d.items()} if _norm(hand.get("configuration", {})) != _norm(real["configuration"]): a, b = _norm(hand.get("configuration", {})), _norm(real["configuration"]) _mismatch += [f"{lang}:{k}" for k in sorted(set(a) | set(b)) if a.get(k) != b.get(k)] show("the hand parser CI uses (tools/translations_lint.py) returns EXACTLY " "what Supervisor's own loader returns, for both files, key by key and " f"string by string. Disagreements: {_mismatch}", not _mismatch) _cfg_text = Path("/w/config.yaml").read_text(encoding="utf-8") _probs = [] for lang in ("en", "nl"): _probs += _tl.audit( _cfg_text, {"configuration": TRANSLATIONS[lang].get("configuration", {})}, lang) 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) print("\n== I. MAINT-10: `maintenance_start_hour` now defaults to 19 ==") _mk = "maintenance_start_hour" ok_i, out_i = validate(defaults) show("the shipped `options:` block validates and carries 19", ok_i and out_i.get(_mk) == 19, "" if ok_i else out_i) # What the Supervisor hands the add-on is deepmerge(options:, stored). _stored10 = dict(defaults, **{_mk: 10}) ok_i2, out_i2 = validate(MERGER.merge(copy.deepcopy(defaults), _stored10)) show("a stored explicit 10 (what a blob saved before MAINT-10 holds) stays 10", ok_i2 and out_i2.get(_mk) == 10, "" if ok_i2 else out_i2) _absent = {k: v for k, v in defaults.items() if k != _mk} ok_i3, out_i3 = validate(MERGER.merge(copy.deepcopy(defaults), _absent)) show("a stored blob WITHOUT the key gets the new default, 19", ok_i3 and out_i3.get(_mk) == 19, "" if ok_i3 else out_i3) ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24})) show("the range is unchanged: 24 is refused (`at most 23`)", (not ok_i4) and "at most 23" in str(out_i4), out_i4) print("\n== J. OPT-17: several solar forecast entities per day ==") # The same question as §G, for `pv_forecast_entity` / # `pv_forecast_tomorrow_entity` (`str?` since 0.15.0). Each refusal is # varied from the accepted baseline and asserted on the key's name. # DECISIONS/OPT-17-several-forecast-entities.md. FC_STORED = {"pv_forecast_entity": "sensor.energy_production_today_remaining", "pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"} _fc_old = {k: v for k, v in defaults.items() if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")} _fc_base = MERGER.merge(copy.deepcopy(defaults), dict(_fc_old, **FC_STORED)) ok_j, out_j = validate(_fc_base) show("a 0.17.0 blob (single forecast keys set, no list keys) validates " "unchanged, and both new keys default to []", ok_j and out_j.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"] and out_j.get("pv_forecast_entities") == [] and out_j.get("pv_forecast_tomorrow_entities") == [], "" if ok_j else out_j) for _k in ("pv_forecast_entity", "pv_forecast_tomorrow_entity"): _s1 = dict(schema, **{_k: ["str"]}) ok_j1, out_j1 = validate_with(_s1, _fc_base) show(f"`{_k}` declared as a LIST refuses the string an install stores", (not ok_j1) and f"Invalid list for option '{_k}'" in str(out_j1), out_j1) _m1 = MERGER.merge(copy.deepcopy(dict(defaults, **{_k: []})), copy.deepcopy(FC_STORED)) ok_j2, out_j2 = validate_with(_s1, _m1) show("...and a `[]` default does not rescue it (list merge is override)", _m1[_k] == FC_STORED[_k] and not ok_j2, out_j2) _sm = dict(schema, **{_k: "match(.*)?"}) _ok_s, _ = validate_with(_sm, _fc_base) _ok_l, _ = validate_with(_sm, dict(_fc_base, **{_k: ["sensor.a", "sensor.b"]})) _ok_n, _ = validate_with(_sm, dict(_fc_base, **{_k: 417})) _ui_m = _ui_of(_sm, _k) show(f"...`match(.*)?` on `{_k}` takes a string AND a list, but also the " "integer 417, and renders as a plain text box (no second row)", _ok_s and _ok_l and _ok_n and _ui_m and _ui_m[0].get("multiple") is not True, f"string={_ok_s} list={_ok_l} integer-417={_ok_n} " f"ui={json.dumps(_ui_m)}") _two = ["sensor.fc_east", "sensor.fc_west"] ok_j3, out_j3 = validate(MERGER.merge(copy.deepcopy(defaults), dict( _fc_old, pv_forecast_entities=_two, pv_forecast_tomorrow_entities=_two))) show("⭐ SHIPPED: two-entity lists for today and tomorrow validate", ok_j3 and out_j3.get("pv_forecast_entities") == _two and out_j3.get("pv_forecast_tomorrow_entities") == _two, "" if ok_j3 else out_j3) for _k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"): ok_j4, out_j4 = validate({k: v for k, v in defaults.items() if k != _k}) show(f"...and the `options:` block with ONLY `{_k}` removed is refused " "by name, so its `[]` default is load-bearing", (not ok_j4) and f"Missing option '{_k}' in root" in str(out_j4), out_j4) _ui_j = _ui_of(schema, _k) show(f"...and `{_k}` renders as a repeatable string field", len(_ui_j) == 1 and _ui_j[0].get("multiple") is True and _ui_j[0].get("type") == "string", json.dumps(_ui_j)) _flat_j = {k: v for k, v in schema.items() if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")} try: _rj = vol.Schema(vol.All(dict, AppOptions( None, _flat_j, cfg["name"], "local_goodwe_controller")))( MERGER.merge(copy.deepcopy(defaults), dict( FC_STORED, pv_forecast_entities=_two))) show("a DOWNGRADE to 0.17.0's schema drops the list keys and keeps the " "single ones", "pv_forecast_entities" not in _rj and _rj.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"]) except vol.Invalid as e: show("a DOWNGRADE to 0.17.0's schema drops the list keys", False, str(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 ==")