UX-01 — translations and ordering for the add-on Configuration tab
"the layout, labels, ux and values in the addon config feel too technical" — the owner, 2026-09-04
Scope: the rescoped card. Translations + ordering only. The hiding/defaults half was dropped in the
rescope comment (UX-02 supersedes the surface; the Supervisor Configuration tab cannot be removed, so this
card makes it legible rather than hostile). Nothing here hides an option and nothing here moves a default.
The problem, measured
There was no translations/ directory at all, so Home Assistant fell back to rendering the raw option keys. Every key is present in options: and the frontend keeps a field when name in options || required,
so all 70 options render, always, in schema: order, with no description. The operator read unit_sign_disagree_w, saturation_cycles, integrator_max_w — and had no way to tell a constant derived
from 1890 closed-loop test runs from their own inverter's rating.
(The card says 63 options / 55 required; SAFETY-30, SAFETY-31 and SAFETY-32 have landed since it was
written. The measured figures today are 70 options, 59 of them required, plus 7 per-entry keys.)
What changed
file
translations/en.yaml
name + description for all 70 options and all 7 per-entry keys of inverters:
translations/nl.yaml
the same, written for a Flemish homeowner — afname / injectie, kwartierpiek, capaciteitstarief, laadniveau, uitschakelgrens — not transliterated
config.yaml
reordered into task order in both halves, comments carried with their keys. No value, no range, no behaviour
tools/supervisor_shape_probe.py
new section F: measures the rendered result against the real Supervisor image
tools/translations_lint.py
the strict parser + coverage audit, shared by the check and the probe
test_translations.py
the check that stops this rotting, mutation-tested both ways
DOCS.md, CHANGELOG.md
what the tab can and cannot do, stated without over-claiming
⭐ The highest-value half: the description says whether to touch it
option
what the operator now reads
unit_sign_disagree_s
"DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs produce (10.0 s)… Do not change it without re-deriving it."
circulation_w
"MEASURED ON 924 CLOSED-LOOP TEST RUNS… the worst legitimate opposition collapses from 505 s at 100 W to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below."
meter_freeze_s
"MEASURED… over 2399 s of this house at its settled operating point, 1194 polls, the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that."
bms_soc_floor
"MEASURED, not chosen: register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure."
max_w, peak_cap_w
"THIS IS YOUR NUMBER, not a derived one…"
cycle_cost_threshold_eur
"⚠️THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08)."
The last row is the one I would point a reviewer at: it would have been easy to give it the same authoritative
voice as the others, and that would have been a lie the config surface then repeats forever.
⚠️ The ordering constraints — preserved, and said so explicitly
test_inverter.py:1788 — the nested per-entry max_w: int( (6-space) still appears BEFORE the flat max_w: int( (2-space) in the schema: half. CFG-04 baited that on purpose so a first-match
stripped-line reader is caught, and the reorder keeps it baited: inverters: is in the hardware group,
flat max_w in ratings and limits. Run, not assumed: moving the nested block below the flat one is a 1-byte edit and it goes red in test_inverter.py part 12 and in the new test_translations.py.
test_inverter.py:1436/1508 — \n inverters: []\n is still in the options: half.
test_maintenance.py:134, test_peakguard.py's _cfg_halves/_top_level, test_validate.py, test_watchdog.py, test_freeze.py, test_price.py, test_tariff.py, test_arbitrage.py, test_price_forecast.py — every one of these anchors on two-space indentation and on the \nschema: split, so all are order-independent within a half. Checked before reordering and green after.
The options: and schema: halves already disagreed on order before this card (they diverged at index
10). They now agree, and test_translations.py part 4 pins that.
⚠️ What Home Assistant actually renders — measured, not assumed
tools/supervisor_shape_probe.py section F runs against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 — the version the owner runs — and uses
Supervisor's own code rather than a re-implementation:
supervisor.store.data._read_app_translations() loads both files and validates them with SCHEMA_APP_TRANSLATIONS. ⚠️A file that fails that schema is dropped with a log warning and nothing
else, so "it is on disk" was never the question. The probe asserts both loaded.
App.schema_ui is UiOptions(coresys)(data["schema"]) — so the schema: half decides the render
order, not options:. That is why the ordering work is on the schema half.
The label resolution is transcribed from the shipped frontend bundle in that image
(api/panel/frontend_latest/5680.*.js, frontend 20250925.1): computeLabel = translations[lang]?…name || translations["en"]?…name || schema.name, and the same for description. The fallback is per key, which is why a missing nl key degrades and a wrong one does not.
The probe then prints every rendered row in both languages — the transcript, not a claim.
all cases passed, exit 0, sections A–E (CFG-04's originals) still green.
⚠️ One measured negative result, recorded rather than glossed
The per-entry keys inside the inverters: repeatable section still render RAW.UiOptions gives inverterstype: "schema"withmultiple: true; _convertSchemaElement sends exactly that case to a
selector, not to a nested ha-form:
if("schema"===t.type&&!t.multiple)return{…type:"expandable"…}// not us
if("schema"===t.type){…return{object:{multiple:t.multiple,fields:o}}}// us
and ha-selector-object labels those fields fields[name].label || name — the converter sets neither label nor translation_key. So on frontend 20250925.1 the operator still sees setpoint_entity, max_w, bms_soc_floor inside each card. The fields: block is still shipped and validated — it is the shape
Supervisor's own SCHEMA_TRANSLATION_CONFIGURATION defines for exactly this, and a frontend that wires label through would consume it — but this PR does not claim it reaches the screen today. The inverters label and description themselves do. The probe asserts the current state so the day it
changes, the transcript says so.
The check, and its mutation ledger
test_translations.py fails if a translation key names a non-existent option, or an option has no
translation — both directions, both languages, top level and per entry, name and description.
Mutation-tested both ways. Eleven mutations are applied in-process and each is required to NAME the key
it broke, not merely to go red — plus a negative control that the unmutated input is clean. Six more were
run on disk, through the purge-and-re-read protocol (bytecode purged, count printed before/after, the
edited file re-read from disk, byte delta printed):
#
mutation
delta
result
M1
delete one option's translation from en.yaml
−65 B
RED — names saturation_cycles
M2
rename an option in config.yaml's schema half
+4 B
RED — both languages
M3
add an orphan translation key to nl.yaml
+47 B
RED — names max_watt
M4b
empty one Dutch description (file still valid)
−711 B
RED — names meter_freeze_s
M5
move the nested inverters: block below flat max_w
1 B
RED in test_inverter.pyandtest_translations.py
M6
break one label's group prefix (Advanced → Site)
−4 B
RED — contiguity + nl/en grouping
M7
delete a provenance citation (circulation_w loses "924")
−19 B
RED — provenance check
(A seventh, description: \"\" written malformed, went red as a TranslationSyntaxError — the parser
refuses rather than skipping, which is the point of part 5.)
One gap found by asking what the check would not see — and closed
The configuration: coverage above cannot see a mutation that costs the whole file. _read_app_translations() validates each file through SCHEMA_APP_TRANSLATIONS and, on refusal, logs a
warning and drops all of it — every label, not just the offending key — so the tab silently returns to raw
keys with one line in a log nobody is reading. Two such shapes are reachable and were previously invisible to
CI: an unrecognised top-level block (netwrok:), and a network: value that is a mapping rather than a
string (network: is {str: str}). Both are now refused by audit()and are two of the eleven
in-process mutations, so the check that catches them is itself graded.
⚠️ Honest survivors, with their size
Enumerated because a disclosed gap is worth more than a claim of completeness. Each was built and run:
A factual figure inside a description can be falsified without going red, unless it happens to be one
of the cited tokens. Run: unit_sign_disagree_s's "8.5 times" → "3 times", −2 B, green. The check
grades that the citation is present and names the right record; it cannot grade that the sentence is
true. Closing this would mean deriving the prose from the decision records, which is a much larger card.
Swapping two options' descriptions survives. Run: heartbeat_s ↔ step_w, 0 B, green. Both keys
still have a non-empty description, and nothing ties a description to its key's meaning.
Out of this card's scope, and pre-existing: changing a shipped default that no suite pins survives.
Run: step_w: 10 → 20, 0 B, and test_control.py, test_clamp.py, test_validate.py and test_peakguard.py all stay green. This is the same family as CFG-04's DEFECT 2 (the two unpinned
in-code max_w defaults at validate.py:377 / inverter.py:382), which the UX-01 rescope explicitly
moved to its own card. It should get one.
The Dutch is graded for presence, not for quality. Part 2 catches an nl string left identical to the
English one, and part 4 catches a wrong group prefix — nothing checks that the Dutch reads well. A native
reader is the instrument for that, and this PR is asking for one.
No behaviour and no value moved
The 175 non-comment lines of config.yaml are byte-identical to release/1.0's, reordered.
Verified by sorting and diffing both files' non-comment lines: identical, 175 = 175.
The only comment lines that changed are the old # --- group --- banners (replaced) and five sentences
whose above/below the reorder made false — e.g. bms_soc_floor's "whatever is set above" now names peak_soc_floor, and the roster note's "THE FIVE FLAT KEYS ABOVE" now names the six keys build_roster() actually reads (it had been stale since SAFETY-32 added bms_soc_floor).
translations/ and tools/ are not copied into the add-on image (Dockerfile copies app/ and run.sh only) — correct, because Supervisor reads translations from the repository folder, not the
image. Nothing about the running add-on changes.
Suite
20/20 green — the 19 existing files plus test_translations.py. test_peakguard.py (CI-06's flaky one)
passed in five consecutive full runs here; no red was seen at any point, so nothing needed re-running in
isolation.
Not done here, deliberately
Not deployed. The add-on is live in production on 0.6.0; deploying is a supervised human step.
Not merged.
Overlaps PKG-03 (i18n NL/FR/EN): this card does the add-on config half in EN+NL. PKG-03 keeps FR
and the rest of the user-facing strings. Worth saying so in that card too.
Inverter B does not physically exist. Nothing here presents any figure about it as measured; the roster
example's 4600 W remains labelled as its datasheet figure (SPIKE-04a).
# UX-01 — translations and ordering for the add-on Configuration tab
> *"the layout, labels, ux and values in the addon config feel too technical"* — the owner, 2026-09-04
**Scope: the rescoped card.** Translations + ordering only. The hiding/defaults half was dropped in the
rescope comment (UX-02 supersedes the surface; the Supervisor Configuration tab cannot be removed, so this
card makes it legible rather than hostile). **Nothing here hides an option and nothing here moves a default.**
## The problem, measured
There was no `translations/` directory at all, so Home Assistant fell back to rendering the raw option
**keys**. Every key is present in `options:` and the frontend keeps a field when `name in options || required`,
so **all 70 options render, always, in `schema:` order, with no description**. The operator read
`unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w` — and had no way to tell a constant derived
from 1890 closed-loop test runs from their own inverter's rating.
*(The card says 63 options / 55 required; SAFETY-30, SAFETY-31 and SAFETY-32 have landed since it was
written. The measured figures today are **70 options, 59 of them `required`, plus 7 per-entry keys**.)*
## What changed
| file | |
|---|---|
| `translations/en.yaml` | `name` + `description` for **all 70 options** and **all 7 per-entry keys** of `inverters:` |
| `translations/nl.yaml` | the same, written for a Flemish homeowner — *afname* / *injectie*, *kwartierpiek*, *capaciteitstarief*, *laadniveau*, *uitschakelgrens* — not transliterated |
| `config.yaml` | reordered into task order in **both** halves, comments carried with their keys. **No value, no range, no behaviour** |
| `tools/supervisor_shape_probe.py` | new **section F**: measures the rendered result against the real Supervisor image |
| `tools/translations_lint.py` | the strict parser + coverage audit, shared by the check and the probe |
| `test_translations.py` | the check that stops this rotting, mutation-tested both ways |
| `DOCS.md`, `CHANGELOG.md` | what the tab can and cannot do, stated without over-claiming |
### ⭐ The highest-value half: the description says **whether to touch it**
| option | what the operator now reads |
|---|---|
| `unit_sign_disagree_s` | *"DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs produce (10.0 s)… Do not change it without re-deriving it."* |
| `circulation_w` | *"MEASURED ON 924 CLOSED-LOOP TEST RUNS… the worst legitimate opposition collapses from 505 s at 100 W to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below."* |
| `meter_freeze_s` | *"MEASURED… over 2399 s of this house at its settled operating point, 1194 polls, the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that."* |
| `bms_soc_floor` | *"MEASURED, not chosen: register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure."* |
| **`max_w`, `peak_cap_w`** | *"**THIS IS YOUR NUMBER**, not a derived one…"* |
| **`cycle_cost_threshold_eur`** | *"⚠️ **THIS ONE IS NOT DERIVED FROM A MEASUREMENT.** 0.10 is a product decision and it is still an open question with the owner (OPT-08)."* |
The last row is the one I would point a reviewer at: it would have been easy to give it the same authoritative
voice as the others, and that would have been a lie the config surface then repeats forever.
## ⚠️ The ordering constraints — preserved, and said so explicitly
* **`test_inverter.py:1788` — the nested per-entry `max_w: int(` (6-space) still appears BEFORE the flat
`max_w: int(` (2-space) in the `schema:` half.** CFG-04 baited that on purpose so a first-match
stripped-line reader is caught, and the reorder keeps it baited: `inverters:` is in the *hardware* group,
flat `max_w` in *ratings and limits*. **Run, not assumed:** moving the nested block below the flat one is a
**1-byte** edit and it goes **red** in `test_inverter.py` part 12 *and* in the new `test_translations.py`.
* **`test_inverter.py:1436/1508` — `\n inverters: []\n` is still in the `options:` half.**
* **`test_maintenance.py:134`, `test_peakguard.py`'s `_cfg_halves`/`_top_level`, `test_validate.py`,
`test_watchdog.py`, `test_freeze.py`, `test_price.py`, `test_tariff.py`, `test_arbitrage.py`,
`test_price_forecast.py`** — every one of these anchors on **two-space indentation** and on the
`\nschema:` split, so all are order-independent *within* a half. Checked before reordering and green after.
* **The `options:` and `schema:` halves already disagreed on order before this card** (they diverged at index
10). They now agree, and `test_translations.py` part 4 pins that.
## ⚠️ What Home Assistant actually renders — measured, not assumed
`tools/supervisor_shape_probe.py` section F runs against
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — the version the owner runs — and uses
Supervisor's **own** code rather than a re-implementation:
* `supervisor.store.data._read_app_translations()` loads both files and validates them with
`SCHEMA_APP_TRANSLATIONS`. ⚠️ **A file that fails that schema is dropped with a log warning and nothing
else**, so "it is on disk" was never the question. The probe asserts both loaded.
* `App.schema_ui` is `UiOptions(coresys)(data["schema"])` — so the **`schema:` half decides the render
order**, not `options:`. That is why the ordering work is on the schema half.
* The label resolution is **transcribed from the shipped frontend bundle in that image**
(`api/panel/frontend_latest/5680.*.js`, frontend 20250925.1):
`computeLabel = translations[lang]?…name || translations["en"]?…name || schema.name`, and the same for
`description`. The fallback is **per key**, which is why a missing `nl` key degrades and a wrong one does not.
* The probe then **prints every rendered row in both languages** — the transcript, not a claim.
`all cases passed`, exit 0, sections A–E (CFG-04's originals) still green.
### ⚠️ One measured **negative** result, recorded rather than glossed
**The per-entry keys inside the `inverters:` repeatable section still render RAW.** `UiOptions` gives
`inverters` `type: "schema"` **with** `multiple: true`; `_convertSchemaElement` sends exactly that case to a
selector, not to a nested `ha-form`:
```js
if("schema"===t.type&&!t.multiple) return {…type:"expandable"…} // not us
if("schema"===t.type){…return {object:{multiple:t.multiple,fields:o}}} // us
```
and `ha-selector-object` labels those fields `fields[name].label || name` — the converter sets neither
`label` nor `translation_key`. So on frontend 20250925.1 the operator still sees `setpoint_entity`, `max_w`,
`bms_soc_floor` inside each card. **The `fields:` block is still shipped and validated** — it is the shape
Supervisor's own `SCHEMA_TRANSLATION_CONFIGURATION` defines for exactly this, and a frontend that wires
`label` through would consume it — but this PR does **not** claim it reaches the screen today. The
`inverters` label and description themselves **do**. The probe asserts the current state so the day it
changes, the transcript says so.
## The check, and its mutation ledger
`test_translations.py` fails if **a translation key names a non-existent option**, or **an option has no
translation** — both directions, both languages, top level and per entry, name *and* description.
**Mutation-tested both ways.** Eleven mutations are applied in-process and each is required to **NAME the key
it broke**, not merely to go red — plus a negative control that the unmutated input is clean. Six more were
run **on disk**, through the purge-and-re-read protocol (bytecode purged, count printed before/after, the
edited file re-read from disk, byte delta printed):
| # | mutation | delta | result |
|---|---|---|---|
| M1 | delete one option's translation from `en.yaml` | −65 B | **RED** — names `saturation_cycles` |
| M2 | rename an option in `config.yaml`'s schema half | +4 B | **RED** — both languages |
| M3 | add an orphan translation key to `nl.yaml` | +47 B | **RED** — names `max_watt` |
| M4b | empty one Dutch description (file still valid) | −711 B | **RED** — names `meter_freeze_s` |
| M5 | move the nested `inverters:` block **below** flat `max_w` | **1 B** | **RED** in `test_inverter.py` *and* `test_translations.py` |
| M6 | break one label's group prefix (`Advanced` → `Site`) | −4 B | **RED** — contiguity + nl/en grouping |
| M7 | delete a provenance citation (`circulation_w` loses "924") | −19 B | **RED** — provenance check |
*(A seventh, `description: \"\"` written malformed, went red as a `TranslationSyntaxError` — the parser
refuses rather than skipping, which is the point of part 5.)*
### One gap found by asking what the check would **not** see — and closed
The `configuration:` coverage above cannot see a mutation that costs **the whole file**.
`_read_app_translations()` validates each file through `SCHEMA_APP_TRANSLATIONS` and, on refusal, **logs a
warning and drops all of it** — every label, not just the offending key — so the tab silently returns to raw
keys with one line in a log nobody is reading. Two such shapes are reachable and were previously invisible to
CI: an unrecognised top-level block (`netwrok:`), and a `network:` value that is a mapping rather than a
string (`network:` is `{str: str}`). Both are now refused by `audit()` **and** are two of the eleven
in-process mutations, so the check that catches them is itself graded.
## ⚠️ Honest survivors, with their size
Enumerated because a disclosed gap is worth more than a claim of completeness. Each was **built and run**:
1. **A factual figure inside a description can be falsified without going red**, unless it happens to be one
of the cited tokens. Run: `unit_sign_disagree_s`'s *"8.5 times"* → *"3 times"*, **−2 B, green**. The check
grades that the *citation is present and names the right record*; it cannot grade that the sentence is
true. Closing this would mean deriving the prose from the decision records, which is a much larger card.
2. **Swapping two options' descriptions survives.** Run: `heartbeat_s` ↔ `step_w`, **0 B, green**. Both keys
still have a non-empty description, and nothing ties a description to its key's meaning.
3. **Out of this card's scope, and pre-existing:** changing a shipped default that no suite pins survives.
Run: `step_w: 10` → `20`, **0 B**, and `test_control.py`, `test_clamp.py`, `test_validate.py` and
`test_peakguard.py` all stay **green**. This is the same family as **CFG-04's DEFECT 2** (the two unpinned
in-code `max_w` defaults at `validate.py:377` / `inverter.py:382`), which the UX-01 rescope explicitly
moved to its own card. **It should get one.**
4. **The Dutch is graded for presence, not for quality.** Part 2 catches an `nl` string left identical to the
English one, and part 4 catches a wrong group prefix — nothing checks that the Dutch reads well. A native
reader is the instrument for that, and this PR is asking for one.
## No behaviour and no value moved
* The **175 non-comment lines** of `config.yaml` are **byte-identical** to `release/1.0`'s, reordered.
Verified by sorting and diffing both files' non-comment lines: identical, 175 = 175.
* The only comment lines that changed are the old `# --- group ---` banners (replaced) and **five sentences
whose `above`/`below` the reorder made false** — e.g. `bms_soc_floor`'s *"whatever is set above"* now names
`peak_soc_floor`, and the roster note's *"THE FIVE FLAT KEYS ABOVE"* now names the six keys
`build_roster()` actually reads (it had been stale since SAFETY-32 added `bms_soc_floor`).
* `translations/` and `tools/` are **not** copied into the add-on image (`Dockerfile` copies `app/` and
`run.sh` only) — correct, because Supervisor reads translations from the **repository** folder, not the
image. Nothing about the running add-on changes.
## Suite
**20/20 green** — the 19 existing files plus `test_translations.py`. `test_peakguard.py` (CI-06's flaky one)
passed in **five consecutive full runs** here; no red was seen at any point, so nothing needed re-running in
isolation.
## Not done here, deliberately
* **Not deployed.** The add-on is live in production on 0.6.0; deploying is a supervised human step.
* **Not merged.**
* Overlaps **PKG-03** (i18n NL/FR/EN): this card does the **add-on config** half in EN+NL. PKG-03 keeps FR
and the rest of the user-facing strings. Worth saying so in that card too.
* Inverter B does not physically exist. Nothing here presents any figure about it as measured; the roster
example's 4600 W remains labelled as its **datasheet** figure (SPIKE-04a).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
The add-on had NO translations/ directory, so Home Assistant fell back to
rendering the raw option KEYS. An operator opening Configuration read
`unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w` - seventy of
them, in file order, with no description and no way to tell a constant derived
from 1890 closed-loop test runs from their own inverter's rating.
⚠️ PRESENTATION ONLY. Not one default, not one schema range and not one line of
behaviour moved. The 175 non-comment lines of config.yaml are byte-identical to
release/1.0's, reordered; the only comment lines that changed are the group
banners and five sentences whose "above"/"below" the reorder made false.
* translations/en.yaml and translations/nl.yaml: a name and a description for
all 70 options and all 7 per-entry keys of the `inverters:` repeatable
section. The Dutch is written for a Flemish homeowner - afname/injectie,
kwartierpiek, capaciteitstarief - not transliterated from the English.
* ⭐ Every derived constant says WHERE IT CAME FROM and whether to touch it.
`unit_sign_disagree_s` cites its 1890 closed-loop runs (SAFETY-30);
`circulation_w` its 924 runs and the measured 400 W knee (SAFETY-31);
`meter_freeze_s` its 2399 s capture (SAFETY-18); `bms_soc_floor` register
0x0560. The two numbers that are the operator's - max_w and peak_cap_w - say
"THIS IS YOUR NUMBER". `cycle_cost_threshold_eur`, which is neither, says it
is an open product question (OPT-08).
* config.yaml reordered into task order in BOTH halves, comments carried with
their keys: hardware -> meter -> ratings and limits -> tariff -> maintenance
-> safety -> advanced.
⚠️ test_inverter.py:1788's trap is PRESERVED: the nested per-entry `max_w:`
still precedes the flat one under `schema:`, and `inverters: []` still ships
in the `options:` half. Moving the nested block below the flat one is a 1-byte
edit; it was run and goes red in test_inverter.py and test_translations.py.
* tools/supervisor_shape_probe.py section F MEASURES the result against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 - Supervisor's own
_read_app_translations(), its own UiOptions, and the frontend's label
resolution transcribed from the shipped bundle - and prints every rendered row
in both languages. `all cases passed`, exit 0.
* ⚠️ ONE MEASURED NEGATIVE RESULT, NOT GLOSSED: the per-entry keys inside the
`inverters:` section still render RAW on frontend 20250925.1, because a
list-of-objects goes to ha-selector-object and the converter sets no `label`
and no `translation_key`. The `fields:` translations are shipped and validated
- that is Supervisor's own shape for this - but this card does not claim they
reach the screen. The `inverters` label and description do.
* test_translations.py: a translation naming a non-existent option, or an option
with no translation, fails CI - both directions, both languages, top level and
per entry. Mutation-tested BOTH WAYS: eleven in-process mutations each
required to NAME the key they broke, plus a negative control; six more run on
disk through the purge-and-re-read protocol. Two of the eleven are the ones
that cost the WHOLE FILE rather than one label - an unrecognised top-level
block, and a `network:` value that is not a string. Supervisor drops a file
that fails SCHEMA_APP_TRANSLATIONS with only a log warning, so those silently
return the entire tab to raw keys; neither is reachable through the
`configuration:` coverage, which is why they were hunted for and closed.
* tools/translations_lint.py is the hand parser CI needs (no PyYAML in the
add-on image) and the probe grades it, key by key and string by string,
against Supervisor's own loader. It is strict and RAISES rather than skipping;
nine malformed shapes prove it.
Full 20-file suite green (19 existing + test_translations.py).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin
merged commit f72c48dbfd into release/1.02026-09-05 13:11:52 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
UX-01 — translations and ordering for the add-on Configuration tab
Scope: the rescoped card. Translations + ordering only. The hiding/defaults half was dropped in the
rescope comment (UX-02 supersedes the surface; the Supervisor Configuration tab cannot be removed, so this
card makes it legible rather than hostile). Nothing here hides an option and nothing here moves a default.
The problem, measured
There was no
translations/directory at all, so Home Assistant fell back to rendering the raw optionkeys. Every key is present in
options:and the frontend keeps a field whenname in options || required,so all 70 options render, always, in
schema:order, with no description. The operator readunit_sign_disagree_w,saturation_cycles,integrator_max_w— and had no way to tell a constant derivedfrom 1890 closed-loop test runs from their own inverter's rating.
(The card says 63 options / 55 required; SAFETY-30, SAFETY-31 and SAFETY-32 have landed since it was
written. The measured figures today are 70 options, 59 of them
required, plus 7 per-entry keys.)What changed
translations/en.yamlname+descriptionfor all 70 options and all 7 per-entry keys ofinverters:translations/nl.yamlconfig.yamltools/supervisor_shape_probe.pytools/translations_lint.pytest_translations.pyDOCS.md,CHANGELOG.md⭐ The highest-value half: the description says whether to touch it
unit_sign_disagree_scirculation_wmeter_freeze_sbms_soc_floormax_w,peak_cap_wcycle_cost_threshold_eurThe last row is the one I would point a reviewer at: it would have been easy to give it the same authoritative
voice as the others, and that would have been a lie the config surface then repeats forever.
⚠️ The ordering constraints — preserved, and said so explicitly
test_inverter.py:1788— the nested per-entrymax_w: int((6-space) still appears BEFORE the flatmax_w: int((2-space) in theschema:half. CFG-04 baited that on purpose so a first-matchstripped-line reader is caught, and the reorder keeps it baited:
inverters:is in the hardware group,flat
max_win ratings and limits. Run, not assumed: moving the nested block below the flat one is a1-byte edit and it goes red in
test_inverter.pypart 12 and in the newtest_translations.py.test_inverter.py:1436/1508—\n inverters: []\nis still in theoptions:half.test_maintenance.py:134,test_peakguard.py's_cfg_halves/_top_level,test_validate.py,test_watchdog.py,test_freeze.py,test_price.py,test_tariff.py,test_arbitrage.py,test_price_forecast.py— every one of these anchors on two-space indentation and on the\nschema:split, so all are order-independent within a half. Checked before reordering and green after.options:andschema:halves already disagreed on order before this card (they diverged at index10). They now agree, and
test_translations.pypart 4 pins that.⚠️ What Home Assistant actually renders — measured, not assumed
tools/supervisor_shape_probe.pysection F runs againstghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0— the version the owner runs — and usesSupervisor's own code rather than a re-implementation:
supervisor.store.data._read_app_translations()loads both files and validates them withSCHEMA_APP_TRANSLATIONS. ⚠️ A file that fails that schema is dropped with a log warning and nothingelse, so "it is on disk" was never the question. The probe asserts both loaded.
App.schema_uiisUiOptions(coresys)(data["schema"])— so theschema:half decides the renderorder, not
options:. That is why the ordering work is on the schema half.(
api/panel/frontend_latest/5680.*.js, frontend 20250925.1):computeLabel = translations[lang]?…name || translations["en"]?…name || schema.name, and the same fordescription. The fallback is per key, which is why a missingnlkey degrades and a wrong one does not.all cases passed, exit 0, sections A–E (CFG-04's originals) still green.⚠️ One measured negative result, recorded rather than glossed
The per-entry keys inside the
inverters:repeatable section still render RAW.UiOptionsgivesinverterstype: "schema"withmultiple: true;_convertSchemaElementsends exactly that case to aselector, not to a nested
ha-form:and
ha-selector-objectlabels those fieldsfields[name].label || name— the converter sets neitherlabelnortranslation_key. So on frontend 20250925.1 the operator still seessetpoint_entity,max_w,bms_soc_floorinside each card. Thefields:block is still shipped and validated — it is the shapeSupervisor's own
SCHEMA_TRANSLATION_CONFIGURATIONdefines for exactly this, and a frontend that wireslabelthrough would consume it — but this PR does not claim it reaches the screen today. Theinverterslabel and description themselves do. The probe asserts the current state so the day itchanges, the transcript says so.
The check, and its mutation ledger
test_translations.pyfails if a translation key names a non-existent option, or an option has notranslation — both directions, both languages, top level and per entry, name and description.
Mutation-tested both ways. Eleven mutations are applied in-process and each is required to NAME the key
it broke, not merely to go red — plus a negative control that the unmutated input is clean. Six more were
run on disk, through the purge-and-re-read protocol (bytecode purged, count printed before/after, the
edited file re-read from disk, byte delta printed):
en.yamlsaturation_cyclesconfig.yaml's schema halfnl.yamlmax_wattmeter_freeze_sinverters:block below flatmax_wtest_inverter.pyandtest_translations.pyAdvanced→Site)circulation_wloses "924")(A seventh,
description: \"\"written malformed, went red as aTranslationSyntaxError— the parserrefuses rather than skipping, which is the point of part 5.)
One gap found by asking what the check would not see — and closed
The
configuration:coverage above cannot see a mutation that costs the whole file._read_app_translations()validates each file throughSCHEMA_APP_TRANSLATIONSand, on refusal, logs awarning and drops all of it — every label, not just the offending key — so the tab silently returns to raw
keys with one line in a log nobody is reading. Two such shapes are reachable and were previously invisible to
CI: an unrecognised top-level block (
netwrok:), and anetwork:value that is a mapping rather than astring (
network:is{str: str}). Both are now refused byaudit()and are two of the elevenin-process mutations, so the check that catches them is itself graded.
⚠️ Honest survivors, with their size
Enumerated because a disclosed gap is worth more than a claim of completeness. Each was built and run:
of the cited tokens. Run:
unit_sign_disagree_s's "8.5 times" → "3 times", −2 B, green. The checkgrades that the citation is present and names the right record; it cannot grade that the sentence is
true. Closing this would mean deriving the prose from the decision records, which is a much larger card.
heartbeat_s↔step_w, 0 B, green. Both keysstill have a non-empty description, and nothing ties a description to its key's meaning.
Run:
step_w: 10→20, 0 B, andtest_control.py,test_clamp.py,test_validate.pyandtest_peakguard.pyall stay green. This is the same family as CFG-04's DEFECT 2 (the two unpinnedin-code
max_wdefaults atvalidate.py:377/inverter.py:382), which the UX-01 rescope explicitlymoved to its own card. It should get one.
nlstring left identical to theEnglish one, and part 4 catches a wrong group prefix — nothing checks that the Dutch reads well. A native
reader is the instrument for that, and this PR is asking for one.
No behaviour and no value moved
config.yamlare byte-identical torelease/1.0's, reordered.Verified by sorting and diffing both files' non-comment lines: identical, 175 = 175.
# --- group ---banners (replaced) and five sentenceswhose
above/belowthe reorder made false — e.g.bms_soc_floor's "whatever is set above" now namespeak_soc_floor, and the roster note's "THE FIVE FLAT KEYS ABOVE" now names the six keysbuild_roster()actually reads (it had been stale since SAFETY-32 addedbms_soc_floor).translations/andtools/are not copied into the add-on image (Dockerfilecopiesapp/andrun.shonly) — correct, because Supervisor reads translations from the repository folder, not theimage. Nothing about the running add-on changes.
Suite
20/20 green — the 19 existing files plus
test_translations.py.test_peakguard.py(CI-06's flaky one)passed in five consecutive full runs here; no red was seen at any point, so nothing needed re-running in
isolation.
Not done here, deliberately
and the rest of the user-facing strings. Worth saying so in that card too.
example's 4600 W remains labelled as its datasheet figure (SPIKE-04a).
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU