Merge pull request 'UX-01: translations and task ordering for the add-on Configuration tab' (#50) from UX-01 into release/1.0

This commit was merged in pull request #50.
This commit is contained in:
2026-09-05 13:11:51 +02:00
8 changed files with 1772 additions and 315 deletions
+84
View File
@@ -2,6 +2,90 @@
## Unreleased
**The add-on configuration screen now speaks English and Dutch instead of raw
option keys (UX-01).** There was no `translations/` directory at all, so Home
Assistant fell back to rendering the 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. **This card changes presentation only: not one default, not
one range and not one line of behaviour moved.**
* **`translations/en.yaml` and `translations/nl.yaml`**, with a `name` and a
`description` for **all 70 options** and for **all 7 per-entry keys** of the
`inverters:` repeatable section. The Dutch is written for a Flemish
homeowner rather than transliterated: *afname* and *injectie*, *kwartierpiek*
and *capaciteitstarief*, *laadniveau*, *uitschakelgrens* — the words that are
on the bill and on the meter.
* **⭐ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM AND WHETHER TO TOUCH IT.**
`unit_sign_disagree_s` now reads *"derived from 1890 closed-loop test runs
(SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs
produce… do not change it without re-deriving"*; `circulation_w` cites its 924
runs and the measured 400 W knee; `meter_freeze_s` cites the 2399 s capture;
`bms_soc_floor` says it is register `0x0560` read off the pack. The two
numbers that are genuinely the operator's — `max_w` and `peak_cap_w` — say
**"THIS IS YOUR NUMBER"** instead, and `cycle_cost_threshold_eur`, which is
neither measured nor the operator's, admits it is an open product question
(OPT-08) rather than borrowing the others' authority.
* **`config.yaml` reordered into task order** — hardware/entities → meter →
ratings and limits → tariff → maintenance → safety → advanced — in **both**
halves, with every comment carried with its key. ⚠️ The rendered order is the
**`schema:`** half's (`App.schema_ui` is `UiOptions(...)(data["schema"])`);
the `options:` half is reordered to match so the file reads the way the tab
renders. ⚠️ **`test_inverter.py` part 12's first-match trap is preserved**:
the nested per-entry `max_w:` still appears BEFORE 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 and it was run: red in
`test_inverter.py` and in `test_translations.py`.
* **MEASURED, NOT ASSERTED.** `tools/supervisor_shape_probe.py` gains a section
F that loads both files with Supervisor 2026.08.0's *own*
`_read_app_translations()` out of the release image (a file that fails
`SCHEMA_APP_TRANSLATIONS` is **dropped with a log warning and nothing else**),
runs its own `UiOptions`, replays the frontend's label resolution transcribed
from the shipped bundle, and prints every rendered row in both languages.
* **⚠️ ONE MEASURED NEGATIVE RESULT, RECORDED RATHER THAN GLOSSED.** The
per-entry keys inside the `inverters:` repeatable section still render RAW.
`_convertSchemaElement` sends `type: "schema"` **with** `multiple` to
`ha-selector-object`, which labels its fields `fields[name].label || name`,
and the converter sets neither `label` nor `translation_key`. The `fields:`
translations are shipped and validated because that is the shape Supervisor's
own schema defines for exactly this, and a frontend that wires `label` through
would consume them — but on 20250925.1 they do not reach the screen, and this
card does not claim they do. The `inverters` label and description themselves
do.
* **A new check, `test_translations.py`, so this cannot rot.** A translation key
naming an option that does not exist, or an option with no translation, fails
CI — in both directions, for both languages, top-level and per-entry. It is
**mutation-tested both ways**: eleven mutations applied in-process, each
required to NAME the key it broke rather than merely to go red, plus a
negative control that the unmutated input is clean. 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
validates each translation file through `SCHEMA_APP_TRANSLATIONS` and, on
refusal, **logs a warning and drops all of it**, 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. Six more were run **on disk**, through the
purge-and-re-read protocol: deleting a translation (−65 B), renaming an option
in `config.yaml` (+4 B), adding an orphan key (+47 B), emptying a Dutch
description (−711 B), breaking one label's group prefix (−4 B) and deleting a
provenance citation (−19 B) each go red.
* **The parser has a licence.** There is no PyYAML in the add-on's CI image, so
`tools/translations_lint.py` reads these files by hand — and a hand parser
that quietly disagrees with the real loader is the defect this repository
keeps finding. The probe imports **that same module** and asserts, key by key
and string by string, that it returns what `_read_app_translations()` returns.
The parser is deliberately strict and **raises** on anything outside the
subset; nine malformed shapes are fed to it to prove it does.
* **⚠️ WHAT THIS CHECK CANNOT SEE, stated with its size.** It grades coverage
and provenance *citations*, not truth. Falsifying a figure inside a
description that is not one of the cited tokens survives (run: *"8.5 times"* →
*"3 times"*, −2 B, green), and swapping two options' descriptions survives
(0 B, green). Separately and out of this card's scope: changing a shipped
default that no suite pins survives — `step_w: 10` → `20` (0 B) leaves
`test_control.py`, `test_clamp.py`, `test_validate.py` and
`test_peakguard.py` green. That is the same family as CFG-04's DEFECT 2 and
the UX-01 rescope moved it to its own card on purpose.
**Inverter B's rating is 4600 W, FROM ITS DATASHEET, and every documented copy
of it now says so (SPIKE-04a).** Three places stated B's rating — `config.yaml`'s
commented roster example, `DOCS.md`'s, and `tools/supervisor_shape_probe.py`'s
+24
View File
@@ -40,6 +40,30 @@ hardware holds it there.
## Configuration
### What the Configuration tab actually shows you (UX-01)
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
so Home Assistant's *Configuration* tab renders human labels and a description
for every option instead of the raw option keys. What that does and does not buy
you is worth stating plainly, because it was **measured against the Supervisor
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
rather than assumed — `tools/supervisor_shape_probe.py` section F prints the
whole rendered list, in both languages.
| | |
|---|---|
| **All 70 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
| **The description says whether to touch it** | A number that was **derived** names the campaign it came from and says not to change it without re-deriving; a number that is genuinely yours (`max_w`, `peak_cap_w`) says **"THIS IS YOUR NUMBER"**; and `cycle_cost_threshold_eur`, which is neither, says it is an open product question |
| **Dutch falls back per key** | `translations[your language]?.name \|\| translations["en"]?.name \|\| the raw key`. A key missing from `nl.yaml` degrades to English; a key present but wrong does not degrade at all |
| **⚠️ The per-entry keys inside `inverters:` still render raw** | Measured, and negative. A list-of-objects becomes an `object` selector with `multiple: true`, and `ha-selector-object` labels its fields `fields[name].label \|\| name` — the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do **not** reach the screen. The `inverters` label and description themselves do |
`test_translations.py` is what stops this rotting: a translation naming an
option that does not exist, or an option with no translation, fails CI — in
either direction, and the check is mutation-tested both ways.
### Sources
| option | required | meaning |
+361 -315
View File
@@ -31,23 +31,23 @@ services:
- mqtt:want
options:
# --- sources (required) ---------------------------------------------------
meter_entity: sensor.p1_meter_active_power
meter_invert: false
# ==========================================================================
# HARDWARE - the inverter, its actuator and its own sensors
# ==========================================================================
setpoint_entity: ""
soc_entity: ""
batt_entity: ""
batt_invert: false
setpoint_entity: ""
# --- ARCH-01 / CFG-04: the inverter roster ---------------------------------
# ⚠️ EMPTY IS NOT "UNCONFIGURED", IT IS THE SINGLE-INVERTER CONFIGURATION, and
# it is what every install that upgrades into this version lands on. An empty
# list means THE FIVE FLAT KEYS ABOVE DESCRIBE THE ONE INVERTER - same
# entities, same rating, same behaviour, no operator action - which is the
# only migration story this key was allowed to have. `build_roster()` reads
# this and nothing else decides the shape.
# list means THE SIX FLAT KEYS - `setpoint_entity`, `soc_entity`,
# `batt_entity`, `batt_invert`, `max_w` and `bms_soc_floor` - DESCRIBE THE
# ONE INVERTER: same entities, same rating, same behaviour, no operator
# action, which is the only migration story this key was allowed to have.
# `build_roster()` reads this and nothing else decides the shape.
#
# Declared, it REPLACES those five keys rather than extending them. Each entry
# Declared, it REPLACES those six keys rather than extending them. Each entry
# names its own actuator, its own rating and its own sensors:
#
# inverters:
@@ -126,9 +126,12 @@ options:
# validator answers `Missing option 'inverters' in root` for every existing
# options blob, and the upgrade leaves an add-on that cannot start.
inverters: []
estop_fitted: false
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
# `off` keeps the original single meter_entity path above, so an existing
# ==========================================================================
# METER - where the grid reading comes from
# ==========================================================================
# `off` keeps the original single `meter_entity` path, so an existing
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
@@ -139,209 +142,62 @@ options:
# nothing at all for a repeated reading - so it is the only mode a firmware
# watchdog may threshold. See DOCS.md.
meter_source: "off"
meter_entity: sensor.p1_meter_active_power
meter_invert: false
meter_phases: 1
meter_max_age_s: 30
meter_mqtt_topic: ""
# homewizard_local only. The meter's own address, `host` or `host:port`.
p1_host: ""
# homewizard_local only. Seconds between polls. Must be well under
# meter_max_age_s - the age is never fresher than this interval - and there is
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
meter_poll_s: 5
meter_max_age_s: 30
meter_mqtt_topic: ""
# The two UNSIGNED Belgian registers. The EMS derives net power from them
# (import - export); do NOT point these at a signed template sensor.
p1_import_entity: ""
p1_export_entity: ""
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
# three-phase connection; the list length must equal meter_phases.
p1_phase_import_entities: []
p1_phase_export_entities: []
# ha_signed only. ONE signed net-power sensor: positive = import from the
# grid, negative = export to it. Do NOT split it into two template sensors -
# the split is done in the add-on (p1.split_signed) precisely so the sign
# convention is tested rather than living in unreviewed YAML.
p1_net_entity: ""
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
# three-phase connection; the list length must equal meter_phases.
p1_phase_import_entities: []
p1_phase_export_entities: []
# Optional, in L1..L3 order, each one signed the same way. Same role as
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
# connection. The list length must equal meter_phases.
p1_phase_net_entities: []
# --- control ---------------------------------------------------------------
# ==========================================================================
# RATINGS AND LIMITS - what this site is allowed to do
# ==========================================================================
max_w: 2000
gain: 0.6
slew_w: 1000
deadband_w: 15
target_grid_w: -10
step_w: 10
saturation_w: 500
saturation_cycles: 3
integrator_max_w: 0
heartbeat_s: 10
stale_input_s: 15
cycle_floor_s: 15
auto_start: false
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
# a failsafe that latches until an operator starts control again.
# The pack's OWN cut-out percent - what the BMS enforces whatever
# `peak_soc_floor` is set to. Register 0x0560 reads 10 on the reference
# GW5000S-BP, consistent with DoD 90. It is here so validate.py can REFUSE a
# peak_soc_floor at or below it: such a floor can never bind, the BMS wins,
# and the status page would report an energy backstop that does not exist.
#
# Both defaults are measured, not chosen. On the captured healthy day
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
# not caught up with - is 30 s, and sign agreement over the gated samples is
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
# fault day still trips 13 times. There is no reason to move either number
# without a capture that says so. They are options because the AC says
# thresholds are defaults rather than constants - not because they are knobs
# anyone is expected to turn.
#
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
# arbiter bounds the command to +-max_w). validate.py refuses that config.
sign_disagree_w: 300
sign_disagree_s: 180
# --- SAFETY-30 per-inverter sign-disagreement watchdog ---------------------
# ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS
# ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades
# the SITE's total command against P1 - one meter, one grid connection. This
# pair grades EACH inverter's own share against THAT inverter's own measured
# power (holding register 0x0518), and it catches the fault the meter cannot
# answer for once there are two units: a unit doing the opposite of what it
# was told, while its sibling covers for it and P1 reads exactly what a
# healthy site reads.
#
# At ONE inverter these two keys do nothing at all. §6.2's identity makes the
# site's total command that inverter's command, so the pair above is already
# asking this question of the same actuator, and arming a second detector
# would be two latches and two banners for one fault.
#
# Both defaults are measured on admin/goodwe's two-master rig, over 1890
# closed-loop runs in which nothing is faulty, swept across the whole range
# this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so
# the smallest divergence this can see is exactly the gap at which the control
# law itself already says the inverter is not following; 85 s is 8.5x the
# worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's
# own 30 s link veto, and under half the 180 s the site check takes. See
# app/watchdog.py's SIGN_OPTION_DEFAULTS and
# DECISIONS/SAFETY-30-per-unit-sign.md.
#
# ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason:
# a share is bounded to +-max_w, so a threshold at or above it can never fire.
# validate.py refuses that config.
unit_sign_disagree_w: 250
unit_sign_disagree_s: 85
# --- SAFETY-30 SITE sign-disagreement watchdog -----------------------------
# ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS
# BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the
# standing commands is graded against P1, and the trip stops the whole site -
# what it catches is the site being asked for the wrong thing, which every
# per-inverter check is blind to because each unit is faithfully executing its
# share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is
# already asking this of the same actuator.
#
# The threshold is the shipped 300 W, CARRIED, and the reason is that the
# argument behind it does not depend on the roster: near zero the sign is
# noise, the deadband is 15 W, and there is one P1 whatever is behind it.
#
# ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s
# exists to reject is the loop crossing zero after a load reversal, and the
# command has to travel the whole SITE rail at `slew_w` per cycle - a rail
# that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in
# which nothing is faulty, the worst continuous legitimate opposition at 300 W
# is 110 s at one inverter and 215 s at two: carrying 180 s across would have
# shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary
# 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter,
# and still inside every episode of the 2026-08-20 fault day.
site_sign_disagree_w: 300
site_sign_disagree_s: 325
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
# Seconds the meter may keep answering with an UNCHANGED measurement while the
# command stands at the rail before the add-on raises an alarm. Both halves
# are required: neither one alone is safe, and app/freeze.py carries why.
#
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
# the battery; a false notification costs an alert.
#
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
# only transport that reads the meter's own document. On every other source
# the status page says NOT COVERED rather than "armed".
#
# 300 s is measured, not chosen. Over 2399 s of the live house at the
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
# still inside the 900 s quarter that bills the capacity tariff.
meter_freeze_s: 300
# --- SAFETY-31: circulating power ------------------------------------------
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
# Dockerfile does not put this file in the image, so the running add-on cannot
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
# goes red in CI rather than shipping. Add a third option in three places -
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
#
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
# and is therefore invisible to every other guard in this add-on. Both numbers
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
# in which nothing was faulty; circulation.py carries the curve they were read
# off and the argument for each. They are NOT sensitivity preferences.
#
# Both are ignored at one inverter, where the quantity is identically zero.
circulation_w: 700
circulation_hold_s: 75
# --- maintenance -----------------------------------------------------------
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
# so nothing else in the add-on gets an opinion about what a missing
# `maintenance_*` key means. There is exactly ONE in-code copy -
# `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does
# not put this file in the image and the running add-on therefore cannot read
# it; test_maintenance.py pins that copy by READING these lines, so a drift
# goes red in CI rather than shipping. main.py and validate.py both read the
# mirror and type no number of their own.
#
# Before this card the same defaults were written down three times and had
# already disagreed: this block said 2000 while main.py and MaintConfig said
# 2500, so an options dict with the key ABSENT passed validation at 2000 and
# then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter
# contradiction. Changing one of these numbers now moves every consumer.
maintenance_enabled: false
maintenance_interval_days: 28
maintenance_start_hour: 10
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
# than the 2500 they shipped as. The cheap-window charge shaping asks the
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
# default install. validate.py refuses that config (rule 6), so these
# defaults had to become self-consistent with the max_w above rather than
# ship a configuration the validator rejects.
#
# The discharge side is clamped rather than contradicted - it drains at max_w
# and the arbiter says so - so it is only warned about; it is 2000 here for
# symmetry and to keep the shipped config warning-free.
#
# Raising max_w (the live reference install runs 5000) is what unlocks a
# faster maintenance cycle. Raise max_w FIRST, then these.
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
maintenance_soc_floor: 11
maintenance_soc_target: 99
maintenance_hold_min: 120
# --- tariff / capacity tariff (all optional) ------------------------------
peak_forecast_entity: ""
# ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER
# shape's number and nothing more. Declare an `inverters:` roster and each
# entry states its own `bms_soc_floor`; this one is not read at all and is
# not inherited by any entry. Rule 8 then grades every unit separately, which
# is the whole of that card: one cut-out for two packs of different models
# protects the shallower pack with the deeper pack's number.
bms_soc_floor: 10
peak_cap_w: 3500
peak_forecast_entity: ""
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
# The peak guard can COMMAND a discharge to hold a billed quarter under
# peak_cap_w. Until this card it was bounded from below by nothing but the
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
@@ -385,38 +241,10 @@ options:
# loop-side reserve and is waiting on the product owner. Until that is
# answered, the BMS is the only floor under self-consumption.
peak_soc_floor: 20
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
# it: such a floor can never bind, the BMS wins, and the status page would
# report an energy backstop that does not exist.
#
# ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER
# shape's number and nothing more. Declare an `inverters:` roster and each
# entry states its own `bms_soc_floor`; this one is not read at all and is
# not inherited by any entry. Rule 8 then grades every unit separately, which
# is the whole of that card: one cut-out for two packs of different models
# protects the shallower pack with the deeper pack's number.
bms_soc_floor: 10
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
# add-on delegating the whole price question to Home Assistant: main.py reads
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
# nothing except the maintenance cycle's cheap-window charge floor. That is
# all they have ever done. They are kept, because retiring them would take
# the maintenance cycle's only price signal away on a live install before
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
# are the cards that replace it, and neither has landed.
#
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
# one site, one of which silently wins, is exactly the "double up" the PM
# named; validate.py rule 7 refuses that configuration at startup rather than
# letting an operator believe the provider they configured is driving
# something. See app/validate.py.
price_now_entity: ""
price_avg_entity: ""
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
# ==========================================================================
# TARIFF - what energy costs, and whether to trade on it
# ==========================================================================
# `off` is the shipped default and means the §11.4 no-price-data row applies:
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
@@ -435,12 +263,12 @@ options:
price_provider: "off"
price_markup_eur_kwh: 0.03
price_taxes_eur_kwh: 0.06
price_csv_path: "/share/goodwe_prices.csv"
price_feed_url: ""
price_horizon_h: 48
price_timeout_s: 10
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
# which by itself strongly favours self-consumption over export. A scalar, not
# a second series: no shipped supplier publishes a dynamic injection curve.
export_price_eur_kwh: 0.04
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
# of the installed base is on [a static profile]" - this is what makes those
# sites first-class rather than "price_provider: off, good luck".
@@ -470,13 +298,28 @@ options:
# to fall back to and a dead feed leaves the site with no tariff at all.
# validate.py warns about exactly that.
static_profile: ""
price_csv_path: "/share/goodwe_prices.csv"
price_feed_url: ""
price_horizon_h: 48
price_timeout_s: 10
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
# which by itself strongly favours self-consumption over export. A scalar, not
# a second series: no shipped supplier publishes a dynamic injection curve.
export_price_eur_kwh: 0.04
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
# add-on delegating the whole price question to Home Assistant: main.py reads
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
# nothing except the maintenance cycle's cheap-window charge floor. That is
# all they have ever done. They are kept, because retiring them would take
# the maintenance cycle's only price signal away on a live install before
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
# are the cards that replace it, and neither has landed.
#
# ⚠️ They may NOT be combined with `price_provider`. Two price sources on
# one site, one of which silently wins, is exactly the "double up" the PM
# named; validate.py rule 7 refuses that configuration at startup rather than
# letting an operator believe the provider they configured is driving
# something. See app/validate.py.
price_now_entity: ""
price_avg_entity: ""
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
# when the spread covers what the round trip costs. Off in the shipped
# configuration: this is the first strategy in the add-on that moves energy
@@ -497,10 +340,12 @@ options:
# owner; the code is correct at any value and no number was picked to make a
# trade appear.
arbitrage_enabled: false
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
# - the wear on the pack plus the conversion losses. A spread below this is
# a cycle that loses money and pack life, so it is not taken.
cycle_cost_threshold_eur: 0.10
# The reserve arbitrage may not spend, and the headroom it may not overfill.
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
# balancing cycle, and pointing a trading strategy at them would couple two
@@ -508,17 +353,189 @@ options:
arbitrage_soc_floor: 20
arbitrage_soc_ceiling: 95
# --- site ------------------------------------------------------------------
estop_fitted: false
# ==========================================================================
# MAINTENANCE - the monthly battery recalibration cycle
# ==========================================================================
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
# so nothing else in the add-on gets an opinion about what a missing
# `maintenance_*` key means. There is exactly ONE in-code copy -
# `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does
# not put this file in the image and the running add-on therefore cannot read
# it; test_maintenance.py pins that copy by READING these lines, so a drift
# goes red in CI rather than shipping. main.py and validate.py both read the
# mirror and type no number of their own.
#
# Before this card the same defaults were written down three times and had
# already disagreed: this block said 2000 while main.py and MaintConfig said
# 2500, so an options dict with the key ABSENT passed validation at 2000 and
# then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter
# contradiction. Changing one of these numbers now moves every consumer.
maintenance_enabled: false
maintenance_interval_days: 28
maintenance_start_hour: 10
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
# than the 2500 they shipped as. The cheap-window charge shaping asks the
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
# default install. validate.py refuses that config (rule 6), so these
# defaults had to become self-consistent with the max_w above rather than
# ship a configuration the validator rejects.
#
# The discharge side is clamped rather than contradicted - it drains at max_w
# and the arbiter says so - so it is only warned about; it is 2000 here for
# symmetry and to keep the shipped config warning-free.
#
# Raising max_w (the live reference install runs 5000) is what unlocks a
# faster maintenance cycle. Raise max_w FIRST, then these.
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
maintenance_soc_floor: 11
maintenance_soc_target: 99
maintenance_hold_min: 120
# ==========================================================================
# SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change
# one without re-deriving it
# ==========================================================================
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
# a failsafe that latches until an operator starts control again.
#
# Both defaults are measured, not chosen. On the captured healthy day
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
# not caught up with - is 30 s, and sign agreement over the gated samples is
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
# fault day still trips 13 times. There is no reason to move either number
# without a capture that says so. They are options because the AC says
# thresholds are defaults rather than constants - not because they are knobs
# anyone is expected to turn.
#
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
# arbiter bounds the command to +-max_w). validate.py refuses that config.
sign_disagree_w: 300
sign_disagree_s: 180
# ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS
# ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades
# the SITE's total command against P1 - one meter, one grid connection. This
# pair grades EACH inverter's own share against THAT inverter's own measured
# power (holding register 0x0518), and it catches the fault the meter cannot
# answer for once there are two units: a unit doing the opposite of what it
# was told, while its sibling covers for it and P1 reads exactly what a
# healthy site reads.
#
# At ONE inverter these two keys do nothing at all. §6.2's identity makes the
# site's total command that inverter's command, so the pair above is already
# asking this question of the same actuator, and arming a second detector
# would be two latches and two banners for one fault.
#
# Both defaults are measured on admin/goodwe's two-master rig, over 1890
# closed-loop runs in which nothing is faulty, swept across the whole range
# this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so
# the smallest divergence this can see is exactly the gap at which the control
# law itself already says the inverter is not following; 85 s is 8.5x the
# worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's
# own 30 s link veto, and under half the 180 s the site check takes. See
# app/watchdog.py's SIGN_OPTION_DEFAULTS and
# DECISIONS/SAFETY-30-per-unit-sign.md.
#
# ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason:
# a share is bounded to +-max_w, so a threshold at or above it can never fire.
# validate.py refuses that config.
unit_sign_disagree_w: 250
unit_sign_disagree_s: 85
# ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS
# BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the
# standing commands is graded against P1, and the trip stops the whole site -
# what it catches is the site being asked for the wrong thing, which every
# per-inverter check is blind to because each unit is faithfully executing its
# share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is
# already asking this of the same actuator.
#
# The threshold is the shipped 300 W, CARRIED, and the reason is that the
# argument behind it does not depend on the roster: near zero the sign is
# noise, the deadband is 15 W, and there is one P1 whatever is behind it.
#
# ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s
# exists to reject is the loop crossing zero after a load reversal, and the
# command has to travel the whole SITE rail at `slew_w` per cycle - a rail
# that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in
# which nothing is faulty, the worst continuous legitimate opposition at 300 W
# is 110 s at one inverter and 215 s at two: carrying 180 s across would have
# shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary
# 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter,
# and still inside every episode of the 2026-08-20 fault day.
site_sign_disagree_w: 300
site_sign_disagree_s: 325
# Seconds the meter may keep answering with an UNCHANGED measurement while the
# command stands at the rail before the add-on raises an alarm. Both halves
# are required: neither one alone is safe, and app/freeze.py carries why.
#
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
# the battery; a false notification costs an alert.
#
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
# only transport that reads the meter's own document. On every other source
# the status page says NOT COVERED rather than "armed".
#
# 300 s is measured, not chosen. Over 2399 s of the live house at the
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
# still inside the 900 s quarter that bills the capacity tariff.
meter_freeze_s: 300
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
# Dockerfile does not put this file in the image, so the running add-on cannot
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
# goes red in CI rather than shipping. Add a third option in three places -
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
#
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
# and is therefore invisible to every other guard in this add-on. Both numbers
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
# in which nothing was faulty; circulation.py carries the curve they were read
# off and the argument for each. They are NOT sensitivity preferences.
#
# Both are ignored at one inverter, where the quantity is identically zero.
circulation_w: 700
circulation_hold_s: 75
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
auto_start: false
gain: 0.6
slew_w: 1000
deadband_w: 15
target_grid_w: -10
step_w: 10
saturation_w: 500
saturation_cycles: 3
integrator_max_w: 0
heartbeat_s: 10
stale_input_s: 15
cycle_floor_s: 15
log_level: info
schema:
meter_entity: str
meter_invert: bool
# ==========================================================================
# HARDWARE - the inverter, its actuator and its own sensors
# ==========================================================================
setpoint_entity: str
soc_entity: str
batt_entity: str
batt_invert: bool
setpoint_entity: str
# CFG-04. THE ONE LIST-OF-OBJECTS IN THIS FILE, and see the options block for
# the measurement that says Supervisor 2026.08.0 accepts it: nested types are
@@ -547,40 +564,116 @@ schema:
batt_entity: str?
batt_invert: bool?
estop_fitted: bool
# ==========================================================================
# METER - where the grid reading comes from
# ==========================================================================
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
meter_entity: str
meter_invert: bool
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
# telegram whose phase count disagrees is rejected at ingest and logged, so a
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
# quietly wrong number.
meter_phases: int(1,3)
meter_max_age_s: int(5,300)
meter_poll_s: int(1,60)
meter_mqtt_topic: str?
p1_host: str?
meter_poll_s: int(1,60)
meter_max_age_s: int(5,300)
meter_mqtt_topic: str?
p1_import_entity: str?
p1_export_entity: str?
p1_net_entity: str?
p1_phase_import_entities:
- str
p1_phase_export_entities:
- str
p1_net_entity: str?
p1_phase_net_entities:
- str
# ==========================================================================
# RATINGS AND LIMITS - what this site is allowed to do
# ==========================================================================
max_w: int(100,5000)
gain: float(0.05,1.0)
slew_w: int(50,5000)
deadband_w: int(0,500)
target_grid_w: float(-200,200)
step_w: int(1,100)
saturation_w: int(100,2000)
saturation_cycles: int(1,10)
integrator_max_w: int(0,15000)
heartbeat_s: int(2,25)
stale_input_s: int(5,120)
cycle_floor_s: int(5,60)
auto_start: bool
bms_soc_floor: int(0,50)
peak_cap_w: int(500,15000)
peak_forecast_entity: str?
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
# a supported way to have no energy bound at all. The ranges OVERLAP on
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
# rule 8 is what refuses the pair, with a message naming both numbers.
peak_soc_floor: int(5,60)
# ==========================================================================
# TARIFF - what energy costs, and whether to trade on it
# ==========================================================================
# ⚠️ The provider list is written out rather than left as `str`, so the
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
# checks reach validate_startup_config() with a dict the Supervisor never saw.
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
# else, which is AC2.
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
# gone below zero (you pay to export at midday in summer), and a schema that
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
# nested schema is a config.yaml change and no code change at all.
static_profile: str?
price_csv_path: str?
price_feed_url: str?
price_horizon_h: int(1,72)
price_timeout_s: int(2,60)
price_now_entity: str?
price_avg_entity: str?
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
# and a negative one is a threshold every spread clears - arbitrage with the
# brake removed. app/arbitrage.py refuses it in code too, because
# validate_startup_config() is also sim/inject.py's entry point and can be
# handed any dict at all.
arbitrage_enabled: bool
cycle_cost_threshold_eur: float(0,1)
arbitrage_soc_floor: int(5,95)
arbitrage_soc_ceiling: int(10,100)
# ==========================================================================
# MAINTENANCE - the monthly battery recalibration cycle
# ==========================================================================
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
# the Supervisor is what enforces a range and the Supervisor reads only this
# file, so a second copy of `int(500,5000)` in Python would be a place to
# disagree and nothing else. What is NOT expressible here is a relation
# BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the
# schema grammar has no way to say so, and that is why validate.py's rule 6 is
# written in code instead. Add a ninth maintenance option in three places -
# options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth.
maintenance_enabled: bool
maintenance_interval_days: int(1,90)
maintenance_start_hour: int(0,23)
maintenance_discharge_w: int(500,5000)
maintenance_charge_w: int(500,5000)
maintenance_soc_floor: int(5,30)
maintenance_soc_target: int(50,100)
maintenance_hold_min: int(5,480)
# ==========================================================================
# SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change
# one without re-deriving it
# ==========================================================================
# No "off" value, deliberately: this is a P0 safety check and the schema is
# not the place to make disarming it a supported configuration.
sign_disagree_w: int(50,5000)
@@ -596,6 +689,7 @@ schema:
# this range puts the guard outside the regime it can be answered for, and the
# floor does not have to be a knee the way `circulation_w`'s does.
unit_sign_disagree_w: int(50,5000)
# The floor of 30 is `inverter.LINK_STALE_S`, the firmware's own veto window:
# below it, a unit not executing its share is a LINK fault the add-on already
# answers by dropping the unit from the split, and firing inside that window
@@ -636,6 +730,7 @@ schema:
# fault. A configurable value under 400 W would be a false-alarm generator
# that this project's own doctrine (SAFETY-15, SAFETY-19) forbids shipping.
circulation_w: int(400,5000)
# The floor of 30 is `inverter.LINK_STALE_S`: below the firmware's own veto
# window a unit still executing a stale share is a LINK fault the add-on
# already handles by dropping it from the split, and firing inside that window
@@ -644,68 +739,19 @@ schema:
# loop must be caught and acted on well inside one.
circulation_hold_s: int(30,900)
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
# the Supervisor is what enforces a range and the Supervisor reads only this
# file, so a second copy of `int(500,5000)` in Python would be a place to
# disagree and nothing else. What is NOT expressible here is a relation
# BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the
# schema grammar has no way to say so, and that is why validate.py's rule 6 is
# written in code instead. Add a ninth maintenance option in three places -
# options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth.
maintenance_enabled: bool
maintenance_interval_days: int(1,90)
maintenance_start_hour: int(0,23)
maintenance_discharge_w: int(500,5000)
maintenance_charge_w: int(500,5000)
maintenance_soc_floor: int(5,30)
maintenance_soc_target: int(50,100)
maintenance_hold_min: int(5,480)
peak_forecast_entity: str?
peak_cap_w: int(500,15000)
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
# a supported way to have no energy bound at all. The ranges OVERLAP on
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
# rule 8 is what refuses the pair, with a message naming both numbers.
peak_soc_floor: int(5,60)
bms_soc_floor: int(0,50)
price_now_entity: str?
price_avg_entity: str?
# ⚠️ The provider list is written out rather than left as `str`, so the
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
# checks reach validate_startup_config() with a dict the Supervisor never saw.
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
# else, which is AC2.
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
price_csv_path: str?
price_feed_url: str?
price_horizon_h: int(1,72)
price_timeout_s: int(2,60)
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
# nested schema is a config.yaml change and no code change at all.
static_profile: str?
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
# gone below zero (you pay to export at midday in summer), and a schema that
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
# and a negative one is a threshold every spread clears - arbitrage with the
# brake removed. app/arbitrage.py refuses it in code too, because
# validate_startup_config() is also sim/inject.py's entry point and can be
# handed any dict at all.
arbitrage_enabled: bool
cycle_cost_threshold_eur: float(0,1)
arbitrage_soc_floor: int(5,95)
arbitrage_soc_ceiling: int(10,100)
estop_fitted: bool
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
auto_start: bool
gain: float(0.05,1.0)
slew_w: int(50,5000)
deadband_w: int(0,500)
target_grid_w: float(-200,200)
step_w: int(1,100)
saturation_w: int(100,2000)
saturation_cycles: int(1,10)
integrator_max_w: int(0,15000)
heartbeat_s: int(2,25)
stale_input_s: int(5,120)
cycle_floor_s: int(5,60)
log_level: list(trace|debug|info|warning|error)
+361
View File
@@ -0,0 +1,361 @@
"""UX-01 - the Configuration tab's labels, and the check that stops them rotting.
RUN: python3 -B test_translations.py (no third-party imports; the add-on
CI image is Alpine + aiohttp + paho)
WHAT THIS FILE IS FOR. Before UX-01 there was no `translations/` directory at
all, so Home Assistant rendered the raw option KEYS - `unit_sign_disagree_w`,
`saturation_cycles`, `integrator_max_w` - for all seventy options, in
`schema:` order, with no description. The two files this grades are what turn
that into sentences, and the highest-value half of them is not the label: it is
the sentence that says WHETHER TO TOUCH THE VALUE, because the tab presents a
constant derived from 1890 closed-loop test runs and the operator's own inverter
rating as the same kind of field.
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE
CITATIONS - that every option has a name and a description in both languages,
that no translation names an option that does not exist, and that each derived
constant's English description cites the record it was derived from. It does NOT
grade whether a sentence is true, whether the Dutch is good Dutch, or whether
the result looks right on screen. The last of those is measured, not asserted,
by `tools/supervisor_shape_probe.py` section F, which loads these files with
Supervisor 2026.08.0's OWN loader out of the release image, runs its OWN
`UiOptions`, replays the frontend's label resolution as the shipped bundle
writes it, and prints every rendered row. That transcript is the evidence; this
file is the gate that keeps it true.
⚠️ AND THE PARSER IS THE OTHER HALF OF THAT PAIR. There is no PyYAML in the CI
image and a check that skips on ImportError is not a check, so
`tools/translations_lint.py` parses these files by hand. A hand parser that
quietly disagrees with the real loader is exactly the defect this repository
keeps finding, so the probe imports THAT SAME MODULE and asserts, key by key and
string by string, that it returns what `supervisor.store.data.
_read_app_translations()` returns. Part 5 below re-states the arrangement so a
reader of this file is told where the licence for the hand parser comes from.
MUTATION.md is the method for the ledger in part 6; `tools/mutation_run.sh` is
the harness, and every row there was RUN rather than reasoned about.
"""
import io
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
import translations_lint as TL # noqa: E402
FAILED = 0
def check(what, ok):
global FAILED
print((" ok " if ok else " FAIL ") + what)
if not ok:
FAILED += 1
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
ORDER, NESTED = TL.config_surface(CFG_TEXT)
LANGS = ("en", "nl")
TR = {lg: TL.parse_translation_file(HERE / "translations" / f"{lg}.yaml")
for lg in LANGS}
print("UX-01 - add-on option translations\n")
print("1. the surface being described")
print(f" config.yaml declares {len(ORDER)} top-level options under `schema:` "
f"and {sum(len(v) for v in NESTED.values())} per-entry key(s) under "
f"{sorted(NESTED)}")
# ⚠️ A FLOOR, NOT AN EQUALITY. This is the "did the reader read anything at all"
# line: `config_surface()` returning an empty list would make every coverage
# check below vacuously true, which is the third failure mode in MUTATION.md -
# the assertion runs, the sentence is true, and the data cannot reach the
# failure it names.
check(f"the option surface was actually read - {len(ORDER)} options, not zero",
len(ORDER) >= 60 and "inverters" in ORDER and "max_w" in ORDER)
check("...and the repeatable `inverters:` section's per-entry keys were read "
"too, which is the half a top-level-only reader would miss",
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
and len(NESTED["inverters"]) == 7)
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
"languages")
for lg in LANGS:
problems = TL.audit(CFG_TEXT, TR[lg], lg)
print(f" {lg}: {len(TR[lg]['configuration'])} options translated, "
f"{len(problems)} problem(s)")
for p in problems[:10]:
print(" - " + p)
check(f"`{lg}` covers config.yaml exactly: no option without a translation, "
f"no translation without an option, every one with a name AND a "
f"description. Problems: {problems[:3]}", not problems)
# ⚠️ THE `nl` HALF IS NOT OPTIONAL EVEN THOUGH THE FALLBACK IS. The frontend
# resolves `translations[lang]?.name || translations["en"]?.name || key`, PER
# KEY - measured off the shipped bundle, see the probe - so a MISSING nl key
# degrades to English and a WRONG one does not degrade at all. The card asks for
# both languages complete, and complete is a thing a check can hold.
check("...and the two files describe the SAME option set, so nothing on the "
"owner's Dutch screen quietly falls back to English",
set(TR["en"]["configuration"]) == set(TR["nl"]["configuration"]))
check("...including inside the repeatable section",
set(TR["en"]["configuration"]["inverters"]["fields"])
== set(TR["nl"]["configuration"]["inverters"]["fields"]))
check("...and no Dutch string was left as a copy of the English one, which is "
"what an untranslated key looks like when it is present rather than "
"absent",
not [k for k in ORDER
if TR["en"]["configuration"][k]["description"]
== TR["nl"]["configuration"][k]["description"]])
print("\n3. ⭐ the description says WHETHER TO TOUCH IT")
# ⚠️ THE HIGHEST-VALUE ITEM IN THE CARD, AND THE ONE A COVERAGE COUNT CANNOT
# REACH. Sixty-odd descriptions that all say "the threshold, in watts" would
# pass part 2 completely. What makes this surface safe is that a DERIVED
# constant names the campaign it came from and says the value is not a knob,
# while a number that really is the operator's says so too.
#
# ⚠️ AND THIS IS A TEXT CHECK, WHICH IS WEAK ON PURPOSE AND IS SAID TO BE. It
# cannot know whether a citation is TRUE; it can only know that the citation is
# there and names the right record, so that deleting the provenance - or
# renaming the card it points at - goes red instead of shipping. The table is
# written out rather than derived because there is nothing in config.yaml that
# distinguishes a derived constant from an operator's number; that distinction
# is the whole of what this card had to add.
DERIVED = {
"sign_disagree_w": ("SAFETY-02", "MEASURED"),
"sign_disagree_s": ("SAFETY-02", "MEASURED"),
"unit_sign_disagree_w": ("SAFETY-30", "1890"),
"unit_sign_disagree_s": ("SAFETY-30", "1890"),
"site_sign_disagree_w": ("SAFETY-30", "CARRIED"),
"site_sign_disagree_s": ("SAFETY-30", "1890"),
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
"circulation_w": ("SAFETY-31", "924"),
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
"bms_soc_floor": ("0x0560", "MEASURED"),
"integrator_max_w": ("MEASURED", "Do not raise it"),
"gain": ("MEASURED ON", "do not raise it"),
}
_uncited = []
for key, needles in sorted(DERIVED.items()):
desc = TR["en"]["configuration"][key]["description"]
for needle in needles:
if needle not in desc:
_uncited.append(f"{key} does not cite {needle!r}")
print(f" {len(DERIVED)} derived constants must cite their campaign; "
f"{len(_uncited)} do not")
check(f"every derived constant's English description names WHERE THE NUMBER "
f"CAME FROM, so an operator reading the tab can tell it apart from their "
f"own inverter rating. Uncited: {_uncited}", not _uncited)
# The other direction, and it matters as much: a number that IS the operator's
# must not be dressed up as derived, or the labels stop meaning anything.
_theirs = ["max_w", "peak_cap_w"]
check("...and the numbers that really are the OPERATOR'S say so instead of "
"carrying a citation - `max_w` and `peak_cap_w` are the two the card "
"names, and both read 'THIS IS YOUR NUMBER'",
all("THIS IS YOUR NUMBER" in TR["en"]["configuration"][k]["description"]
for k in _theirs))
check("...and the one threshold that is NOT derived from a measurement admits "
"it rather than borrowing the others' authority - "
"`cycle_cost_threshold_eur` is an open product question (OPT-08)",
"NOT DERIVED FROM A MEASUREMENT"
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
and "OPT-08"
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"])
print("\n4. task ordering, and the labels that carry it")
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced
# flag, no units on the input. Grouping is approximated by ORDER plus label text
# and by nothing else, so both halves are graded: the label prefix, and that the
# prefixes appear in the file as CONTIGUOUS RUNS in the task order. A group
# whose members are scattered renders as a jumble however good each label is.
GROUPS = ["Hardware", "Meter", "Limits", "Tariff", "Maintenance", "Safety",
"Advanced"]
NL_GROUPS = {"Hardware": "Hardware", "Meter": "Meter", "Limits": "Grenzen",
"Tariff": "Tarief", "Maintenance": "Onderhoud",
"Safety": "Veiligheid", "Advanced": "Geavanceerd"}
_prefixes = []
for key in ORDER:
name = TR["en"]["configuration"][key]["name"]
_prefixes.append(name.split(" \u00b7 ")[0] if " \u00b7 " in name else f"<none:{key}>")
_runs = [p for i, p in enumerate(_prefixes) if i == 0 or p != _prefixes[i - 1]]
print(f" the rendered order groups into: {_runs}")
check(f"every label carries a group prefix, so the flat list reads as groups. "
f"Missing: {[p for p in _prefixes if p.startswith('<none:')]}",
not [p for p in _prefixes if p.startswith("<none:")])
check(f"...and each group is ONE CONTIGUOUS RUN in the rendered order, in the "
f"task order the card asks for - hardware, meter, ratings and limits, "
f"tariff, maintenance, safety, advanced. Got: {_runs}", _runs == GROUPS)
_nl_bad = [k for k in ORDER
if NL_GROUPS.get(TR["en"]["configuration"][k]["name"].split(" \u00b7 ")[0])
!= TR["nl"]["configuration"][k]["name"].split(" \u00b7 ")[0]]
check(f"...and the Dutch labels carry the SAME grouping under Dutch names, so "
f"the two languages do not read as two different layouts. Off: {_nl_bad}",
not _nl_bad)
# ⚠️ THE ORDER IS THE `schema:` BLOCK'S, NOT THE `options:` BLOCK'S, AND THAT IS
# A MEASUREMENT. `App.schema_ui` is `UiOptions(coresys)(data["schema"])`, so the
# tab renders the schema half in its own order. Asserted here because the whole
# of part 4 is about an ordering that would otherwise be graded on the wrong
# half of the file - and because the two halves of config.yaml were already in
# different orders before this card.
# ⚠️ ANCHORED ON THE INDENTATION AND SCOPED TO THE `options:` BLOCK. The file
# starts with the add-on manifest, whose `services:` list carries ` - mqtt:want`
# at two spaces with a colon in it - a looser reader picks that up as an option
# called `- mqtt` and the comparison below then fails for a reason that has
# nothing to do with what it is watching. Measured, on the first run.
import re as _re # noqa: E402
_opt_half = CFG_TEXT[CFG_TEXT.index("\noptions:\n"):CFG_TEXT.index("\nschema:")]
_opt_order = _re.findall(r"^ ([a-z_0-9]+):", _opt_half, _re.M)
check("config.yaml's two halves now declare the same options in the same "
"order, so the file reads the way the tab renders even though only the "
"schema half decides it", _opt_order == ORDER)
print("\n5. the parser is strict, so a file it half-read cannot pass")
# ⚠️ THE FAILURE THIS PART EXISTS FOR. A permissive parser answers "no problems"
# for a file it silently skipped half of - the same green-and-not-watching
# defect one layer down. So every shape outside the subset UX-01 writes must
# RAISE, and that is graded with real malformed input rather than asserted.
BAD = [
("a tab-indented line", "configuration:\n\tmeter_entity:\n"),
("three-space indent", "configuration:\n meter_entity:\n"),
("a bare unquoted scalar", 'configuration:\n a:\n name: hello\n'),
("a single-quoted scalar", "configuration:\n a:\n name: 'hello'\n"),
("a list item", 'configuration:\n a:\n - "x"\n'),
("a duplicate key", 'configuration:\n a:\n name: "x"\n name: "y"\n'),
("an over-indented child", 'configuration:\n a:\n'),
("a line that is not a key at all", "configuration:\n just some prose\n"),
("an unsupported escape", 'configuration:\n a:\n name: "x\\ty"\n'),
]
_accepted = []
for what, text in BAD:
try:
TL.parse_translation_text(text, what)
_accepted.append(what)
except TL.TranslationSyntaxError:
pass
check(f"every shape outside the accepted subset RAISES rather than being "
f"skipped. Silently accepted: {_accepted}", not _accepted)
check("...and the accepted subset really does round-trip the shipped files - "
"70 options and 7 per-entry keys came back out of each, which is what "
"says the strictness above is not just refusing everything",
all(len(TR[lg]["configuration"]) == len(ORDER)
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
for lg in LANGS))
print(" ⚠️ the licence for this hand parser is NOT this file. "
"tools/supervisor_shape_probe.py section F loads the same two files with "
"`supervisor.store.data._read_app_translations()` out of the 2026.08.0 "
"release image and asserts, key by key and string by string, that this "
"module returns the same thing. Re-run it when either changes.")
print("\n6. MUTATION: the check is run against broken inputs, both directions")
# ⚠️ THIS IS THE PART THE CARD ASKS FOR IN SO MANY WORDS, AND IT IS RUN RATHER
# THAN DESCRIBED. A coverage check is worth exactly what it catches, so each
# mutation below is APPLIED to a copy of the real parsed translations and the
# audit is required to NAME the key it broke - not merely to be non-empty. A
# check that goes red for the wrong reason is a check that will go green for the
# wrong reason later.
import copy # noqa: E402
def _mutate(what, fn, must_name):
tr = copy.deepcopy(TR["en"])
fn(tr)
problems = TL.audit(CFG_TEXT, tr, "en")
named = [p for p in problems if must_name in p]
print(f" {what}\n -> {len(problems)} problem(s); "
f"{(named[0][:120] + '...') if named else 'NONE NAMED ' + must_name}")
check(f"MUTATION: {what} -> the audit REFUSES and names `{must_name}`",
bool(named))
_mutate("delete one option's whole translation (`saturation_cycles`)",
lambda tr: tr["configuration"].pop("saturation_cycles"),
"saturation_cycles")
_mutate("delete one option's description but keep its name (`meter_phases`)",
lambda tr: tr["configuration"]["meter_phases"].pop("description"),
"meter_phases")
_mutate("blank one option's description (`gain`) - present but empty is not "
"covered either",
lambda tr: tr["configuration"]["gain"].update(description=" "),
"gain")
_mutate("translate an option that does not exist (`saturation_cyles`, one "
"letter out - the rename this check exists for)",
lambda tr: tr["configuration"].update(
saturation_cyles={"name": "x", "description": "y"}),
"saturation_cyles")
_mutate("delete one PER-ENTRY key of the repeatable section (`bms_soc_floor`)",
lambda tr: tr["configuration"]["inverters"]["fields"].pop("bms_soc_floor"),
"bms_soc_floor")
_mutate("invent a per-entry key the schema does not declare (`max_kw`)",
lambda tr: tr["configuration"]["inverters"]["fields"].update(
max_kw={"name": "x", "description": "y"}),
"max_kw")
_mutate("drop the whole `fields:` block from the repeatable section",
lambda tr: tr["configuration"]["inverters"].pop("fields"),
"inverters")
_mutate("hang a `fields:` block off an option that is not a repeatable section",
lambda tr: tr["configuration"]["max_w"].update(
fields={"x": {"name": "y"}}),
"max_w")
# ⚠️ AND THE TWO THAT COST THE WHOLE FILE RATHER THAN ONE LABEL. Supervisor
# validates each translation file through SCHEMA_APP_TRANSLATIONS and, on
# refusal, LOGS A WARNING AND DROPS ALL OF IT - so a shape it rejects does not
# lose one description, it silently returns the entire tab to raw keys, and the
# only trace is one line in a log nobody is reading. Neither of these is
# reachable through the `configuration:` coverage above, which is exactly why
# they are here.
_mutate("a top-level block Supervisor does not read (`netwrok:`) - the file is "
"DROPPED WHOLE, not partly",
lambda tr: tr.update(netwrok={"x": "y"}), "netwrok")
_mutate("a `network:` block whose value is a mapping rather than a string - "
"same consequence, every label in the file gone",
lambda tr: tr.update(network={"8099/tcp": {"x": "y"}}), "network")
# ⚠️ AND THE MUTATION THAT MATTERS MOST: A NEW OPTION ARRIVES. This is the rot
# the acceptance criterion is written about - somebody adds a key to config.yaml
# in six months and the tab renders it raw. The mutation is applied to the
# CONFIG side rather than the translation side, because that is the direction it
# happens in.
_grown = CFG_TEXT.replace("\n log_level: list(", "\n new_option_s: int(1,9)\n log_level: list(")
check("the config-side mutation really did land - a new option in the schema "
"half that no translation mentions", _grown != CFG_TEXT
and "new_option_s" in TL.config_surface(_grown)[0])
_grow_problems = TL.audit(_grown, TR["en"], "en")
print(f" a new option added to config.yaml -> {_grow_problems[:1]}")
check("MUTATION: adding an option to config.yaml and forgetting the "
"translation goes RED, naming the key - which is the whole of the "
"acceptance criterion about this rotting",
any("new_option_s" in p for p in _grow_problems))
# ⚠️ THE NEGATIVE CONTROL, WITHOUT WHICH THE EIGHT ROWS ABOVE PROVE NOTHING. An
# audit that returned a problem for every input would have "passed" all of them.
check("...and the UNMUTATED input is still clean, so the rows above are the "
"mutations speaking and not a check that always complains",
not TL.audit(CFG_TEXT, TR["en"], "en")
and not TL.audit(CFG_TEXT, TR["nl"], "nl"))
print("\n7. no behaviour and no value moved")
# ⚠️ UX-01 IS A PRESENTATION CARD. It reorders config.yaml and adds two files;
# it must not move a single default. The whole options half is compared against
# the schema half key by key here, and every other suite in this directory pins
# the actual values by reading this same file - test_maintenance.py's eight,
# test_watchdog.py's six, test_price.py's seven, test_freeze.py's, and
# test_peakguard.py's. This line is the cheap one that says the reorder did not
# lose or duplicate a key on the way.
check("no option was lost, gained or duplicated by the reordering: the two "
"halves of config.yaml hold the same 70 keys, once each",
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
check("...and `inverters: []` is still shipped in the options half, which is "
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
"(measured; see DECISIONS/CFG-04-inverter-roster.md B)",
"\n inverters: []\n" in _opt_half)
check("...and the schema half still declares the NESTED per-entry `max_w:` "
"BEFORE the flat one, so test_inverter.py part 12's first-match trap "
"stays baited rather than sorted out by this card's reordering",
CFG_TEXT[CFG_TEXT.index("\nschema:"):].index(" max_w: int(")
< CFG_TEXT[CFG_TEXT.index("\nschema:"):].index("\n max_w: int("))
print()
if FAILED:
print(f"{FAILED} FAILED")
sys.exit(1)
print("all checks passed")
@@ -272,6 +272,180 @@ except Exception as e: # noqa: BLE001
show("Supervisor's UI renderer survives the shape", False, f"{type(e).__name__}: {e}")
print("\n== F. UX-01: the TRANSLATED Configuration tab, rendered ==")
# ⚠️ WHAT THIS SECTION MEASURES, AND WHY IT IS NOT AN ASSERTION ABOUT A FILE.
# UX-01 adds translations/en.yaml and translations/nl.yaml. Whether they reach
# an operator depends on two pieces of somebody else's software, not on us:
#
# 1. SUPERVISOR decides whether a file is loaded at all.
# `supervisor/store/data.py:_read_app_translations()` globs this directory,
# keys each file by its STEM, and runs it through `SCHEMA_APP_TRANSLATIONS`.
# ⚠️ A FILE THAT FAILS THAT SCHEMA IS DROPPED WITH A LOG WARNING AND
# NOTHING ELSE: `except (ConfigurationFileError, vol.Invalid): _LOGGER.
# warning(...); continue`. The add-on then renders raw keys and the only
# trace is one line in the Supervisor log. So the loader is RUN here, out
# of the release image, rather than the schema being re-implemented.
# 2. THE FRONTEND decides what it does with what Supervisor loaded. The
# resolution below is not invented: it is transcribed from the shipped
# bundle in THIS image, `api/panel/frontend_latest/5680.*.js`
# (frontend 20250925.1), which reads
#
# _getTranslationEntry(lang,s,o){let e=this.addon.translations[lang]?.
# configuration; if(e){if(o?.path)for(const t of o.path)
# if(e=e[t]?.fields,!e)return; return e[s.name]}}
# computeLabel =(s,_,o)=>this._getTranslationEntry(this.hass.language,s,o)?.name
# ||this._getTranslationEntry("en",s,o)?.name||s.name
# computeHelper=(s,o) =>this._getTranslationEntry(this.hass.language,s,o)?.description
# ||this._getTranslationEntry("en",s,o)?.description||""
# _filteredSchema=(opts,schema)=>schema.filter(e=>e.name in opts||e.required)
#
# i.e. the fallback is PER KEY, en is the second chance, and the raw key
# name is the third.
from supervisor.apps.validate import SCHEMA_APP_TRANSLATIONS # noqa: E402
from supervisor.store.data import _read_app_translations # noqa: E402
TRANSLATIONS = _read_app_translations(Path("/w"))
show("Supervisor's OWN translation loader accepts both files - a file that "
"fails SCHEMA_APP_TRANSLATIONS is DROPPED with a log warning and the tab "
"silently goes back to raw keys, so 'it is on disk' is not the question",
set(TRANSLATIONS) == {"en", "nl"}, sorted(TRANSLATIONS))
ui = UiOptions(None)(schema)
print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
"SCHEMA BLOCK'S and not the options block's)")
def _entry(lang, name, path=()):
node = TRANSLATIONS.get(lang, {}).get("configuration")
if not node:
return None
for step in path:
node = (node.get(step) or {}).get("fields")
if not node:
return None
return node.get(name)
def _label(lang, name, path=()):
for lg in (lang, "en"):
e = _entry(lg, name, path)
if e and e.get("name"):
return e["name"], lg
return name, "RAW KEY"
def _helper(lang, name, path=()):
for lg in (lang, "en"):
e = _entry(lg, name, path)
if e and e.get("description"):
return e["description"], lg
return "", "NONE"
# ⚠️ EVERY FIELD, PRINTED. This is the transcript the card asks for: what the
# owner's Supervisor puts on the screen, not a claim that it would look nice.
for lang in ("en", "nl"):
print(f"\n --- as rendered at hass.language == {lang!r} "
f"(the owner's HA runs Europe/Brussels) ---")
for node in ui:
lbl, src = _label(lang, node["name"])
hlp, hsrc = _helper(lang, node["name"])
flag = "" if src == lang else f" [name from {src}]"
print(f" {node['name']:28s} -> {lbl}{flag}")
print(f" {'':28s} {hlp[:150]}{'...' if len(hlp) > 150 else ''}")
for sub in node.get("schema", ()):
slbl, ssrc = _label(lang, sub["name"], (node["name"],))
print(f" · {sub['name']:24s} -> {slbl}"
+ ("" if ssrc == lang else f" [{ssrc}]"))
_raw = [n["name"] for n in ui if _label("en", n["name"])[1] == "RAW KEY"]
show("NOT ONE top-level field falls through to its raw key name in `en` - "
f"which is what the tab rendered for all {len(ui)} of them before this "
f"card, because there was no translations/ directory at all. Raw: {_raw}",
not _raw)
_nohelp = [n["name"] for n in ui if not _helper("en", n["name"])[0]]
show("...and not one is left without a description, which is the half that "
f"says WHETHER TO TOUCH IT. Missing: {_nohelp}", not _nohelp)
_nl_missing = [n["name"] for n in ui if _label("nl", n["name"])[1] != "nl"]
show("...and every one of them is translated in `nl` too, so nothing on the "
f"owner's own screen falls back to English. Falling back: {_nl_missing}",
not _nl_missing)
# ⚠️ THE HONEST HALF, AND IT IS A NEGATIVE RESULT. `inverters` is a
# list-of-objects, so UiOptions gives it type "schema" WITH multiple: True, and
# `_convertSchemaElement` sends exactly that case to a SELECTOR rather than to a
# nested ha-form:
#
# if("schema"===t.type&&!t.multiple) return {...type:"expandable"...} <- not us
# if("schema"===t.type){const o={};for(const a of t.schema)
# o[a.name]={required:a.required,selector:...}; return {object:{multiple:t.multiple,fields:o}}}
#
# and ha-selector-object labels those fields with
#
# computeLabel=e=>{const t=this.selector.object?.translation_key;
# if(this.localizeValue&&t){...}
# return this.selector.object?.fields?.[e.name]?.label||e.name}
#
# `_convertSchemaElementToSelector` sets neither `label` nor `translation_key`,
# so ON THIS FRONTEND the per-entry keys inside the repeatable section render as
# `setpoint_entity`, `max_w`, `bms_soc_floor` - RAW - however well they are
# translated here. The `fields:` block is still shipped and still validated,
# because it is the shape Supervisor's own schema defines for exactly this and
# it is what a frontend that wires `label` through would consume; but this card
# must not claim it reaches the operator today, and the count below is asserted
# so that the day it changes, this transcript says so.
_multi = [n for n in ui if n.get("multiple") and n.get("type") == "schema"]
show("`inverters` is rendered as a REPEATABLE SECTION (type schema, multiple), "
"which is CFG-04's measurement re-confirmed",
[n["name"] for n in _multi] == ["inverters"], json.dumps(_multi)[:200])
show("⚠️ MEASURED AND NEGATIVE: this frontend gives that section's per-entry "
"keys to ha-selector-object, which labels them `fields[name].label || "
"name` - and the converter sets no `label` and no `translation_key`. So "
"the per-entry translations below are SHIPPED AND VALIDATED but do NOT "
"reach the screen on 20250925.1. The top-level `inverters` label and "
"description DO.",
bool(_multi) and all("label" not in s and "translation_key" not in s
for n in _multi for s in n.get("schema", ())),
"per-entry keys as rendered today: "
+ ", ".join(s["name"] for n in _multi for s in n.get("schema", ())))
# ⚠️ AND THE HAND PARSER CI USES IS GRADED AGAINST THIS LOADER, KEY BY KEY.
# test_translations.py runs in the add-on's CI image, which has no PyYAML, so it
# parses these files with tools/translations_lint.py. A hand parser that
# disagrees with the real loader is a check whose data cannot reach the failure
# it names, so the two are compared here - where both are available - rather
# than assumed to agree.
sys.path.insert(0, "/probe")
import translations_lint as _tl # noqa: E402
_mismatch = []
for lang in ("en", "nl"):
hand = _tl.parse_translation_file(f"/w/translations/{lang}.yaml")
real = {"configuration": TRANSLATIONS[lang].get("configuration", {})}
# SCHEMA_APP_TRANSLATIONS fills `description: None` for an absent one; the
# hand parser simply has no key. Normalise that ONE difference and nothing
# else, so a real disagreement cannot hide behind the normalisation.
def _norm(d):
return {k: {kk: vv for kk, vv in v.items() if vv is not None
and kk != "fields"}
| ({"fields": _norm(v["fields"])} if v.get("fields") else {})
for k, v in d.items()}
if _norm(hand.get("configuration", {})) != _norm(real["configuration"]):
a, b = _norm(hand.get("configuration", {})), _norm(real["configuration"])
_mismatch += [f"{lang}:{k}" for k in sorted(set(a) | set(b))
if a.get(k) != b.get(k)]
show("the hand parser CI uses (tools/translations_lint.py) returns EXACTLY "
"what Supervisor's own loader returns, for both files, key by key and "
f"string by string. Disagreements: {_mismatch}", not _mismatch)
_cfg_text = Path("/w/config.yaml").read_text(encoding="utf-8")
_probs = []
for lang in ("en", "nl"):
_probs += _tl.audit(
_cfg_text,
{"configuration": TRANSLATIONS[lang].get("configuration", {})}, lang)
show(f"...and the coverage audit is clean against the real loader's view too. "
f"Problems: {_probs}", not _probs)
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
# and still succeeds, and a transcript is only evidence if a wrong answer is
# distinguishable from a right one by something other than reading it.
@@ -0,0 +1,238 @@
"""UX-01: read config.yaml's option surface and the translations, and compare.
⚠️ WHY THIS IS A MODULE AND NOT A FUNCTION INSIDE THE CHECK. Two very different
programs have to agree about what is in `translations/*.yaml`:
* `test_translations.py`, which runs in the add-on's own CI image - Alpine,
python3 + py3-aiohttp + py3-paho-mqtt and NOTHING ELSE. There is no PyYAML
there and a check that skips on ImportError is not a check, so the parser
below is written by hand;
* `tools/supervisor_shape_probe.py`, which runs inside the real Home Assistant
Supervisor image and loads the same files with the loader Home Assistant
ACTUALLY uses (`supervisor.utils.yaml.read_json_or_yaml_file`, validated by
`SCHEMA_APP_TRANSLATIONS`).
A hand parser that quietly disagrees with the real loader is precisely the
defect this repository keeps finding - a check whose data cannot reach the
failure it names. So the probe imports THIS module and asserts, key by key and
string by string, that `parse_translation_file()` and the real loader return the
same thing. That equality is the licence for CI to use the hand parser at all.
The parser is DELIBERATELY STRICT. It accepts exactly the shape UX-01 generates
- two-space indentation, one `key:` mapping opener or one `key: "double quoted
scalar"` per line, `#` comments, blank lines - and RAISES on anything else. A
permissive parser would answer "no problems" for a file it had silently skipped
half of, which is the same failure wearing a passing grade.
"""
import io
import re
__all__ = ["parse_translation_file", "parse_translation_text", "config_surface",
"audit"]
class TranslationSyntaxError(ValueError):
"""The file is not in the subset this parser accepts. Never silently skip."""
_LINE = re.compile(r"^(?P<indent> *)(?P<name>[A-Za-z0-9_.:/-]+):(?P<rest>.*)$")
def _unescape(raw, lineno):
"""The body of a YAML double-quoted scalar -> its value."""
out, i = [], 0
while i < len(raw):
ch = raw[i]
if ch == "\\":
if i + 1 >= len(raw):
raise TranslationSyntaxError(f"line {lineno}: trailing backslash")
nxt = raw[i + 1]
if nxt not in ('"', "\\"):
# Deliberately narrow: UX-01's generator emits only these two.
# Anything else is a shape this parser has not been measured on.
raise TranslationSyntaxError(
f"line {lineno}: unsupported escape \\{nxt}")
out.append(nxt)
i += 2
continue
if ch == '"':
raise TranslationSyntaxError(f"line {lineno}: unescaped quote")
out.append(ch)
i += 1
return "".join(out)
def parse_translation_text(text, where="<text>"):
"""A nested dict from the strict two-space YAML subset UX-01 writes.
Raises TranslationSyntaxError on ANY line it does not fully understand.
"""
root = {}
stack = [(-2, root)] # (indent, mapping)
for lineno, line in enumerate(text.splitlines(), 1):
if not line.strip() or line.lstrip().startswith("#"):
continue
if line.rstrip() != line:
raise TranslationSyntaxError(f"{where} line {lineno}: trailing whitespace")
m = _LINE.match(line)
if not m:
raise TranslationSyntaxError(f"{where} line {lineno}: not `key:` -> {line!r}")
indent, name, rest = len(m.group("indent")), m.group("name"), m.group("rest")
if indent % 2:
raise TranslationSyntaxError(
f"{where} line {lineno}: indent {indent} is not a multiple of 2")
while stack and indent <= stack[-1][0]:
stack.pop()
if not stack:
raise TranslationSyntaxError(f"{where} line {lineno}: dedented past the root")
parent_indent, parent = stack[-1]
if indent != parent_indent + 2:
raise TranslationSyntaxError(
f"{where} line {lineno}: indent {indent} does not open under "
f"indent {parent_indent}")
if name in parent:
raise TranslationSyntaxError(f"{where} line {lineno}: duplicate key {name!r}")
if rest == "":
child = {}
parent[name] = child
stack.append((indent, child))
continue
if not rest.startswith(" "):
raise TranslationSyntaxError(f"{where} line {lineno}: no space after the colon")
value = rest[1:]
if len(value) < 2 or value[0] != '"' or value[-1] != '"':
raise TranslationSyntaxError(
f"{where} line {lineno}: scalars must be double quoted -> {value!r}")
parent[name] = _unescape(value[1:-1], lineno)
return root
def parse_translation_file(path):
return parse_translation_text(io.open(path, encoding="utf-8").read(), str(path))
# --- config.yaml's option surface --------------------------------------------
# ⚠️ THE SCHEMA HALF, NOT THE OPTIONS HALF, AND THAT IS A MEASUREMENT RATHER
# THAN A PREFERENCE. Supervisor's `App.schema_ui` is `UiOptions(...)(data[
# ATTR_SCHEMA])`, so the ORDER the Configuration tab renders in is the order of
# the `schema:` block and nothing else. tools/supervisor_shape_probe.py section
# F prints that order out of the real image.
#
# ⚠️ ANCHORED ON THE INDENTATION, never on a stripped line. A top-level schema
# key is at EXACTLY two spaces and everything nested is at four or more; the
# `max_w` inside `inverters:` and the flat `max_w` are the same text otherwise,
# and config.yaml declares the nested one FIRST on purpose so that a first-match
# stripped reader is caught. See test_inverter.py part 12.
_TOP = re.compile(r"^ ([a-z_0-9]+):(.*)$")
_NESTED = re.compile(r"^ {4,}(?:- )?([a-z_0-9]+): *(\S.*?) *$")
def config_surface(config_text):
"""(top-level schema keys in render order, {key: [nested per-entry keys]}).
Nested keys are the ones a list-of-objects option declares per entry - today
only `inverters:`. A list of scalars (`- str`) declares none, and comes back
absent from the second dict rather than as an empty list.
"""
marker = config_text.index("\nschema:")
half = config_text[marker:]
order, nested, current = [], {}, None
for line in half.splitlines():
m = _TOP.match(line)
if m:
order.append(m.group(1))
current = m.group(1) if not m.group(2).strip() else None
continue
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if current is None:
continue
n = _NESTED.match(line)
if n:
nested.setdefault(current, []).append(n.group(1))
else:
current = None
if len(order) != len(set(order)):
dupes = sorted({k for k in order if order.count(k) > 1})
raise ValueError(f"config.yaml declares {dupes} twice at the top level of schema:")
return order, nested
# --- the audit ---------------------------------------------------------------
def audit(config_text, translation, lang, require_descriptions=True):
"""Every disagreement between config.yaml and one translation file.
Returns a list of strings. EMPTY MEANS AGREEMENT; each string names the key
and says which direction the disagreement runs, because "the translations do
not match" is not something anybody can act on.
"""
order, nested = config_surface(config_text)
problems = []
top = translation.get("configuration")
if not isinstance(top, dict):
return [f"{lang}: no `configuration:` block at all"]
# ⚠️ THE WHOLE FILE IS ALL-OR-NOTHING, AND THAT IS WHY THESE TWO LINES ARE
# HERE RATHER THAN ONLY THE `configuration:` COVERAGE BELOW.
# `_read_app_translations()` runs the file through SCHEMA_APP_TRANSLATIONS
# and, on `vol.Invalid`, LOGS A WARNING AND DROPS THE WHOLE FILE - every
# label in it, not just the offending key. So a shape Supervisor refuses is
# not a small defect that costs one description; it silently returns the tab
# to raw keys. The two ways to write one that this parser can still read are
# a top-level block that is not `configuration` or `network`, and a
# `network:` block whose values are not strings ({str: str} is the schema).
for extra in sorted(set(translation) - {"configuration", "network"}):
problems.append(f"{lang}: `{extra}:` is not a block Supervisor reads "
"(only `configuration` and `network` are) - "
"SCHEMA_APP_TRANSLATIONS refuses the file and the "
"Supervisor DROPS ALL OF IT with a log warning")
for port, text in sorted((translation.get("network") or {}).items()):
if not isinstance(text, str):
problems.append(f"{lang}: `network.{port}` is not a string - "
"`network:` is {str: str} and a mapping there makes "
"Supervisor drop the WHOLE file, every label in it")
for key in order:
entry = top.get(key)
if entry is None:
problems.append(f"{lang}: option `{key}` has no translation - Home "
"Assistant would render the raw key")
continue
if not isinstance(entry, dict):
problems.append(f"{lang}: `{key}` is not a mapping")
continue
if not str(entry.get("name", "")).strip():
problems.append(f"{lang}: option `{key}` has no `name`")
if require_descriptions and not str(entry.get("description", "")).strip():
problems.append(f"{lang}: option `{key}` has no `description` - the "
"operator gets a label and no way to know whether to "
"touch it")
want = nested.get(key, [])
got = entry.get("fields")
if want and not isinstance(got, dict):
problems.append(f"{lang}: `{key}` is a repeatable section with "
f"per-entry keys {sorted(want)} and no `fields:` block")
continue
if got is not None and not want:
problems.append(f"{lang}: `{key}` carries a `fields:` block but "
"config.yaml declares no per-entry keys for it")
continue
for sub in want:
se = (got or {}).get(sub)
if se is None:
problems.append(f"{lang}: `{key}` per-entry key `{sub}` has no "
"translation")
continue
if not str(se.get("name", "")).strip():
problems.append(f"{lang}: `{key}.{sub}` has no `name`")
if require_descriptions and not str(se.get("description", "")).strip():
problems.append(f"{lang}: `{key}.{sub}` has no `description`")
for orphan in sorted(set(got or {}) - set(want)):
problems.append(f"{lang}: `{key}.{orphan}` is translated but "
"config.yaml declares no such per-entry key")
for orphan in sorted(set(top) - set(order)):
problems.append(f"{lang}: `{orphan}` is translated but config.yaml "
"declares no such option - a rename or a typo")
return problems
+275
View File
@@ -0,0 +1,275 @@
# Home Assistant add-on translations - ENGLISH (UX-01)
#
# ⚠️ WHAT THIS FILE IS FOR, AND WHAT HOME ASSISTANT ACTUALLY DOES WITH IT.
# Without a `translations/` directory the Configuration tab renders the raw
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
# The frontend keeps a field when `e.name in options || e.required`, and all 70
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY are
# rendered, always, in `schema:` order, with no description. This file is what
# turns that into sentences.
#
# HOW IT IS READ, measured on the Supervisor image this site runs
# (ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0):
#
# * `supervisor/store/data.py:_read_app_translations()` reads every
# `.yaml`/`.yml`/`.json` in this directory, keyed by FILE STEM (so this file
# is the `en` translation), and validates each through
# `SCHEMA_APP_TRANSLATIONS`: `configuration: {str: {name (required),
# description, fields}}` plus `network: {str: str}`. ⚠️ A FILE THAT FAILS
# THAT SCHEMA IS DROPPED WITH A LOG WARNING AND NOTHING ELSE - the add-on
# goes on rendering raw keys and nobody is told twice. tools/
# supervisor_shape_probe.py section F is what says it loaded.
# * The frontend resolves each label as
# `translations[<user's language>]?...name || translations["en"]?...name ||
# schema.name`, and the helper text the same way with `description`. ⚠️ THE
# FALLBACK IS PER KEY, so a key missing from nl.yaml degrades to this file,
# while a key present but wrong does not degrade at all.
#
# ⚠️ ORDER IS NOT SET HERE. `App.schema_ui` is `UiOptions(...)(data["schema"])`,
# so the Configuration tab renders in the order of config.yaml's `schema:`
# block. The order below only mirrors it so the two files read alike.
#
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST. There is no section-header primitive, no
# per-option "advanced" flag, no units on the input and no sliders, so grouping
# is approximated by ORDER plus the `Group · ` prefix in each label. Do not
# promise the operator visual sections; there are none.
#
# ⚠️ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM. The Configuration tab
# presents a number read off 1890 closed-loop test runs and the operator's own
# inverter rating as the same kind of field. The descriptions are the only place
# that difference can be stated, so a derived value names its campaign and says
# "do not change without re-deriving", and a value that is genuinely the
# operator's says so too.
configuration:
setpoint_entity:
name: "Hardware · Inverter setpoint entity"
description: "The ESPHome number entity this add-on writes the power setpoint to, normally number.*_goodwe_setpoint_w. This is the only thing the add-on commands. Leave it empty if you fill in the inverter list below: a site with a roster must not also name one loose actuator, and the add-on refuses to start when both are set."
soc_entity:
name: "Hardware · Battery charge level entity (%)"
description: "The sensor that reports the pack's state of charge in percent. Use the ESP32's own reading, not a figure computed somewhere else. The maintenance cycle and every charge-level floor read this."
batt_entity:
name: "Hardware · Battery power entity (W)"
description: "The sensor that reports this inverter's own battery power, positive while discharging. On this installation that is holding register 0x0518, read by the ESP32. The sign watchdog and the circulating-power alarm both grade the inverter against this reading."
batt_invert:
name: "Hardware · Battery power sign is reversed"
description: "Only switch this on if your battery power sensor reports discharging as a negative number. The add-on expects positive to mean discharging."
inverters:
name: "Hardware · Inverter list (leave empty if you have one inverter)"
description: "Leave this empty unless the site really has more than one inverter. EMPTY DOES NOT MEAN 'not configured yet': it means the four entity settings above, together with the inverter rating and the battery cut-out under Limits, describe the one inverter you have. That is what every existing installation reads, and nobody has to touch anything. Filling this in REPLACES those settings instead of extending them, and nothing is inherited: each entry has to name its own actuator, its own rating and its own battery cut-out, so that no inverter is ever judged against another unit's numbers. The flat setpoint entity above must then be left empty."
fields:
name:
name: "Name"
description: "A short identifier for this inverter, for example inverter-a. It gives this unit its own namespace for its safety latch, heartbeat and write bookkeeping in the add-on's state file, so two units may not share one."
setpoint_entity:
name: "Setpoint entity"
description: "This inverter's own ESPHome number.*_goodwe_setpoint_w. Two entries may not name the same one - two writers on one actuator is refused at startup."
max_w:
name: "Inverter rating (W)"
description: "The supervised ceiling on what may be written to THIS inverter; the site's ceiling is the sum over the list. Required of every entry: an entry without one would have to borrow a rating, and judging one inverter against another unit's numbers is exactly the fault this per-entry list exists to remove."
bms_soc_floor:
name: "Battery cut-out level (%)"
description: "The percent at which THIS pack's own battery management system cuts out. Read it off the pack that is actually installed (register 0x0560) before that pack is ever commanded; do not copy another unit's figure. Required of every entry, deliberately: an optional one would be a defaulted one, and one default for two batteries of different models protects the shallower pack with the deeper pack's number."
soc_entity:
name: "Battery charge level entity (%) (optional)"
description: "This pack's own state of charge. Leave it empty if this unit reports none - that is what it means, and it never falls back to another unit's sensor."
batt_entity:
name: "Battery power entity (W) (optional)"
description: "This unit's own battery power, positive while discharging. Leave it empty if this unit reports none; nothing is inherited from another entry."
batt_invert:
name: "Battery power sign is reversed (optional)"
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
estop_fitted:
name: "Hardware · RS485 emergency stop is fitted"
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
meter_source:
name: "Meter · Where the meter reading comes from"
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
meter_entity:
name: "Meter · Grid power entity (W)"
description: "The net grid power sensor, used when the meter source is 'off'. POSITIVE MUST MEAN DRAWING FROM THE GRID. Get this the wrong way round and the loop drives the battery in the wrong direction; use the sign switch below rather than a template sensor."
meter_invert:
name: "Meter · Grid power sign is reversed"
description: "Switch this on if your grid sensor reports drawing from the grid as a negative number."
meter_phases:
name: "Meter · Number of phases (1 or 3)"
description: "Must match your connection. A telegram whose phase count disagrees is rejected as it arrives and logged, so a wrong value shows up straight away as 'no telegrams accepted' rather than as a quietly wrong number. The range accepts 2, but that is not a real Belgian connection."
p1_host:
name: "Meter · HomeWizard P1 address"
description: "For meter source homewizard_local only: the meter's own address on your network, as host or host:port, for example 192.168.2.250."
meter_poll_s:
name: "Meter · Seconds between meter polls"
description: "For meter source homewizard_local only. 5 s is the meter's own update rate, so there is nothing to gain below it. It must stay well under the staleness limit below - the reading is never fresher than this interval - and the add-on refuses to start if it is at or above that limit."
meter_max_age_s:
name: "Meter · A reading counts as stale after (s)"
description: "Beyond this the grid figure counts as missing. On its own it does not command 0 W: the 'inputs missing' timer under Advanced starts only after this one has expired, and the two stack. 30 s is six polls of headroom at the default poll interval."
meter_mqtt_topic:
name: "Meter · MQTT topic"
description: "For meter source mqtt_p1 only: the topic the P1 readings are published on."
p1_import_entity:
name: "Meter · Import register entity (W)"
description: "The UNSIGNED consumption register. Do not point this at a signed template sensor: the add-on works out net power itself as import minus export, so the sign convention lives in tested code rather than in unreviewed YAML."
p1_export_entity:
name: "Meter · Export register entity (W)"
description: "The UNSIGNED injection register, the counterpart of the import register above. Same rule: no signed template sensor."
p1_net_entity:
name: "Meter · Signed net power entity (W)"
description: "For meter source ha_signed only. ONE signed sensor: positive is drawn from the grid, negative is fed back into it. Do not split it into two template sensors - the add-on does that split itself, so that it is tested."
p1_phase_import_entities:
name: "Meter · Per-phase import entities (L1, L2, L3)"
description: "Optional, in L1..L3 order. Needed for the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases above."
p1_phase_export_entities:
name: "Meter · Per-phase export entities (L1, L2, L3)"
description: "Optional, in L1..L3 order, alongside the per-phase import entities above. The number of entries must equal the number of phases."
p1_phase_net_entities:
name: "Meter · Per-phase signed net entities (L1, L2, L3)"
description: "For meter source ha_signed only. Optional, in L1..L3 order, each signed the same way as the net entity above. Same job as the per-phase import and export entities: the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases."
max_w:
name: "Limits · Inverter rating (W)"
description: "THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may ever be commanded, in watts. Set it to what your inverter is actually rated for, start low and raise it once the site has been commissioned. It is not read at all when the inverter list is filled in - each entry then carries its own, and the site ceiling is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not."
bms_soc_floor:
name: "Limits · Battery cut-out level (%)"
description: "MEASURED, not chosen: the percent at which the pack's OWN battery management system stops discharging, whatever this add-on is set to. Register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure. It is here so the add-on can refuse a capacity-guard floor at or below it: such a floor could never bind, the BMS would win first, and the status page would report an energy backstop that does not exist. Not read when the inverter list is filled in - each entry then states its own pack's cut-out."
peak_cap_w:
name: "Limits · Capacity-tariff target (W)"
description: "THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tries to stay under. Take it from your grid contract - in Belgium it is the capacity-tariff peak your distribution charge is billed on."
peak_forecast_entity:
name: "Limits · Quarter-hour demand forecast entity (W)"
description: "Optional. A forecast of the current billing quarter's average demand. Leave it empty and there is no capacity cap at all."
peak_soc_floor:
name: "Limits · Peak guard will not discharge below (%)"
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
price_provider:
name: "Tariff · Dynamic price source"
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ Switching it on today fetches, checks and publishes prices and changes no energy decision - nothing consumes the price series yet. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
price_markup_eur_kwh:
name: "Tariff · Supplier markup (EUR/kWh)"
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under engie_dynamic, totalenergies_dynamic, eneco_eddy and manual_csv it is ignored, and logged as ignored, never applied twice."
price_taxes_eur_kwh:
name: "Tariff · Levies and VAT (EUR/kWh)"
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
export_price_eur_kwh:
name: "Tariff · Injection price (EUR/kWh)"
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
static_profile:
name: "Tariff · Fixed or day/night tariff (one line of JSON)"
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
price_csv_path:
name: "Tariff · Price CSV file"
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
price_horizon_h:
name: "Tariff · How far ahead to fetch prices (hours)"
description: "How many hours of prices to ask the source for."
price_timeout_s:
name: "Tariff · Price request timeout (s)"
description: "How long a single request to the price source may take before it is given up on."
price_now_entity:
name: "Tariff · Current price entity (older price path)"
description: "The older way of getting a price, kept because retiring it would take the maintenance cycle's only price signal away from a live installation. The add-on reads this and the average below and derives exactly one thing from them - whether now is cheaper than average - which only the maintenance cycle's cheap-window charging uses. It may not be combined with a dynamic price source."
price_avg_entity:
name: "Tariff · Average price entity (older price path)"
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
arbitrage_enabled:
name: "Tariff · Trade on the price spread"
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
cycle_cost_threshold_eur:
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ 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). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
arbitrage_soc_floor:
name: "Tariff · Trading may not discharge below (%)"
description: "The reserve price trading may not spend. Deliberately not the same key as the maintenance floor: pointing a trading strategy at the monthly balancing cycle's numbers would couple two unrelated policies through one setting."
arbitrage_soc_ceiling:
name: "Tariff · Trading may not charge above (%)"
description: "The headroom price trading may not fill. The counterpart of the trading floor above, and kept separate from the maintenance target for the same reason."
maintenance_enabled:
name: "Maintenance · Run the monthly battery cycle"
description: "A deliberate deep discharge followed by a full recharge and a hold, so the battery management system can recalibrate its charge estimate and balance the cells. Off by default."
maintenance_interval_days:
name: "Maintenance · Minimum days between cycles"
description: "The shortest gap between two maintenance cycles."
maintenance_start_hour:
name: "Maintenance · Hour of day a due cycle starts"
description: "Local hour, 0 to 23, at which a cycle that has become due begins."
maintenance_discharge_w:
name: "Maintenance · Discharge rate (W)"
description: "How fast the cycle drains the pack; the surplus is exported. Above the inverter rating it is CLAMPED to the rating rather than refused - the cycle still completes, just more slowly, and the status page says so. It ships at 2000 to match the shipped rating; raise the rating first, then this."
maintenance_charge_w:
name: "Maintenance · Recharge rate (W)"
description: "⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses to start otherwise. Above it, the cheap-window charge shaping asks for more than the supervised limit allows, the arbiter resolves that contradiction to 0 W, and maintenance would silently never charge in a cheap window on a default installation. It is also capped again by whatever headroom the peak guard leaves."
maintenance_soc_floor:
name: "Maintenance · Discharge down to (%)"
description: "The depth the monthly recalibration deliberately goes to, once a month, on purpose. ⚠️ THIS IS NOT THE PEAK GUARD'S FLOOR: that one is the depth a money-saving discharge may never reach on any day. The right values genuinely differ - 11 here against 20 there - which is why they are two separate settings and must not be merged."
maintenance_soc_target:
name: "Maintenance · Recharge up to (%)"
description: "The level the recharge phase aims for before the hold begins."
maintenance_hold_min:
name: "Maintenance · Hold at full for (minutes)"
description: "How long to sit at the target so the battery management system can finish balancing the cells."
sign_disagree_w:
name: "Safety · Sign watchdog threshold (W) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and the grid reading must exceed this before their opposite signs count as a disagreement at all. On the captured healthy day (2026-08-23) sign agreement over the gated samples is 93.4 %; on the captured fault day (2026-08-20) the clusters run 0.0-10.5 % with opposed runs up to 1752 s. There is no reason to move this without a new capture that says so. It must stay below the inverter rating or the watchdog can never fire, and the add-on refuses that configuration."
sign_disagree_s:
name: "Safety · Sign watchdog hold (s) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter must stay opposed, continuously, before the add-on commands 0 W and latches until an operator starts control again. 180 s sits in the middle of a very wide measured gap: the longest legitimate opposed run on the healthy day is 30 s, while the fault day still trips 13 times at 300 s. Do not change it without a new capture."
unit_sign_disagree_w:
name: "Safety · Per-inverter sign threshold (W) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothing at all. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS on the two-master rig (SAFETY-30), in which nothing was faulty, swept across the whole range this add-on accepts for loop gain and slew rate. 250 W is exactly half the saturation threshold, so the smallest divergence this can see is precisely the gap at which the control law itself already says the inverter is not following. Do not change it without re-deriving it. It must stay below the inverter rating or it can never fire."
unit_sign_disagree_s:
name: "Safety · Per-inverter sign hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. 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), 2.8 times the firmware's own 30 s link veto, and under half the 180 s the site-wide check takes. Do not change it without re-deriving it."
site_sign_disagree_w:
name: "Safety · Site-wide sign threshold (W) — measured, do not change"
description: "TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter threshold, CARRIED ACROSS DELIBERATELY (SAFETY-30): the argument behind it does not depend on how many inverters there are - near zero the sign is noise, the deadband is 15 W, and there is one meter whatever is behind it. Do not change it without a capture that says so."
site_sign_disagree_s:
name: "Safety · Site-wide sign hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30), and deliberately NOT carried across from the single-inverter 180 s. On those runs the worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, because the command has to travel the whole site rail at a slew rate that is not the site's - so carrying 180 s across would have shipped a false trip reachable at the lowest gain and slew with an ordinary 2.2 kW load reversal. 325 s is 1.51 times that, still inside the 900 s billing quarter, and still inside every episode of the captured fault day. Do not change it without re-deriving it."
meter_freeze_s:
name: "Safety · Frozen-meter alarm after (s) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answering with an UNCHANGED reading, while the command stands at the rail, before the add-on raises an alarm. Over 2399 s of this house at its settled operating point - 1194 polls, 735 of them repeating the previous reading - the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that and still inside the 900 s billing quarter. ALARM ONLY: it does not command 0 W and does not latch. ⚠️ Only the homewizard_local meter source can see this at all; on every other source the status page says NOT COVERED rather than armed. Do not change it without re-deriving it."
circulation_w:
name: "Safety · Circulating-power threshold (W) — measured, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identically zero. Two inverters pushing against each other net out to zero at the meter and are invisible to every other guard in this add-on. MEASURED ON 924 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-31): the worst legitimate opposition collapses from 505 s at a 100 W threshold to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below - under it, ordinary operation holds the sensor over the threshold for minutes and nothing shorter than the billing quarter could tell that from a fault. 700 W is 1.75 times the knee, and the extra margin is there because the second inverter's ramp and lag are still copied from the first one rather than measured. Do not change it without re-deriving it."
circulation_hold_s:
name: "Safety · Circulating-power hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
auto_start:
name: "Advanced · Start controlling automatically on boot"
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
gain:
name: "Advanced · Loop gain"
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
slew_w:
name: "Advanced · Largest change per cycle (W)"
description: "How far the setpoint may move in one control cycle. It bounds how fast the site can turn around, and several safety windows were measured across the whole range this accepts."
deadband_w:
name: "Advanced · Deadband (W)"
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
target_grid_w:
name: "Advanced · Resting point at the meter (W)"
description: "What the meter should sit at when nothing is happening; negative means a slight export. -10 W is deliberate: resting at 0 W leaks about half the deadband in billed import all day - roughly 0.2 to 0.35 kWh at a 15 W deadband - and biasing the rest point below zero moves that leak into the export register, which is not billed. It costs about this many watts of given-away export, so keep it small."
step_w:
name: "Advanced · Setpoint quantisation (W)"
description: "The setpoint is rounded to a multiple of this before it is written, so the inverter is not handed a new number for every watt of noise."
saturation_w:
name: "Advanced · Saturation threshold (W)"
description: "The gap between what was commanded and what the inverter delivers that counts as 'this inverter is at a limit'. ⚠️ The per-inverter sign threshold under Safety is derived as exactly half of this, so moving this moves that measurement's argument with it."
saturation_cycles:
name: "Advanced · Saturated cycles before freezing (count)"
description: "How many consecutive saturated cycles before the loop stops winding up. ⚠️ Do not set this to 1: a single noisy cycle would then freeze the loop."
integrator_max_w:
name: "Advanced · Integrator bound (W, 0 means the same as the rating)"
description: "Caps how much stale error can be waiting to unwind when the sign flips. MEASURED on the closed-loop simulator with a 4000 W load dropped to zero: at a bound equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating it is 1800 W. ⚠️ Do not raise it above the inverter rating - the output clamp already bounds what reaches the wire, so extra headroom buys nothing but wrong-direction watts after every saturation event. Lowering it below the rating is the useful direction, and the only reason this is a separate setting."
heartbeat_s:
name: "Advanced · Setpoint refresh interval (s)"
description: "How often the setpoint is rewritten even when it has not changed. It must stay well under the firmware's own watchdog, which vetoes a link that has been quiet for 30 s."
stale_input_s:
name: "Advanced · Command 0 W after inputs missing for (s)"
description: "How long the inputs may be missing before the add-on commands 0 W. With a P1 meter this clock starts only AFTER the meter staleness limit has already expired, so the two stack: the total is longer than this number on its own."
cycle_floor_s:
name: "Advanced · Longest gap between control cycles (s)"
description: "A deadline on the gap between cycles, reset by every cycle. On a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than about 10 s, and keep it under the meter staleness limit - past that there is no reading left to evaluate anyway."
log_level:
name: "Advanced · Log level"
description: "How much detail the add-on writes to its log. 'info' is the shipped setting; 'debug' and 'trace' are for diagnosing a problem and are noisy."
+255
View File
@@ -0,0 +1,255 @@
# Home Assistant add-on-vertalingen - NEDERLANDS (UX-01)
#
# ⚠️ DIT IS GEEN WOORD-VOOR-WOORDVERTALING VAN en.yaml en hoort dat ook niet te
# zijn. De eigenaar draait Home Assistant op Europe/Brussels; dit is de tekst
# die een Vlaamse huiseigenaar leest, met de woorden die op zijn factuur en op
# zijn digitale meter staan: afname en injectie, kwartierpiek en
# capaciteitstarief, laadniveau, uitschakelgrens.
#
# ⚠️ DE TERUGVAL IS PER SLEUTEL. De frontend probeert eerst de taal van de
# gebruiker, dan `en`, dan de kale sleutelnaam. Een sleutel die HIER ONTBREEKT
# valt dus netjes terug op het Engels; een sleutel die hier STAAT maar fout is,
# valt nergens op terug. Een half vertaalde sleutel weglaten is beter dan hem
# verkeerd invullen.
#
# ⚠️ DE VOLGORDE WORDT HIER NIET BEPAALD. Het tabblad Configuratie rendert in de
# volgorde van het `schema:`-blok van config.yaml; de volgorde hieronder is
# alleen een spiegel daarvan, zodat beide bestanden gelijk lezen.
#
# ⚠️ AFGELEIDE CONSTANTEN VERMELDEN WAAR ZE VANDAAN KOMEN. Het tabblad toont een
# getal dat uit 1890 gesloten-lustests komt en het omvormervermogen van de
# eigenaar als hetzelfde soort veld. De beschrijving is de enige plek waar dat
# verschil gezegd kan worden.
configuration:
setpoint_entity:
name: "Hardware · Setpoint-entiteit van de omvormer"
description: "De ESPHome number-entiteit waar deze add-on het vermogen naartoe schrijft, meestal number.*_goodwe_setpoint_w. Dit is het enige wat de add-on aanstuurt. Laat leeg als u hieronder de lijst met omvormers invult: een installatie met een lijst mag niet óók één losse aansturing hebben, en de add-on start dan niet op."
soc_entity:
name: "Hardware · Entiteit met het laadniveau van de batterij (%)"
description: "De sensor die de laadtoestand van de batterij in procent doorgeeft. Gebruik de meting van de ESP32 zelf, geen waarde die elders berekend wordt. De onderhoudscyclus en alle ondergrenzen voor het laadniveau lezen deze sensor."
batt_entity:
name: "Hardware · Entiteit met het batterijvermogen (W)"
description: "De sensor die het batterijvermogen van deze omvormer doorgeeft, positief bij ontladen. Op deze installatie is dat holding register 0x0518, uitgelezen door de ESP32. Zowel de tekenbewaking als het alarm voor rondpompend vermogen beoordelen de omvormer aan de hand van deze meting."
batt_invert:
name: "Hardware · Teken van het batterijvermogen omkeren"
description: "Zet dit alleen aan als uw sensor ontladen als een negatief getal doorgeeft. De add-on gaat ervan uit dat positief ontladen betekent."
inverters:
name: "Hardware · Lijst met omvormers (leeg laten bij één omvormer)"
description: "Laat dit leeg tenzij er echt meer dan één omvormer staat. LEEG BETEKENT NIET 'nog niet ingesteld': het betekent dat de vier entiteiten hierboven, samen met het omvormervermogen en de uitschakelgrens van de batterij onder Grenzen, de ene omvormer beschrijven die u hebt. Dat is wat elke bestaande installatie leest, zonder dat iemand iets hoeft aan te passen. Vult u de lijst wel in, dan VERVANGT die de instellingen hierboven in plaats van ze aan te vullen, en wordt er niets overgenomen: elk item moet zijn eigen aansturing, zijn eigen vermogen en zijn eigen uitschakelgrens vermelden, zodat geen enkele omvormer ooit beoordeeld wordt op de cijfers van een andere. De losse setpoint-entiteit hierboven moet dan leeg blijven."
fields:
name:
name: "Naam"
description: "Een korte aanduiding voor deze omvormer, bijvoorbeeld inverter-a. Ze geeft deze omvormer een eigen naamruimte voor haar veiligheidsvergrendeling, hartslag en schrijfadministratie in het statusbestand van de add-on, dus twee omvormers mogen er niet één delen."
setpoint_entity:
name: "Setpoint-entiteit"
description: "De eigen ESPHome-entiteit number.*_goodwe_setpoint_w van deze omvormer. Twee items mogen niet dezelfde entiteit noemen - twee schrijvers op één aansturing wordt bij het opstarten geweigerd."
max_w:
name: "Vermogen van deze omvormer (W)"
description: "De bewaakte bovengrens van wat er naar DEZE omvormer geschreven mag worden; het plafond van de installatie is de som over de lijst. Verplicht bij elk item: een item zonder deze waarde zou een vermogen moeten lenen, en een omvormer beoordelen op de cijfers van een andere is precies de fout die deze lijst per omvormer moet wegnemen."
bms_soc_floor:
name: "Uitschakelniveau van de batterij (%)"
description: "Het percentage waarbij het batterijmanagementsysteem van DEZE batterij uitschakelt. Lees dit af van de batterij die er werkelijk staat (register 0x0560) voordat die batterij ooit aangestuurd wordt; neem geen waarde van een andere omvormer over. Bewust verplicht bij elk item: optioneel maken zou neerkomen op een standaardwaarde, en één standaardwaarde voor twee verschillende batterijen beschermt de kleinste batterij met het getal van de grootste."
soc_entity:
name: "Entiteit met het laadniveau (%) (optioneel)"
description: "Het laadniveau van deze batterij zelf. Laat leeg als deze omvormer dat niet doorgeeft - dat is wat het betekent, en er wordt nooit teruggevallen op de sensor van een andere omvormer."
batt_entity:
name: "Entiteit met het batterijvermogen (W) (optioneel)"
description: "Het batterijvermogen van deze omvormer zelf, positief bij ontladen. Laat leeg als deze omvormer dat niet doorgeeft; er wordt niets van een ander item overgenomen."
batt_invert:
name: "Teken van het batterijvermogen omkeren (optioneel)"
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst"
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
meter_source:
name: "Meter · Waar de meterstand vandaan komt"
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."
meter_entity:
name: "Meter · Entiteit met het netvermogen (W)"
description: "De sensor met het netto netvermogen, gebruikt wanneer de meterbron 'off' is. POSITIEF MOET AFNAME VAN HET NET BETEKENEN. Staat dit omgekeerd, dan stuurt de regeling de batterij de verkeerde kant op; gebruik daarvoor de tekenschakelaar hieronder en geen template-sensor."
meter_invert:
name: "Meter · Teken van het netvermogen omkeren"
description: "Zet dit aan als uw netsensor afname van het net als een negatief getal doorgeeft."
meter_phases:
name: "Meter · Aantal fasen (1 of 3)"
description: "Moet overeenkomen met uw aansluiting. Een telegram met een ander aantal fasen wordt bij binnenkomst geweigerd en gelogd, zodat een verkeerde waarde meteen zichtbaar wordt als 'geen telegrammen aanvaard' in plaats van als een stil verkeerd getal. Het bereik aanvaardt 2, maar dat is geen echte Belgische aansluiting."
p1_host:
name: "Meter · Adres van de HomeWizard P1"
description: "Alleen voor meterbron homewizard_local: het adres van de meter zelf op uw netwerk, als host of host:poort, bijvoorbeeld 192.168.2.250."
meter_poll_s:
name: "Meter · Seconden tussen twee uitlezingen"
description: "Alleen voor meterbron homewizard_local. 5 s is het tempo waarin de meter zelf ververst, dus lager instellen levert niets op. Deze waarde moet ruim onder de verouderingsgrens hieronder blijven - de meting is nooit verser dan dit interval - en de add-on start niet op als ze eraan gelijk is of erboven ligt."
meter_max_age_s:
name: "Meter · Een meting is verouderd na (s)"
description: "Daarna geldt de netwaarde als ontbrekend. Op zichzelf stuurt dit nog geen 0 W aan: de teller 'invoer ontbreekt' onder Geavanceerd begint pas te lopen nadat deze verstreken is, en de twee stapelen. 30 s is zes uitlezingen speling bij het standaard uitleesinterval."
meter_mqtt_topic:
name: "Meter · MQTT-topic"
description: "Alleen voor meterbron mqtt_p1: het topic waarop de P1-metingen gepubliceerd worden."
p1_import_entity:
name: "Meter · Entiteit van het afnameregister (W)"
description: "Het afnameregister ZONDER teken. Wijs dit niet aan een template-sensor mét teken toe: de add-on berekent het nettovermogen zelf als afname min injectie, zodat de tekenafspraak in geteste code zit en niet in ongecontroleerde YAML."
p1_export_entity:
name: "Meter · Entiteit van het injectieregister (W)"
description: "Het injectieregister ZONDER teken, de tegenhanger van het afnameregister hierboven. Zelfde regel: geen template-sensor mét teken."
p1_net_entity:
name: "Meter · Entiteit met het nettovermogen mét teken (W)"
description: "Alleen voor meterbron ha_signed. ÉÉN sensor mét teken: positief is afname van het net, negatief is injectie. Splits die niet in twee template-sensoren op - de add-on doet die splitsing zelf, juist zodat ze getest is."
p1_phase_import_entities:
name: "Meter · Afname-entiteiten per fase (L1, L2, L3)"
description: "Optioneel, in de volgorde L1..L3. Nodig voor de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen hierboven."
p1_phase_export_entities:
name: "Meter · Injectie-entiteiten per fase (L1, L2, L3)"
description: "Optioneel, in de volgorde L1..L3, naast de afname-entiteiten per fase hierboven. Het aantal items moet gelijk zijn aan het aantal fasen."
p1_phase_net_entities:
name: "Meter · Netto-entiteiten per fase mét teken (L1, L2, L3)"
description: "Alleen voor meterbron ha_signed. Optioneel, in de volgorde L1..L3, elk met hetzelfde teken als de netto-entiteit hierboven. Zelfde rol als de afname- en injectie-entiteiten per fase: de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen."
max_w:
name: "Grenzen · Vermogen van de omvormer (W)"
description: "DIT IS UW GETAL, geen afgeleide waarde: de harde bovengrens van wat er ooit gestuurd mag worden, in watt. Zet het op wat uw omvormer werkelijk aankan, begin laag en verhoog het pas na de indienstname. Het wordt helemaal niet gelezen zodra de lijst met omvormers is ingevuld - elk item draagt dan zijn eigen waarde en het plafond van de installatie is hun som. Verschillende veiligheidsdrempels moeten eronder blijven; de add-on start niet op als er één niet aan voldoet."
bms_soc_floor:
name: "Grenzen · Uitschakelniveau van de batterij (%)"
description: "GEMETEN, niet gekozen: het percentage waarbij het batterijmanagementsysteem van de batterij ZELF stopt met ontladen, wat u hier ook instelt. Register 0x0560 leest 10 op de referentie-GW5000S-BP. Lees deze waarde af van de batterij die er werkelijk staat in plaats van dit getal over te nemen. Ze staat hier zodat de add-on een ondergrens voor de piekbewaking op of onder deze waarde kan weigeren: zo'n ondergrens kan nooit werken, het BMS wint altijd eerst, en de statuspagina zou een reserve tonen die niet bestaat. Wordt niet gelezen zodra de lijst met omvormers is ingevuld - elk item vermeldt dan de uitschakelgrens van zijn eigen batterij."
peak_cap_w:
name: "Grenzen · Streefwaarde capaciteitstarief (W)"
description: "DIT IS UW GETAL: het gemiddelde kwartiervermogen waar de piekbewaking onder probeert te blijven. Neem het over uit uw netcontract - in België is dit de kwartierpiek waarop het capaciteitstarief van uw distributiefactuur berekend wordt."
peak_forecast_entity:
name: "Grenzen · Entiteit met de kwartierprognose (W)"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende factuurkwartier. Laat u dit leeg, dan is er helemaal geen piekbegrenzing."
peak_soc_floor:
name: "Grenzen · Piekbewaking ontlaadt niet onder (%)"
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
price_provider:
name: "Tarief · Bron van dynamische prijzen"
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ Dit vandaag aanzetten haalt prijzen op, controleert en publiceert ze, en verandert geen enkele energiebeslissing - er is nog niets dat de prijsreeks gebruikt. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
price_markup_eur_kwh:
name: "Tarief · Marge van de leverancier (EUR/kWh)"
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij engie_dynamic, totalenergies_dynamic, eneco_eddy en manual_csv wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
price_taxes_eur_kwh:
name: "Tarief · Heffingen en btw (EUR/kWh)"
description: "Belastingen en heffingen per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat."
export_price_eur_kwh:
name: "Tarief · Injectievergoeding (EUR/kWh)"
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."
static_profile:
name: "Tarief · Vast of dag/nacht-tarief (één regel JSON)"
description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief."
price_csv_path:
name: "Tarief · CSV-bestand met prijzen"
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
price_feed_url:
name: "Tarief · Afwijkende URL voor de prijsfeed"
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen."
price_horizon_h:
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
price_timeout_s:
name: "Tarief · Time-out voor een prijsaanvraag (s)"
description: "Hoe lang één aanvraag bij de prijsbron mag duren voordat ze wordt opgegeven."
price_now_entity:
name: "Tarief · Entiteit met de huidige prijs (oudere prijsweg)"
description: "De oudere manier om aan een prijs te komen, behouden omdat ze weghalen de onderhoudscyclus op een draaiende installatie zijn enige prijssignaal zou afnemen. De add-on leest deze entiteit en het gemiddelde hieronder en leidt er precies één ding uit af - of het nu goedkoper is dan gemiddeld - wat alleen gebruikt wordt om tijdens de onderhoudscyclus in een goedkoop venster te laden. Mag niet gecombineerd worden met een dynamische prijsbron."
price_avg_entity:
name: "Tarief · Entiteit met de gemiddelde prijs (oudere prijsweg)"
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
arbitrage_enabled:
name: "Tarief · Handelen op het prijsverschil"
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
cycle_cost_threshold_eur:
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
arbitrage_soc_floor:
name: "Tarief · Handel ontlaadt niet onder (%)"
description: "De reserve die de prijshandel niet mag aanspreken. Bewust een andere instelling dan de ondergrens van het onderhoud: een handelsstrategie op de cijfers van de maandelijkse balanceercyclus richten, zou twee ongerelateerde keuzes aan één getal koppelen."
arbitrage_soc_ceiling:
name: "Tarief · Handel laadt niet boven (%)"
description: "De ruimte die de prijshandel niet mag volmaken. De tegenhanger van de ondergrens hierboven, en om dezelfde reden losgehouden van het onderhoudsdoel."
maintenance_enabled:
name: "Onderhoud · Maandelijkse batterijcyclus uitvoeren"
description: "Een bewuste diepe ontlading, gevolgd door volledig opladen en een rustperiode op vol, zodat het batterijmanagementsysteem zijn schatting van het laadniveau kan herijken en de cellen kan balanceren. Staat standaard uit."
maintenance_interval_days:
name: "Onderhoud · Minimum aantal dagen tussen cycli"
description: "De kortste tijd tussen twee onderhoudscycli."
maintenance_start_hour:
name: "Onderhoud · Uur waarop een geplande cyclus start"
description: "Lokaal uur, 0 tot 23, waarop een cyclus die aan de beurt is begint."
maintenance_discharge_w:
name: "Onderhoud · Ontlaadvermogen (W)"
description: "Hoe snel de cyclus de batterij leegtrekt; het overschot gaat naar het net. Boven het vermogen van de omvormer wordt dit BEGRENSD tot dat vermogen en niet geweigerd - de cyclus loopt gewoon door, alleen trager, en de statuspagina meldt dat. Standaard 2000, afgestemd op het standaardvermogen; verhoog eerst het omvormervermogen en dan pas dit."
maintenance_charge_w:
name: "Onderhoud · Laadvermogen (W)"
description: "⚠️ MOET GELIJK AAN OF LAGER ZIJN dan het vermogen van de omvormer; anders start de add-on niet op. Erboven vraagt het laden in een goedkoop venster meer dan de bewaakte grens toelaat, lost de arbiter die tegenstrijdigheid op naar 0 W, en zou het onderhoud op een standaardinstallatie stilzwijgend nooit laden in een goedkoop venster. Daarnaast wordt dit nog eens begrensd door de ruimte die de piekbewaking overlaat."
maintenance_soc_floor:
name: "Onderhoud · Ontladen tot (%)"
description: "De diepte waar de maandelijkse herijking bewust naartoe gaat, één keer per maand, met opzet. ⚠️ DIT IS NIET DE ONDERGRENS VAN DE PIEKBEWAKING: die geeft aan hoe diep een besparende ontlading op geen enkele dag mag komen. De juiste waarden verschillen echt - 11 hier tegenover 20 daar - en daarom zijn het twee aparte instellingen die niet samengevoegd mogen worden."
maintenance_soc_target:
name: "Onderhoud · Opladen tot (%)"
description: "Het niveau waar de laadfase naartoe werkt voordat de rustperiode begint."
maintenance_hold_min:
name: "Onderhoud · Op vol houden gedurende (minuten)"
description: "Hoe lang er op het streefniveau gebleven wordt zodat het batterijmanagementsysteem het balanceren van de cellen kan afmaken."
sign_disagree_w:
name: "Veiligheid · Drempel tekenbewaking (W) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Zowel het uitgestuurde setpoint als de netmeting moet boven deze waarde liggen voordat hun tegengestelde tekens überhaupt als onenigheid tellen. Op de vastgelegde gezonde dag (23-08-2026) komt het teken in 93,4 % van de beoordeelde metingen overeen; op de vastgelegde foutdag (20-08-2026) ligt dat tussen 0,0 en 10,5 %, met tegengestelde periodes tot 1752 s. Er is geen reden om hieraan te sleutelen zonder een nieuwe meetcampagne die dat onderbouwt. De waarde moet onder het omvormervermogen blijven, anders kan de bewaking nooit aanslaan, en de add-on weigert die instelling."
sign_disagree_s:
name: "Veiligheid · Wachttijd tekenbewaking (s) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Hoe lang het setpoint en de meter onafgebroken tegengesteld moeten blijven voordat de add-on 0 W stuurt en vergrendelt tot iemand de regeling opnieuw start. 180 s ligt midden in een zeer brede gemeten marge: de langste terechte tegengestelde periode op de gezonde dag duurt 30 s, terwijl de foutdag bij 300 s nog altijd 13 keer aanslaat. Verander dit niet zonder nieuwe metingen."
unit_sign_disagree_w:
name: "Veiligheid · Tekendrempel per omvormer (W) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer doet deze instelling niets. AFGELEID UIT 1890 GESLOTEN-LUSTESTS op de testopstelling met twee omvormers (SAFETY-30), waarbij niets defect was, over het volledige bereik dat deze add-on toelaat voor versterking en volgsnelheid. 250 W is precies de helft van de verzadigingsdrempel, zodat het kleinste verschil dat hier zichtbaar is, exact de afwijking is waarbij de regeling zelf al zegt dat de omvormer niet volgt. Verander dit niet zonder de afleiding opnieuw te maken. De waarde moet onder het omvormervermogen blijven, anders kan ze nooit aanslaan."
unit_sign_disagree_s:
name: "Veiligheid · Wachttijd per omvormer (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30): 85 s is 8,5 keer de langste terechte tegengestelde periode die die tests opleveren (10,0 s), 2,8 keer het eigen veto van 30 s in de firmware, en minder dan de helft van de 180 s die de installatiebrede controle neemt. Verander dit niet zonder de afleiding opnieuw te maken."
site_sign_disagree_w:
name: "Veiligheid · Tekendrempel voor de hele installatie (W) — gemeten, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. Dezelfde 300 W als de drempel voor één omvormer, BEWUST OVERGENOMEN (SAFETY-30): de onderbouwing hangt niet af van het aantal omvormers - rond nul is het teken ruis, de dode zone is 15 W, en er is één meter, wat er ook achter hangt. Verander dit niet zonder metingen die dat onderbouwen."
site_sign_disagree_s:
name: "Veiligheid · Wachttijd voor de hele installatie (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30), en bewust NIET overgenomen van de 180 s voor één omvormer. In die tests duurt de langste terechte tegengestelde periode bij 300 W 110 s met één omvormer en 215 s met twee, omdat het commando de volledige installatiegrens moet doorlopen aan een volgsnelheid die niet die van de installatie is. 180 s overnemen zou dus een vals alarm hebben opgeleverd bij de laagste versterking en volgsnelheid en een gewone belastingomkering van 2,2 kW. 325 s is 1,51 keer die waarde, blijft binnen het factuurkwartier van 900 s en binnen elke episode van de vastgelegde foutdag. Verander dit niet zonder de afleiding opnieuw te maken."
meter_freeze_s:
name: "Veiligheid · Alarm vastgelopen meter na (s) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-18). Hoe lang de meter mag blijven antwoorden met een ONVERANDERDE waarde, terwijl het commando op de grens staat, voordat de add-on alarm slaat. Over 2399 s van deze woning op haar ingeregelde werkpunt - 1194 uitlezingen, waarvan 735 een herhaling van de vorige waarde - duurt de langste periode zonder een nieuwe waarde 15,4 s; 300 s is 19,5 keer zoveel en blijft binnen het factuurkwartier van 900 s. ALLEEN EEN ALARM: er wordt geen 0 W gestuurd en er wordt niets vergrendeld. ⚠️ Alleen de meterbron homewizard_local kan dit überhaupt zien; bij elke andere bron meldt de statuspagina NIET GEDEKT in plaats van actief. Verander dit niet zonder de afleiding opnieuw te maken."
circulation_w:
name: "Veiligheid · Drempel rondpompend vermogen (W) — gemeten, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer is deze grootheid altijd nul. Twee omvormers die tegen elkaar in duwen, heffen elkaar aan de meter op en zijn onzichtbaar voor elke andere bewaking in deze add-on. GEMETEN OVER 924 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-31): de langste terechte tegenwerking valt terug van 505 s bij een drempel van 100 W naar 5 s bij 400 W, en 400 W is de scherpe knik waaronder deze add-on u niet laat gaan - daaronder houdt gewone werking de sensor minutenlang boven de drempel en kan niets korter dan het factuurkwartier dat van een storing onderscheiden. 700 W is 1,75 keer de knik; die extra marge is er omdat het aanloopgedrag van de tweede omvormer nog gekopieerd is van de eerste en niet gemeten. Verander dit niet zonder de afleiding opnieuw te maken."
circulation_hold_s:
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
auto_start:
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
gain:
name: "Geavanceerd · Versterking van de regellus"
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
slew_w:
name: "Geavanceerd · Grootste verandering per cyclus (W)"
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. Dit begrenst hoe snel de installatie kan omkeren; verschillende veiligheidsvensters zijn over het volledige toegelaten bereik gemeten."
deadband_w:
name: "Geavanceerd · Dode zone (W)"
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."
target_grid_w:
name: "Geavanceerd · Rustpunt aan de meter (W)"
description: "Waar de meter moet staan wanneer er niets gebeurt; negatief betekent een lichte injectie. -10 W is bewust gekozen: rusten op 0 W lekt de hele dag door ongeveer de helft van de dode zone aan gefactureerde afname - grofweg 0,2 tot 0,35 kWh bij een dode zone van 15 W - en het rustpunt onder nul leggen verplaatst dat lek naar het injectieregister, dat niet gefactureerd wordt. Het kost ongeveer evenveel watt weggegeven injectie, dus houd het klein."
step_w:
name: "Geavanceerd · Afronding van het setpoint (W)"
description: "Het setpoint wordt op een veelvoud hiervan afgerond voordat het geschreven wordt, zodat de omvormer niet voor elke watt ruis een nieuw getal krijgt."
saturation_w:
name: "Geavanceerd · Verzadigingsdrempel (W)"
description: "Het verschil tussen wat gevraagd werd en wat de omvormer levert dat geldt als 'deze omvormer zit aan een grens'. ⚠️ De tekendrempel per omvormer onder Veiligheid is afgeleid als precies de helft hiervan, dus deze waarde verschuiven, verschuift ook de onderbouwing daarvan."
saturation_cycles:
name: "Geavanceerd · Verzadigde cycli voor bevriezen (aantal)"
description: "Hoeveel opeenvolgende verzadigde cycli er nodig zijn voordat de regeling stopt met verder oplopen. ⚠️ Zet dit niet op 1: één ruiscyclus zou de regeling dan al bevriezen."
integrator_max_w:
name: "Geavanceerd · Begrenzing van de integrator (W, 0 betekent gelijk aan het vermogen)"
description: "Begrenst hoeveel oude afwijking er kan klaarstaan om af te bouwen wanneer het teken omslaat. GEMETEN op de gesloten-lussimulator met een belasting van 4000 W die naar nul valt: bij een grens gelijk aan het omvormervermogen staat het commando twee cycli later op 1000 W, bij anderhalf keer dat vermogen op 1800 W. ⚠️ Zet dit niet hoger dan het omvormervermogen - de uitgangsbegrenzing bepaalt toch al wat er naar de omvormer gaat, dus extra ruimte levert alleen watts in de verkeerde richting op na elke verzadiging. Lager zetten dan het omvormervermogen is de nuttige richting, en de enige reden dat dit een aparte instelling is."
heartbeat_s:
name: "Geavanceerd · Verversingsinterval van het setpoint (s)"
description: "Hoe vaak het setpoint opnieuw geschreven wordt, ook als het niet veranderd is. Dit moet ruim onder de eigen bewaking van de firmware blijven, die een verbinding die 30 s stil is geweest, blokkeert."
stale_input_s:
name: "Geavanceerd · 0 W sturen nadat invoer ontbreekt gedurende (s)"
description: "Hoe lang de invoer mag ontbreken voordat de add-on 0 W stuurt. Bij een P1-meter begint deze teller pas te lopen NADAT de verouderingsgrens van de meter al verstreken is, dus de twee stapelen: het totaal is langer dan dit getal op zich."
cycle_floor_s:
name: "Geavanceerd · Langste tijd tussen twee regelcycli (s)"
description: "Een uiterste termijn voor de tijd tussen twee cycli, die door elke cyclus opnieuw begint. Bij een gezonde meter verstrijkt hij nooit en verandert hij niets. Verhoog hem alleen als uw meter trager is dan ongeveer 10 s, en houd hem onder de verouderingsgrens van de meter - daarna is er toch geen meting meer om te beoordelen."
log_level:
name: "Geavanceerd · Logniveau"
description: "Hoeveel detail de add-on naar zijn logboek schrijft. 'info' is de standaardinstelling; 'debug' en 'trace' dienen om een probleem te onderzoeken en leveren veel tekst op."