Files
goodwe-addon/goodwe_controller/tools/supervisor_selfapi_probe.py
glenn schrooyenandClaude Opus 5 fdaf3fb54f
checks / unit (pull_request) Successful in 6m17s
UX-02: the validator's refusals, next to the field, before the save
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.

WHAT IT ADDS
  app/configui.py     the field model, built from `GET /addons/self/info`'s own
                      `schema` + `translations`. No option key, no label, no
                      rule of its own. UX-01's `Group · ` label prefix becomes a
                      real section heading, in whichever language resolved.
  app/selfconfig.py   the Supervisor conversation and the write gate.
  app/config_page.py  the page. Its longest function decides which HTML input a
                      type deserves; it contains no rule.
  app/web.py          five routes. Still the only file a reviewer has to read to
                      answer "does anything here reach the inverter".

THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
  1. A blob the SHIPPED validator refuses is never written - not warned about,
     not written. An invalid blob plus a restart is exit 78, and the panel is
     gone with the add-on that serves it.
  2. A blob that moved underneath is refused, not clobbered. Read hands out a
     sha256 of the canonical blob, save hands it back, and the stored blob is
     re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
     applied to configuration - "a blob reaches the Supervisor only if it was
     derived from the blob stored right now" - with the honest weakening stated
     in the decision record: a second writer is DETECTED, not made
     inexpressible.
  3. Every message is the validator's own, byte for byte. test_selfconfig.py
     part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
     under app/ to show validate.py is the only one that differs, and serves the
     real /config/validate route out of that copy in a subprocess. The new
     sentence arrives, filed under the key it names.
  4. Nothing on the surface commands an inverter or clears a latch. Saving does
     not restart: applying costs a control interruption - the wind-down commands
     0 W on every inverter - so that is a separate, confirmed button that says
     so first.

MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
  - the three self-paths are permitted, and `/addons/self/info` is admitted by
    the `default` role too while `options` and `restart` rest on the bypass
    ALONE. The probe's first draft claimed the role admitted none of the three
    and went red.
  - `/addons/self/options/validate` is NOT reachable from inside an add-on, so
    the only validator this panel can call is ours - which is the one with the
    cross-field rules.
  - a POST that omits a key does not preserve it: the key reverts to
    config.yaml's default. Hence whole-blob read-modify-write.
  - the Configuration tab CAN be removed, and doing so drops 0-of-71 options
    through and takes the add-on down. The card said it could not be; it stays
    by decision, which is a better reason.

DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.

Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

---

⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.

So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 21:38:31 +02:00

335 lines
18 KiB
Python

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