The Setup tab draws the Installer view's page model one step at a time, from each field's `wizard` step in app/uilayout.py: meter (with the kettle test), inverters and batteries, solar (optional), prices, peak limit (battery modes: "comes in a later update", OPT-11), check and start. It reuses P2's controls and saves through /config/validate and /config/save. No route was added. - First run: /config/model says `setup_needed` when the stored options name no power request entity, or on a new install (no `setup_done` and no `auto` in the Store, control not started). The page opens #setup only when opened with no route. The wizard's save writes `setup_done`. - Kettle test: GET ha/entities?entity= reads one entity now, in W. The change decides: up keeps meter_invert off, down turns it on; a net or import/export meter that goes down blocks the step with what to check. - Last step: validate.setup_checks() beside the refusals: the meter reads, each power request entity exists in HA (bad if not, SAFETY-77), battery levels and power with direction, solar sign, prices. Saves with shadow_mode on by default, then a confirmed restart naming the 0 W. - P2 test finding (a): a stale save lists only the keys the other surface moved (the blob handed out on open, by fingerprint, against stored now). - test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard can save; all accepted by Supervisor 2026.08.0 AppOptions (offline). - DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
422 lines
21 KiB
Python
422 lines
21 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 . import messages
|
|
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,
|
|
moved=None):
|
|
self.kind = kind
|
|
self.problems = list(problems)
|
|
self.changed = list(changed)
|
|
self.stored = stored
|
|
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
|
|
# against stored-now - when the caller knows what it opened. None
|
|
# when it does not. `changed` also holds the operator's own edits.
|
|
self.moved = None if moved is None else list(moved)
|
|
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, notes=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.
|
|
|
|
UX-13: a list passed as `notes` collects the validator's warnings too.
|
|
"""
|
|
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, notes=notes))
|
|
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,
|
|
notes=notes))
|
|
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, base=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.
|
|
|
|
`base`, when given, is the blob the page opened (the one `expect` is the
|
|
fingerprint of). A stale refusal then also names the keys that moved
|
|
underneath it, so the page does not list the operator's own edits among
|
|
the settings "changed elsewhere" (UX-13 P2 test, finding a).
|
|
"""
|
|
problems = await problems_for(proposed, hass)
|
|
if problems:
|
|
_LOG.warning("The settings page did not save, because of %s. The "
|
|
"first: %s", messages.count(len(problems), "problem"),
|
|
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)
|
|
moved = (changed_keys(base, stored)
|
|
if isinstance(base, dict) and fingerprint(base) == expect
|
|
else None)
|
|
keys = changed if moved is None else moved
|
|
_LOG.warning("The settings page did not save, because the settings "
|
|
"changed while it was open (keys: %s).",
|
|
", ".join(keys) or "none this page also edited")
|
|
raise SaveRefused("stale", changed=changed, stored=stored,
|
|
moved=moved)
|
|
|
|
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)}
|