Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons; the reasoning of the ten longest is on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0). The status page: the month-peak and peak-guard rows, the stale-reading row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the Stopped banner and the line under the buttons, which now shows the plain hint of each event instead of the raw action log. The Supervisor question at start logs one line with a real reason. Refusals are counted as shown (labels expanded, long cases driven), seven were shortened, the card and parse errors lost their internals, a blank value reads "empty", and the panel no longer files inverters.max_w under the flat max_w. Option names lose "(JSON)", the NL texts are checked too. DOCS gets a glossary (control board, isolator, Developer Tools) and card ids move from headings to a "For installers" line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
402 lines
20 KiB
Python
402 lines
20 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()
|
|
|
|
# ⚠️ 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": <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("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)}
|