Files
goodwe-addon/goodwe_controller/app/selfconfig.py
T
adminandClaude Opus 5.5 6a95447248
checks / unit (pull_request) Successful in 8m55s
infra-pin / fresh (pull_request) Successful in 1s
UX-08 2d: plain log lines, the rest of the status page, and DOCS
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
2026-09-27 08:01:16 +02:00

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)}