"""UX-02: can this add-on read and write its OWN options, and what comes back? ⚠️ WHY THIS SCRIPT EXISTS. The UX-02 card asserts that `GET /addons/self/info`, `POST /addons/self/options` and `POST /addons/self/restart` are permitted to an add-on with `hassio_api: true` and no special `hassio_role`, and it says - in its own words - "confirm this again in the PR by calling it, rather than citing this card". A permission read off a regex in somebody else's repository is a hypothesis; the whole configuration surface rests on it, so it is RUN here, against the compiled patterns out of the release image this site runs. It also answers the question the panel's design depends on next: WHAT the info response carries. app/configui.py builds the entire form from `schema` and `translations` and owns no copy of either, which is only sound if those two really are in the response and really are the loader's own output. Both are constructed here, out of the image, and this repository's own model is built from them. 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 UX-02 landed, and it is the same image tools/supervisor_shape_probe.py pins): docker run --rm \ -v "$PWD:/w:ro" -v "$PWD/app:/app:ro" -v "$PWD/tools:/probe:ro" \ --entrypoint python3 \ ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \ /probe/supervisor_selfapi_probe.py ⚠️ IT TOUCHES NOTHING, AND IT TOUCHES THE LIVE SITE LEAST OF ALL. It reads config.yaml and translations/, imports Supervisor's own modules and runs them in a throwaway container. It opens no socket, contacts no Supervisor, POSTs no options and restarts nothing. The add-on is live in a real house; the only honest way to measure this is against a copy of the software, not against it. ⚠️ WHAT IT CANNOT MEASURE, SAID OUT LOUD. It does not execute an HTTP request against a running Supervisor, because doing that would mean pointing it at the live one. So §A grades the ACCESS DECISION (the compiled patterns, run against the exact paths) and §B grades the RESPONSE KEYS (the constants `info_data` builds its dict from), rather than a captured response body. What is NOT verified here is the wiring between the two - that the handler which builds that dict is the one mounted at that path. That is read, not run, and this is the sentence saying so. """ import copy import json import sys sys.path.insert(0, "/usr/src/supervisor") sys.path.insert(0, "/") # app/ is mounted at /app import voluptuous as vol # noqa: E402 from pathlib import Path # noqa: E402 from supervisor.utils.yaml import read_yaml_file # noqa: E402 from supervisor.apps.validate import SCHEMA_APP_CONFIG # noqa: E402 from supervisor.apps.options import AppOptions, UiOptions # noqa: E402 from supervisor.apps.app import _OPTIONS_MERGER # noqa: E402 from supervisor.store.data import _read_app_translations # noqa: E402 from supervisor.const import (ATTR_OPTIONS, ATTR_SCHEMA, # noqa: E402 ATTR_TRANSLATIONS) from supervisor.api.middleware.security import _V1_PATTERNS # noqa: E402 from app import configui # noqa: E402 FAILED = [] def show(title, ok, detail=""): print(f" {'PASS' if ok else 'FAIL'} {title}") if detail: print(" " + str(detail)[:400]) if not ok: FAILED.append(title) # --------------------------------------------------------------------------- # print("== A. THE PERMISSION, RUN RATHER THAN READ ==") # ⚠️ THE PATTERNS ARE SUPERVISOR'S OWN COMPILED OBJECTS, imported, not retyped. # `token_validation()` tests `api_bypass` against `request.path` for an # installed app BEFORE it consults `access_hassio_api` or any role, so a match # here is the whole permission question for these paths. for path, want in (("/addons/self/info", True), ("/addons/self/options", True), ("/addons/self/restart", True), ("/addons/self/options/config", True), # The two the pattern explicitly excludes, asserted so that # a future widening of it does not go unnoticed here. ("/addons/self/security", False), ("/addons/self/update", False)): got = bool(_V1_PATTERNS.api_bypass.match(path)) show(f"{path:32s} bypass={got}", got == want) # ⚠️ THE ONE THE CARD DID NOT PREDICT, AND IT IS A NEGATIVE RESULT WORTH HAVING. # Supervisor has its OWN options validator at `/addons/{app}/options/validate`. # It is two path segments after `self`, so `[^/]+` cannot match it, and it is # not one of the listed literals either. An add-on cannot call it. That does # not hurt this card - the Supervisor validator knows only types and ranges, and # the whole reason this panel exists is the cross-field rules it structurally # cannot express - but "there is exactly one validator reachable from inside the # add-on, and it is ours" is a fact the design should rest on knowingly. _val = bool(_V1_PATTERNS.api_bypass.match("/addons/self/options/validate")) show("/addons/self/options/validate is NOT reachable from an add-on - so the " "ONLY validator this panel can call is app/validate.py, which is the one " "with the cross-field rules", not _val, f"bypass={_val}") # ⚠️ AND WHICH OF THE TWO MECHANISMS IS CARRYING EACH PATH - MEASURED, BECAUSE # THE FIRST DRAFT OF THIS SECTION GOT IT WRONG. It asserted that the `default` # role admits none of the three, and that FAILED: the default role's pattern is # `^(?:|/.+/info)$`, which matches `/addons/self/info` perfectly well. So the # split is: # # /addons/self/info - admitted by BOTH the bypass and the default role # /addons/self/options - the bypass ONLY # /addons/self/restart - the bypass ONLY # # i.e. the two WRITING paths rest entirely on the bypass list, and if it were # narrowed this panel could still read its own options and could no longer save # them. That is a more useful thing to know than the tidier claim that was # asserted first, and it is only known because the claim was run. _default = _V1_PATTERNS.role_access["default"] show("`/addons/self/info` is admitted by the DEFAULT role as well as by the " "bypass (`/.+/info`) - so reading has two independent licences", bool(_default.match("/addons/self/info"))) show("...while `/addons/self/options` and `/addons/self/restart` are admitted " "by the BYPASS ONLY. The two writing paths rest on that list alone", not _default.match("/addons/self/options") and not _default.match("/addons/self/restart")) # --------------------------------------------------------------------------- # print("\n== B. WHAT `GET /addons/self/info` IS BUILT FROM ==") # `AppsAPI.info_data()` builds its dict with these constants as keys. Their # VALUES are what the panel reads off the JSON, so they are asserted here # rather than typed into app/selfconfig.py as a guess. show("the response keys the panel reads are named `options`, `schema` and " "`translations`", (ATTR_OPTIONS, ATTR_SCHEMA, ATTR_TRANSLATIONS) == ("options", "schema", "translations"), f"{ATTR_OPTIONS} / {ATTR_SCHEMA} / {ATTR_TRANSLATIONS}") # --------------------------------------------------------------------------- # print("\n== C. `schema` - what App.schema_ui actually is, for THIS config.yaml ==") raw = read_yaml_file(Path("/w/config.yaml")) cfg = SCHEMA_APP_CONFIG(raw) schema, defaults = cfg["schema"], cfg["options"] ui = UiOptions(None)(schema) show(f"UiOptions renders every key config.yaml declares ({len(ui)})", len(ui) == len(schema), f"schema keys {len(schema)}, ui nodes {len(ui)}") # ⚠️ THE NAME IS THE MEASUREMENT. A NUMERIC range comes back as `lengthMin` / # `lengthMax` - which reads like a string length and is not - and app/configui.py # copies those two names straight through into the form's `min`/`max`. Guessing # `valueMin` would have produced a form with no bounds at all and no error # anywhere. _maxw = [n for n in ui if n["name"] == "max_w"][0] show("a numeric range arrives as lengthMin/lengthMax (NOT valueMin/valueMax), " "which is why app/configui.py reads those two names", _maxw.get("lengthMin") == 100 and _maxw.get("lengthMax") == 5000, json.dumps(_maxw)) _sel = [n for n in ui if n["name"] == "meter_source"][0] show("an enum arrives as type `select` with its `options` listed", _sel.get("type") == "select" and "homewizard_local" in _sel.get("options", []), json.dumps(_sel)) _inv = [n for n in ui if n["name"] == "inverters"][0] show("the roster arrives as type `schema` + `multiple`, carrying its per-entry " "fields - which is what the panel's roster editor draws cards from", _inv.get("type") == "schema" and _inv.get("multiple") is True and [s["name"] for s in _inv["schema"]][:3] == ["name", "setpoint_entity", "max_w"], json.dumps([s["name"] for s in _inv["schema"]])) # --------------------------------------------------------------------------- # print("\n== D. `options` - and what a POST to /addons/self/options REPLACES ==") # ⚠️ THE HAZARD THE COMPARE-AND-SWAP EXISTS FOR, DEMONSTRATED. `App.options` is # `deepmerge(config.yaml defaults, the STORED user options)` and the setter is # `persist[options] = deepcopy(value)` - a REPLACEMENT. So a key left out of a # POST does not keep its previous value; it falls back to the config.yaml # DEFAULT. Both halves are run below on real objects. _persist_before = {"max_w": 4200, "gain": 0.42} _effective_before = _OPTIONS_MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(_persist_before)) show("stored user options merge OVER the config.yaml defaults, so every key is " "present in what an add-on reads back", _effective_before["max_w"] == 4200 and set(_effective_before) == set(defaults), f"max_w={_effective_before['max_w']} gain={_effective_before['gain']}") _persist_after = {"gain": 0.42} # a POST that simply omits max_w _effective_after = _OPTIONS_MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(_persist_after)) show("...and a POST that OMITS a key does not preserve it - the key reverts to " "config.yaml's default. That is why this panel posts the WHOLE blob it " "read, and why it refuses to post one built on a stale read", _effective_after["max_w"] == defaults["max_w"] != 4200, f"omitted max_w -> {_effective_after['max_w']} " f"(default {defaults['max_w']}, was 4200)") # The blob the panel would actually POST: the full merged options, unchanged. # If Supervisor refused that shape the read-modify-write would be unusable. _v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"], "local_goodwe_controller"))) try: _validated = _v(copy.deepcopy(_effective_before)) show("the WHOLE merged blob - what this panel sends back - is accepted by " "Supervisor's own options validator", True) except vol.Invalid as err: _validated = None show("the WHOLE merged blob is accepted", False, str(err)) # ⚠️ AND WHETHER IT COMES BACK BYTE-IDENTICAL, because the panel tells the # operator which settings are "stored but not running" by comparing the stored # blob with `/data/options.json`. Supervisor writes that file through # `schema.validate(self.options)`, so any COERCION in that step would show up # as a spurious difference on every page load. if _validated is not None: _drift = sorted(k for k in set(_validated) | set(_effective_before) if _validated.get(k) != _effective_before.get(k)) show("...and validation does not COERCE it, so `stored vs running` compares " "like with like and shows a difference only when there is one. " f"Keys changed by validation: {_drift}", not _drift) # --------------------------------------------------------------------------- # print("\n== E. `translations` - UX-01's labels, reaching a SECOND surface ==") translations = _read_app_translations(Path("/w")) show("Supervisor's own loader accepts both files, so `info['translations']` " "carries them", set(translations) == {"en", "nl"}, sorted(translations)) for lang, groups_expected in (("en", 7), ("nl", 7)): model = configui.field_model(ui, translations, lang) names = [g["name"] for g in model["groups"]] show(f"[{lang}] this repository's own field_model() turns those into " f"{len(names)} real section headings out of UX-01's `Group · ` prefix " f"- which the Supervisor's flat list cannot render at all", len(names) == groups_expected and not model["ungrouped"], f"{names} ungrouped={model['ungrouped']}") _raw = [f["key"] for g in model["groups"] for f in g["fields"] if f["label_lang"] == "raw"] show(f"[{lang}] ...and not one field falls back to its raw key name. " f"Raw: {_raw}", not _raw) _nohelp = [f["key"] for g in model["groups"] for f in g["fields"] if not f["help"]] show(f"[{lang}] ...and not one is left without the sentence that says " f"whether to touch it. Missing: {_nohelp}", not _nohelp) # ⚠️ THE ONE PLACE THIS PANEL IS STRICTLY BETTER THAN THE CONFIGURATION TAB ON # THE SAME DATA, and shape_probe §F is the measurement that makes it a claim # rather than a boast: on frontend 20250925.1 the roster's per-entry keys go to # `ha-selector-object`, which labels them `fields[name].label || name`, and the # converter sets no label - so they render RAW there, however well UX-01 # translated them. Here they are read out of the same `fields:` block and shown. _model_en = configui.field_model(ui, translations, "en") _roster = [f for g in _model_en["groups"] for f in g["fields"] if f["key"] == "inverters"][0] _sub = [(s["key"], s["label"], s["label_lang"]) for s in _roster["fields"]] show("the roster's PER-ENTRY keys resolve to their UX-01 labels here, which is " "exactly what the Supervisor's Configuration tab renders raw on frontend " "20250925.1 (shape_probe §F)", all(lang == "en" for _, _, lang in _sub) and len(_sub) == 7, json.dumps([(k, l) for k, l, _ in _sub])) # --------------------------------------------------------------------------- # print("\n== F. the anchor list is the SCHEMA's keys, and collisions are real ==") # app/configui.py files each validator sentence under every option key it # NAMES, with word boundaries. Three of this schema's keys are suffixes of one # another, so a naive `in` test would file every per-unit refusal under the site # key as well. Run against the real key list rather than a hand-made one. _keys = sorted(defaults) _collide = [k for k in _keys if any(o != k and o.endswith("_" + k) for o in _keys)] show(f"config.yaml really does contain keys that are suffixes of other keys, " f"so the boundary matching is load-bearing: {_collide}", bool(_collide)) _by, _un = configui.problems_by_field( ["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"], _keys) show("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under " "`sign_disagree_w`", sorted(_by) == ["max_w", "unit_sign_disagree_w"] and not _un, json.dumps(sorted(_by))) # --------------------------------------------------------------------------- # print("\n== G. COULD THE SUPERVISOR'S CONFIGURATION TAB BE REMOVED? ==") # ⚠️ THE CARD SAYS IT CANNOT BE. THAT TURNS OUT TO BE THE RIGHT DECISION FOR # THE WRONG REASON, AND THE DIFFERENCE MATTERS - a constraint you believe is # imposed is one nobody re-examines, while a choice is something a decision # record has to justify. So it was RUN. # # The tab renders `App.schema_ui`, which is `UiOptions(...)(data["schema"])`. # Delete `schema:` from config.yaml and there is nothing to render. Supervisor # ACCEPTS that manifest - the key is optional and defaults to `{}` - so the tab # really would go away. # # What goes away with it is the add-on. `AppOptions` DROPS every option that # the schema does not declare (one "Option 'x' does not exist in the schema" # warning per key, and nothing else), so `App.options` validates down to the # EMPTY DICT, `write_options()` writes `{}` to /data/options.json, and # `load_options()` hands the controller nothing. The startup gate then refuses # it for having no actuator - exit 78 - and the ingress panel this card builds # is not running either, because the add-on serving it is not. # # So: removable, at the cost of every setting on the site and the add-on with # them. That is the measured reason DECISIONS/UX-02-two-config-surfaces.md # keeps the tab, and it is a much better reason than "the platform will not # let us". _noschema = {k: v for k, v in raw.items() if k != "schema"} try: _cfg2 = SCHEMA_APP_CONFIG(_noschema) _accepted, _detail = True, repr(_cfg2.get("schema")) except vol.Invalid as err: _cfg2, _accepted, _detail = None, False, str(err) show("a manifest with NO `schema:` block is ACCEPTED by Supervisor, and its " "`schema_ui` is empty - so the Configuration tab is removable after all", _accepted and _cfg2.get("schema") == {} and UiOptions(None)(_cfg2["schema"]) == [], _detail) if _accepted: _v2 = vol.Schema(vol.All(dict, AppOptions(None, _cfg2["schema"], _cfg2["name"], "local_x"))) try: _out2 = _v2(copy.deepcopy(_cfg2["options"])) _kept = len(_out2) except vol.Invalid as err: _kept = f"refused: {err}" show("⚠️ ...and EVERY OPTION IS DROPPED WITH IT. Options not declared in " "the schema are discarded with one log warning each, so the add-on " "would start on an empty configuration, be refused by its own startup " "gate for having no actuator, and take the ingress panel down with " "it. That is why the tab stays", _kept == 0, f"options surviving an empty schema: {_kept} " f"of {len(defaults)}") # --------------------------------------------------------------------------- # print() if FAILED: print(f"== {len(FAILED)} CASE(S) FAILED ==") for title in FAILED: print(" - " + title) sys.exit(1) print("== all cases passed ==")