"""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"(?