checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.
WHAT IT ADDS
app/configui.py the field model, built from `GET /addons/self/info`'s own
`schema` + `translations`. No option key, no label, no
rule of its own. UX-01's `Group · ` label prefix becomes a
real section heading, in whichever language resolved.
app/selfconfig.py the Supervisor conversation and the write gate.
app/config_page.py the page. Its longest function decides which HTML input a
type deserves; it contains no rule.
app/web.py five routes. Still the only file a reviewer has to read to
answer "does anything here reach the inverter".
THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
1. A blob the SHIPPED validator refuses is never written - not warned about,
not written. An invalid blob plus a restart is exit 78, and the panel is
gone with the add-on that serves it.
2. A blob that moved underneath is refused, not clobbered. Read hands out a
sha256 of the canonical blob, save hands it back, and the stored blob is
re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
applied to configuration - "a blob reaches the Supervisor only if it was
derived from the blob stored right now" - with the honest weakening stated
in the decision record: a second writer is DETECTED, not made
inexpressible.
3. Every message is the validator's own, byte for byte. test_selfconfig.py
part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
under app/ to show validate.py is the only one that differs, and serves the
real /config/validate route out of that copy in a subprocess. The new
sentence arrives, filed under the key it names.
4. Nothing on the surface commands an inverter or clears a latch. Saving does
not restart: applying costs a control interruption - the wind-down commands
0 W on every inverter - so that is a separate, confirmed button that says
so first.
MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
- the three self-paths are permitted, and `/addons/self/info` is admitted by
the `default` role too while `options` and `restart` rest on the bypass
ALONE. The probe's first draft claimed the role admitted none of the three
and went red.
- `/addons/self/options/validate` is NOT reachable from inside an add-on, so
the only validator this panel can call is ours - which is the one with the
cross-field rules.
- a POST that omits a key does not preserve it: the key reverts to
config.yaml's default. Hence whole-blob read-modify-write.
- the Configuration tab CAN be removed, and doing so drops 0-of-71 options
through and takes the add-on down. The card said it could not be; it stays
by decision, which is a better reason.
DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.
Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
---
⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.
So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
316 lines
14 KiB
Python
316 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.
|
|
"""
|
|
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))
|