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
319 lines
14 KiB
Python
319 lines
14 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("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
|
|
|
|
|
|
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))
|