Files
goodwe-addon/goodwe_controller/app/selfconfig.py
T
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

373 lines
18 KiB
Python

"""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()
units = build_roster(opts, None)
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": <the new one>}` 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("panel REFUSED to save: %d configuration problem(s); "
"nothing written. 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("panel REFUSED to save: the stored options changed "
"underneath this page (keys: %s); nothing written",
", ".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)}