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
367 lines
16 KiB
Python
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))
|