"""UX-02: reading and writing THIS add-on's own options, through the Supervisor. WHAT THIS IS. An add-on may read and write its own options. That is not a guess: `supervisor/api/middleware/security.py` in `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` - the version this site runs - matches an installed app's own token against api_bypass = ^(?:|/addons/self/(?!security|update)[^/]+ |/addons/self/options/config|/info|/services.* |/discovery.*|/auth)$ BEFORE it looks at `access_hassio_api` or at any role, so `GET /addons/self/info`, `POST /addons/self/options` and `POST /addons/self/restart` need no `hassio_role` at all. tools/supervisor_selfapi_probe.py RUNS those compiled patterns against these exact paths rather than re-reading the regex, because a bypass list is precisely the kind of thing that is read optimistically. ⚠️ AND THE PROBE FOUND ONE THE CARD DID NOT PREDICT, WHICH IS WHY IT WAS RUN. `/addons/self/options/validate` - Supervisor's OWN options validator - has TWO path segments after `self`, so `[^/]+` does not match it and it is not in the list of literals either. An add-on cannot call it. That is not a problem for this card, because the Supervisor's validator only knows the schema (types and ranges) and this panel's whole reason to exist is the CROSS-FIELD rules it structurally cannot express - but it does mean there is exactly one validator reachable from here, `app/validate.py`, which is the one we want. --- THE THREE THINGS THIS MODULE REFUSES TO DO ------------------------------ 1. ⚠️ IT NEVER WRITES A BLOB THE SHIPPED VALIDATOR REFUSED. Not "warns and writes"; does not write. The reason is specific to an INGRESS panel and it is the failure mode that makes this card dangerous: an invalid blob plus a restart is exit 78, and an add-on that is not running is not serving the panel either. The operator would be left with no way back except the Supervisor UI - the surface this card exists because it is hard to read. So the refusal happens here, before the POST, and the edits stay on screen. 2. ⚠️ IT NEVER OVERWRITES A BLOB THAT MOVED UNDERNEATH IT. The Supervisor's Configuration tab CANNOT be removed (see DECISIONS/UX-02-two-config- surfaces.md), so there are two editors of one blob, and `POST /addons/self/options` REPLACES the stored user options - measured, not assumed: `App.options`'s setter is `self.persist[ATTR_OPTIONS] = deepcopy( value)`, and the effective options are `deepmerge(config defaults, persist)`. A key omitted from the POST therefore falls back to config.yaml's DEFAULT rather than to its previous value. This is the same shape of hazard as two writers on one actuator, and it gets the same discipline. `Controller.write_refusal()` (SAFETY-28) says: A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS TICK, FOR THIS UNIT. Here: A BLOB REACHES THE SUPERVISOR ONLY IF IT WAS DERIVED FROM THE BLOB THAT IS STORED RIGHT NOW. The read hands out a `fingerprint()`, the save hands it back, and the save re-reads and compares before writing. A change made in the other tab in the meantime is a REFUSAL with the changed keys named, not a silent clobber. ⚠️ THE COMPARISON IS RE-READ IMMEDIATELY BEFORE THE WRITE, not taken from the page load, so the window in which the other surface can slip in is one HTTP round trip rather than however long the operator spent typing. 3. ⚠️ IT NEVER RESTARTS AS A SIDE EFFECT OF SAVING. Applying options costs a restart, and `Controller.shutdown()` commands 0 W on every inverter on the way out (main.py, "shutting down - commanding 0 W on every inverter"). That is a real, if brief, loss of control of the battery, so it is its own deliberate action with its own confirmation - `restart()` below requires an explicit token in the request body, so no stray POST can spend it. --- WHAT THIS MODULE MAY NOT GROW INTO -------------------------------------- ⚠️ NOTHING HERE COMMANDS AN INVERTER, CLEARS A LATCH, OR REACHES A SAFETY MECHANISM. The panel's configuration half talks to the Supervisor about a JSON blob and to `validate.py` about whether that blob is startable; the only actuator-shaped verb in the file is `restart()`, which restarts THIS ADD-ON and whose 0 W is the existing, documented wind-down. Adding "and also set the setpoint while we are here" would put a second writer on the actuator through the config surface, which is the non-goal this add-on is built around. """ import asyncio import hashlib import json import logging import os import aiohttp from .configui import changed_keys from .inverter import build_roster from .validate import validate_startup_config, validate_unit_ranges _LOG = logging.getLogger("goodwe.selfconfig") SUPERVISOR_API = "http://supervisor" SELF_INFO = f"{SUPERVISOR_API}/addons/self/info" SELF_OPTIONS = f"{SUPERVISOR_API}/addons/self/options" SELF_RESTART = f"{SUPERVISOR_API}/addons/self/restart" # The word the page has to send with a restart request. It is not a password - # ingress means Home Assistant has already authenticated the caller - it is an # INTENT token, so that a reload, a retry or a stray fetch cannot spend a # control interruption on the operator's behalf. RESTART_CONFIRM = "restart" # ⚠️ LONGER THAN hass.py's 10 s, AND FOR THE OPPOSITE REASON. `hass.py` would # rather abandon a write and re-issue it on the next tick than hold the control # loop. Nothing here is on the control loop - these calls are made by an # operator standing at a screen - and a Supervisor busy with a backup can take # a while to answer. Abandoning a `POST /addons/self/options` early would leave # the panel unsure whether the blob was stored, which is the one state this # module must never be in. TIMEOUT_S = 30.0 class SupervisorUnavailable(Exception): """The Supervisor could not be reached, or answered with an error.""" class NotConfirmed(Exception): """A control interruption was asked for without the intent token. Its own exception rather than a `SaveRefused`, because it is not a refusal ABOUT a configuration - nothing was validated and nothing was compared. It means the request did not say what it was asking for. """ class SaveRefused(Exception): """The save did not happen, and NOTHING WAS WRITTEN. `kind` is `"invalid"` (the shipped validator refused the blob) or `"stale"` (the stored blob changed underneath). `problems` carries the validator's own sentences, byte for byte; `changed` names the keys that moved. """ def __init__(self, kind, problems=(), changed=(), stored=None): self.kind = kind self.problems = list(problems) self.changed = list(changed) self.stored = stored super().__init__(f"save refused ({kind})") def canonical(options) -> str: """The one textual form of an options blob, for identity only. Sorted keys, no insignificant whitespace, `ensure_ascii=False` so an entity id with a non-ASCII character is one character rather than an escape - the string is hashed, never displayed, so the only property that matters is that equal blobs produce equal text and different blobs do not. """ return json.dumps(options, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str) def fingerprint(options) -> str: """The compare-and-swap token for one options blob. ⚠️ A DIGEST, NOT A VERSION NUMBER, because there is no version to read. The Supervisor exposes no revision or ETag for an add-on's options, so the only thing the panel can hold onto between reading and writing is the CONTENT it read. That is enough for what this is for: the question is not "how many times has this changed" but "is what I am about to overwrite still what I was shown". Identical content is by definition safe to overwrite. """ return hashlib.sha256(canonical(options).encode("utf-8")).hexdigest() class SelfConfig: """The add-on's own options, read and written through the Supervisor.""" def __init__(self, session: aiohttp.ClientSession, token: str | None = None): self.session = session self.token = token or os.environ.get("SUPERVISOR_TOKEN", "") @property def _headers(self) -> dict: return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"} async def _call(self, method, url, payload=None): try: async with self.session.request( method, url, headers=self._headers, json=payload, timeout=aiohttp.ClientTimeout(total=TIMEOUT_S), ) as resp: text = await resp.text() if resp.status >= 400: # The Supervisor's own message is carried through unedited: # `{"result": "error", "message": "..."}`. A rewritten # error is a second opinion about somebody else's failure. try: detail = (json.loads(text) or {}).get("message") or text except ValueError: detail = text raise SupervisorUnavailable( f"{method} {url} -> HTTP {resp.status}: " f"{str(detail)[:400]}") try: body = json.loads(text) if text else {} except ValueError as err: raise SupervisorUnavailable( f"{method} {url} -> unreadable answer: {err}") from None return body.get("data", body) except (aiohttp.ClientError, asyncio.TimeoutError) as err: raise SupervisorUnavailable(f"{method} {url} -> {err}") from None async def info(self) -> dict: """`GET /addons/self/info`. ⚠️ THREE OF THIS PANEL'S FOUR INGREDIENTS COME OUT OF THIS ONE CALL, and that is the reason the panel owns no copy of any of them. Measured on 2026.08.0 (`supervisor/api/apps.py:info_data`): `options` - `App.options`, i.e. deepmerge(config.yaml defaults, the stored user options). Every key is present, which is what makes a whole-blob read-modify-write safe. `schema` - `App.schema_ui`, i.e. `UiOptions(...)(config.yaml ["schema"])`. The field list, types, ranges and enums, in config.yaml's own order. `translations` - `App.translations`, i.e. Supervisor's own `_read_app_translations()` over translations/*.yaml, which is UX-01's work reaching a second surface for free. The fourth ingredient is `app/validate.py`, and it is not fetched. """ return await self._call("GET", SELF_INFO) async def write_options(self, options: dict) -> None: """`POST /addons/self/options` - REPLACES the stored user options. ⚠️ CALL THIS ONLY THROUGH `save()`. On its own it is the unguarded write: no validation, no compare-and-swap. It is a separate method so that the gate above it can be read - and tested - as a gate. """ await self._call("POST", SELF_OPTIONS, {"options": options}) async def restart(self, confirm: str) -> None: """`POST /addons/self/restart`. Costs a control interruption. The add-on's own wind-down runs first: `Controller.shutdown()` commands 0 W on every inverter before the process exits. So this is not an abrupt loss of control, it is a deliberate one - but it IS one, and the page says so before the operator presses it. """ if confirm != RESTART_CONFIRM: raise NotConfirmed( "a restart interrupts control and must be confirmed") _LOG.warning("operator asked for a restart from the ingress panel - " "the wind-down commands 0 W on every inverter first") await self._call("POST", SELF_RESTART) async def problems_for(opts: dict, hass=None) -> list: """Every reason the SHIPPED validator would refuse to start on `opts`. ⚠️ THIS IS THE STARTUP GATE'S OWN COMPOSITION, NOT A SUBSET OF IT. The gate runs in TWO places in main.py and this is the union of both, which is what an operator meets: * `amain()` calls `validate_startup_config(opts, dev_min, dev_max)` UNCONDITIONALLY, before anything starts - so this does too, with the flat `setpoint_entity`'s device range when that entity was readable and None when it was not; * `run_control()` adds `validate_unit_ranges(roster, skip_entity=flat)` once every unit's own ESPHome has answered - so this does too, with the same `skip_entity`, so one inverter is not reported twice under two wordings. A panel that ran only half the gate would go green on a config that then exits 78 - which is worse than no panel, because the operator would have been told it was fine. ⚠️ AND THE DEVICE RANGES ARE READ, NOT INVENTED. `hass.limits()` is a READ of a Home Assistant state; nothing here writes anything to any device. When a device does not answer - ESPHome asleep, an entity that does not exist yet, or `hass=None` in a check - the range is None and the range rules are SKIPPED, exactly as they are at startup. That is the honest behaviour and it is also the conservative one: the panel then shows fewer refusals than the startup gate might, never more, so it cannot bless a config the gate refuses on a rating it could not see. It is stated on the page rather than hidden. `build_roster(opts, None)` builds the roster with NO Store, so this reads no latch and writes no state; it exists to give `validate_unit_ranges()` the same objects `main.py` gives it, rather than a second transcription of what counts as a roster entry. """ problems, _unread = await problems_and_ranges(opts, hass) return problems async def problems_and_ranges(opts: dict, hass=None): """`problems_for()`, plus WHICH setpoint entities did not report a range. ⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE OPERATOR CANNOT SEE. Two rules compare a configured ceiling against the device's own rating, and when the device is silent they are skipped rather than guessed at - so an unread range means the panel is showing FEWER refusals than the startup gate might, and it has to say so, naming the entity that did not answer rather than hinting at one. It is a separate function from `problems_for()` because the answer is about the CONFIGURATION UNDER TEST, not about the running roster: an operator who has just typed a new `setpoint_entity` must be told about THAT entity, and a route that asked the running controller instead would report on the old one. That was the first shape of this and it disagreed with itself. """ flat = str(opts.get("setpoint_entity", "") or "").strip() # ⚠️ THE ROSTER IS BUILT BEFORE THE VALIDATOR RUNS, AND IT PARSES NUMBERS. # `build_roster` reaches `inverter.py`'s `float(opts.get("max_w", 2000))`, # which raises on a value the validator has a SENTENCE for. Cleared the # `max_w` box and the panel answered HTTP 500 - not JSON, so the page said # "cannot reach the add-on", which was untrue - while `validate.py`'s # "max_w ... is not a number" was unreachable through the panel and reachable # at startup, where `main.py` validates before it composes. That made this # surface WEAKER than the startup gate for one field, which is the opposite # of what this module claims below. # # A malformed scalar therefore falls back to validating WITHOUT device # bounds. Those bounds come from the roster, and there is no roster; the # validator's own complaint about the unparseable value is the answer the # operator needs, and it is strictly more informative than a 500. try: units = build_roster(opts, None) except (TypeError, ValueError) as exc: # ⚠️ AND IT MUST NOT RETURN AN EMPTY LIST. An empty list means "no # problems", and the page enables Save on it. If the roster could not be # composed at all and the validator has nothing to say about why, that # disagreement is itself the finding - so it is reported rather than # swallowed. Returning `[]` here would turn a crash into a silent # acceptance, which is worse than the 500 this replaced. found = list(validate_startup_config(opts)) if not found: found = [f"the configuration could not be composed ({type(exc).__name__}: " f"{exc}), and the validator did not name a reason. This is a " f"disagreement between the two and should be reported."] return found, [] if hass is not None: async def _limits(unit): if unit.setpoint_entity: unit.dev_min, unit.dev_max, _ = await hass.limits( unit.setpoint_entity) await asyncio.gather(*(_limits(unit) for unit in units)) dev_min = dev_max = None for unit in units: if flat and unit.setpoint_entity.strip().lower() == flat.lower(): dev_min, dev_max = unit.dev_min, unit.dev_max break problems = list(validate_startup_config(opts, dev_min, dev_max)) problems += validate_unit_ranges(units, skip_entity=flat) unread = [unit.setpoint_entity for unit in units if unit.setpoint_entity and unit.dev_min is None and unit.dev_max is None] return problems, unread async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *, hass=None) -> dict: """Validate, compare-and-swap, then write. Refuse rather than half-do. Returns `{"fingerprint": }` on success. Raises `SaveRefused` with nothing written otherwise. THE ORDER IS DELIBERATE AND BOTH HALVES ARE PRE-WRITE GATES: 1. VALIDATE FIRST. It is local, it costs nothing, and its refusal is about what is on the operator's screen - so it is the answer they can act on without knowing anything about the other surface. 2. RE-READ AND COMPARE SECOND, immediately before the POST, so the window in which the Supervisor tab can slip a change past is one round trip rather than the length of an editing session. 3. Only then write. ⚠️ `expect` IS REQUIRED AND THERE IS NO "FORCE" DOOR. A caller that has not read the current blob has nothing to compare, and a save that skips the comparison is the clobber this gate exists to prevent. If the operator genuinely wants what the other surface wrote replaced, the page reloads, shows them what changed, and they save again from THAT - which is a decision made in front of the difference rather than through it. """ problems = await problems_for(proposed, hass) if problems: _LOG.warning("The settings page did not save, because of %d " "problems. The first: %s", len(problems), problems[0]) raise SaveRefused("invalid", problems=problems) info = await selfcfg.info() stored = info.get("options") or {} actual = fingerprint(stored) if actual != expect: changed = changed_keys(stored, proposed) _LOG.warning("The settings page did not save, because the settings " "changed while it was open (keys: %s).", ", ".join(changed) or "none this page also edited") raise SaveRefused("stale", changed=changed, stored=stored) await selfcfg.write_options(proposed) _LOG.info("options saved from the ingress panel; a restart is needed " "before they take effect") return {"fingerprint": fingerprint(proposed)}