Files
goodwe-addon/goodwe_controller/app/configui.py
T
glenn schrooyenandClaude 0801776645
checks / unit (pull_request) Successful in 39m0s
infra-pin / fresh (pull_request) Successful in 2s
PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
F1: every password option is shown as ******** (set) or empty in /config/model
(options, running, ui) and in a stale refusal; the classic page draws it as a
password field; a save or validate that sends ******** or nothing keeps the
stored value. F2: a static_profile beside price_service is said once at start
(notification and log), not refused. F3 (owner, 2026-10-14): the no-price row,
Home line and notification only while maintenance_enabled is on. F4: tests for
the reviewer's R01 and R10. F5: price_service_token must be 16+ characters.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 18:56:51 +02:00

367 lines
16 KiB
Python

"""UX-02: the config surface's MODEL - built from what the Supervisor returns.
WHAT THIS MODULE IS FOR, in one sentence: turn the three things
`GET /addons/self/info` already carries - `schema`, `translations` and
`options` - into the shape a form renders from, WITHOUT this repository owning
a second copy of any of them.
⚠️ THE RULE THIS FILE EXISTS TO OBEY. There is exactly one description of the
option surface (config.yaml's `schema:` block), exactly one set of labels
(translations/*.yaml, UX-01) and exactly one set of cross-field rules
(app/validate.py). A config UI is the classic place where all three get
transcribed a second time - a hand-written field list, a hand-written label, a
hand-written "max_w must not exceed the device" in JavaScript - and the second
copy is what drifts. So:
* the FIELDS come from `info["schema"]`, which is Supervisor's own
`App.schema_ui` == `UiOptions(...)(config.yaml["schema"])`. Nothing here
lists an option key.
* the LABELS come from `info["translations"]`, which is Supervisor's own
`_read_app_translations()` over translations/*.yaml. Nothing here contains
an English or Dutch sentence about an option.
* the RULES are not here at all. `problems_by_field()` takes strings the
SHIPPED validator produced and decides only WHERE ON THE PAGE to put them.
The consequence is the acceptance criterion: a rule added to validate.py, an
option added to config.yaml, or a label improved in translations/en.yaml all
reach the panel with no change to this file, to web.py, or to the page's
JavaScript. test_selfconfig.py part 6 proves the first of those by running the
panel against a MUTATED COPY of validate.py in a subprocess.
⚠️ STDLIB ONLY, AND THAT IS LOAD-BEARING. This module is imported by
tools/supervisor_selfapi_probe.py, which runs inside the Home Assistant
Supervisor release image so that the model can be built from the REAL loader's
output rather than from a transcription of it. That image has no paho-mqtt, so
an import of anything under `app/` that reaches `mqtt.py` would make the probe
unrunnable and the measurement would quietly become a re-reading.
--- WHAT THE SUPERVISOR CANNOT RENDER, AND WHY THAT IS THE POINT -------------
Add-on options are a FLAT LIST. UX-01's own header says it: no section-header
primitive, no units on an input, no per-option grouping. UX-01 answered that by
prefixing every label with `Group · `, which is the best a flat list allows.
This module reads that prefix back off and turns it into a real heading, so the
grouping the operator already has in words becomes grouping they have on the
screen - in whichever language their labels resolved in, because the prefix is
translated too (`Limits` / `Grenzen`).
⚠️ NOTHING IS INVENTED WHEN THE PREFIX IS ABSENT. A key whose label carries no
` · ` is NOT quietly filed under the last heading seen - that would put a new
option in a section nobody chose and it would look deliberate. It goes into the
model's `ungrouped` list, is rendered under a heading that says so, and
test_selfconfig.py asserts the list is EMPTY today, so the day config.yaml
grows a key UX-01 has not grouped, a check says so instead of the page guessing.
"""
import re
__all__ = ["resolve_entry", "label_of", "help_of", "split_group", "unit_of",
"field_model", "problems_by_field", "changed_keys"]
# --------------------------------------------------------------------------- #
# 1. LABELS - the frontend's own resolution order, transcribed once
#
# ⚠️ THIS IS A TRANSCRIPTION OF SOMEBODY ELSE'S CODE AND IT IS LABELLED AS ONE.
# tools/supervisor_shape_probe.py section F reads the resolution out of the
# shipped frontend bundle in the release image this site runs
# (`5680.*.js`, frontend 20250925.1):
#
# computeLabel = (s,_,o) => this._getTranslationEntry(this.hass.language,s,o)?.name
# || this._getTranslationEntry("en",s,o)?.name
# || s.name
#
# i.e. THE FALLBACK IS PER KEY, `en` is the second chance and the raw key is the
# third. The panel resolves labels the same way ON PURPOSE: two config surfaces
# that disagree about what a field is CALLED would be worse than one ugly one.
# If that resolution ever changes upstream, this is a copy that can go stale -
# it is three lines, it is named here, and the probe prints both renderings side
# by side so a divergence is visible rather than argued about.
# --------------------------------------------------------------------------- #
def resolve_entry(translations, lang, key, path=()):
"""Yield `(language, entry)` for `key`, in the per-key `lang -> en` order.
`translations` is `info["translations"]` verbatim: `{lang: {"configuration":
{key: {"name", "description", "fields": {...}}}}}`. `path` names the
enclosing repeatable section for a per-entry key, e.g. `("inverters",)`.
"""
for language in (lang, "en"):
node = ((translations or {}).get(language) or {}).get("configuration")
if not isinstance(node, dict):
continue
for step in path:
node = ((node.get(step) or {}).get("fields")) or {}
entry = node.get(key)
if isinstance(entry, dict):
yield language, entry
def label_of(translations, lang, key, path=()):
"""(label, source-language) with the raw key as the last resort."""
for language, entry in resolve_entry(translations, lang, key, path):
name = str(entry.get("name") or "").strip()
if name:
return name, language
return key, "raw"
def help_of(translations, lang, key, path=()):
"""(description, source-language). "" when nothing was translated."""
for language, entry in resolve_entry(translations, lang, key, path):
text = str(entry.get("description") or "").strip()
if text:
return text, language
return "", "none"
# UX-01's separator, U+00B7 with a space each side. It is not a general
# delimiter guess: it is the exact string that file's generator writes, and the
# audit in tools/translations_lint.py is what keeps every label carrying one.
GROUP_SEP = " · "
def split_group(label):
"""`"Limits · Inverter rating (W)"` -> `("Limits", "Inverter rating (W)")`.
A label with no separator comes back as `("", label)` - see the module
docstring on why that is not filed under the previous heading.
"""
if GROUP_SEP in label:
group, _, rest = label.partition(GROUP_SEP)
return group.strip(), rest.strip()
return "", label
# --------------------------------------------------------------------------- #
# 2. UNITS - presentation, derived from the key's own suffix
#
# ⚠️ THIS IS THE ONE PLACE THIS MODULE PUTS WORDS NEXT TO A FIELD, and it is
# deliberately the weakest possible rule: a suffix map, applied to a key name,
# affecting nothing but what is printed to the right of an input box. It states
# no bound, no default and no relation, so it cannot disagree with config.yaml
# or with validate.py about anything - the worst a wrong entry here can do is
# print "W" beside a number that is in seconds, which is visible to the first
# person who reads the screen.
#
# It is NOT a schema, and it must never become one: a unit string here that
# named a bound - "h (0-23)", say - would be a second copy of a range that
# config.yaml already states and the panel already renders from the schema. One
# such entry was written and removed; test_selfconfig.py part 5 is what removed
# it, by asserting that no unit string contains a digit.
#
# Anything the map does not know about gets no unit rather than a guessed one.
# --------------------------------------------------------------------------- #
_UNITS = (
("_eur_kwh", "€/kWh"),
("_eur", "€"),
("_soc_floor", "%"),
("_soc_target", "%"),
("_soc_ceiling", "%"),
("_days", "days"),
("_min", "min"),
("_hour", "h"),
("_horizon_h", "h"),
("_cycles", "cycles"),
("_phases", "phases"),
("_w", "W"),
("_s", "s"),
)
def unit_of(key):
for suffix, unit in _UNITS:
if key.endswith(suffix):
return unit
return ""
# --------------------------------------------------------------------------- #
# 3. THE MODEL
# --------------------------------------------------------------------------- #
def _one_field(node, translations, lang, path=()):
"""One entry of Supervisor's `schema_ui` -> one field of the model.
⚠️ THE TYPE, THE RANGE AND THE ENUM ARE COPIED, NOT DECIDED. `type`,
`lengthMin`/`lengthMax` (which is what `UiOptions` calls a NUMERIC range,
misleadingly, and measured rather than guessed - see
tools/supervisor_selfapi_probe.py section C) and `options` come straight off
the node. This function chooses no bound of its own, so a range edited in
config.yaml is a range the panel enforces on the very next page load.
"""
key = str(node.get("name") or "")
raw_label, label_lang = label_of(translations, lang, key, path)
group, label = split_group(raw_label)
text, help_lang = help_of(translations, lang, key, path)
field = {
"key": key,
"label": label,
"group": group,
"help": text,
"label_lang": label_lang,
"help_lang": help_lang,
"type": str(node.get("type") or "string"),
"required": bool(node.get("required")),
"multiple": bool(node.get("multiple")),
"unit": unit_of(key),
}
if node.get("format") == "password":
field["type"] = "password"
if node.get("lengthMin") is not None:
field["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
field["max"] = node["lengthMax"]
if node.get("options"):
field["options"] = list(node["options"])
if node.get("type") == "schema":
field["fields"] = [_one_field(sub, translations, lang, path + (key,))
for sub in node.get("schema") or ()]
return field
def field_model(schema, translations, lang="en"):
"""`info["schema"]` + `info["translations"]` -> the groups a page renders.
Returns `{"groups": [{"name", "fields": [...]}], "ungrouped": [key, ...],
"keys": [key, ...]}`.
⚠️ ORDER IS THE SCHEMA'S, AND SO IS GROUP ORDER. `App.schema_ui` renders
config.yaml's `schema:` block in file order (measured in
tools/supervisor_shape_probe.py section F), and a group takes its position
from the FIRST field that names it. So the panel's section order is
config.yaml's section order, and moving a block in that file moves it here -
which is the property that stops this model needing an order of its own.
"""
groups, order, ungrouped, keys = {}, [], [], []
for node in schema or ():
field = _one_field(node, translations, lang)
keys.append(field["key"])
name = field["group"]
if not name:
ungrouped.append(field["key"])
if name not in groups:
groups[name] = []
order.append(name)
groups[name].append(field)
return {
"groups": [{"name": name, "fields": groups[name]} for name in order],
"ungrouped": ungrouped,
"keys": keys,
}
# --------------------------------------------------------------------------- #
# 4. WHERE A REFUSAL GOES ON THE PAGE
#
# ⚠️ READ THIS BEFORE ADDING ANYTHING TO IT. The entire value of this card is
# that validate.py's cross-field sentences reach the operator BEFORE a save.
# Those sentences are written by validate.py and are shown BYTE FOR BYTE. This
# function decides one thing and one thing only: which field to print each
# sentence under. It does not shorten, re-word, classify by severity, or map a
# message to a rule number - every one of those would be a second, drifting
# opinion about a rule this module is not allowed to hold.
#
# THE MECHANISM IS NAME OCCURRENCE, and it knows no keys of its own: the caller
# passes the key list from the schema. A message that names `max_w` is filed
# under `max_w`. That is why validate.py's habit of naming the option it is
# refusing - which it already had, for the log - is what makes the anchoring
# work, and why nothing here breaks when a rule is added.
#
# ⚠️ WORD BOUNDARIES, AND THE COLLISION THEY EXIST FOR. `sign_disagree_w` is a
# substring of `unit_sign_disagree_w` and of `site_sign_disagree_w` - three
# distinct options, two of them SAFETY-30's. A plain `in` test files every
# per-unit refusal under the site key as well, which would put a message about
# unit-0 next to a field that has nothing to do with it. The boundary class
# excludes `_` on purpose so that the longer names do not match the shorter key.
#
# ⚠️ AND THE HONEST LIMIT, STATED RATHER THAN DISCOVERED: a refusal whose
# sentence never spells an option key cannot be anchored, and is shown in the
# page's top-level refusal block instead. Nothing is lost - it is the same
# sentence, on the same screen, before the same save - it simply is not beside
# a box. test_selfconfig.py part 4 MEASURES how many of the shipped rules
# anchor and prints the ones that do not, rather than this comment claiming a
# coverage figure.
# --------------------------------------------------------------------------- #
def problems_by_field(problems, keys):
"""(by_key, unanchored) for a list of validator sentences.
A sentence naming several keys is filed under EVERY one of them - a
cross-field rule is about all of its fields, and hiding it from one of them
would be this card's own defect in miniature.
"""
# ⚠️ `.` BEFORE THE KEY DOES NOT COUNT (UX-08 2d): `inverters.max_w`
# names a setting of one inverter in the list, which is changed under
# `inverters`, and it must not also land beside the flat `max_w`.
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_.])" + re.escape(key)
+ r"(?![A-Za-z0-9_])")) for key in keys]
by_key, unanchored = {}, []
for problem in problems:
hit = False
for key, pattern in patterns:
if pattern.search(problem):
by_key.setdefault(key, []).append(problem)
hit = True
if not hit:
unanchored.append(problem)
return by_key, unanchored
# --------------------------------------------------------------------------- #
# SECRETS (PRICE-01 F1). A `password` option never leaves this process in a page
# or a JSON answer. The pages get SECRET_SHOWN when one is set and "" when not.
# A save that sends SECRET_SHOWN or nothing keeps the stored value; any other
# value replaces it. The names come from the Supervisor's own schema, so a
# password option added later is covered without an edit here.
# --------------------------------------------------------------------------- #
SECRET_SHOWN = "********"
def secret_keys(schema, extra=()):
"""Names of the options whose schema type is `password`, plus `extra`."""
# The Supervisor may spell it `type: password` or `type: string` with
# `format: password`; both count.
return frozenset(str(n.get("name")) for n in schema or ()
if isinstance(n, dict) and (
n.get("type") == "password"
or n.get("format") == "password")
) | frozenset(extra)
def hide_secrets(value, keys):
"""A copy of `value` with every secret key's value replaced by
SECRET_SHOWN (set) or "" (empty), at any depth."""
if isinstance(value, dict):
return {k: ((SECRET_SHOWN if v else "") if k in keys
else hide_secrets(v, keys))
for k, v in value.items()}
if isinstance(value, list):
return [hide_secrets(v, keys) for v in value]
return value
def keep_secrets(proposed, stored, keys):
"""`proposed` with each secret the page did not change put back from
`stored`: absent, empty or SECRET_SHOWN means 'leave it'."""
out = dict(proposed)
for key in keys:
if out.get(key) in (None, "", SECRET_SHOWN):
if isinstance(stored, dict) and key in stored:
out[key] = stored[key]
elif key in out and out[key] == SECRET_SHOWN:
out[key] = ""
return out
def changed_keys(left, right):
"""Sorted keys whose values differ between two option blobs.
Used for one thing: saying WHICH settings are stored but not yet running,
and which the other configuration surface moved underneath this one. It
compares values as they are, so a list reordered counts as changed - which
is correct for `inverters`, where order is the roster order.
"""
return sorted(k for k in set(left) | set(right)
if left.get(k) != right.get(k))