UX-46: Dutch status rows, banners, notes and wizard lines on the NL pages

Rows and banners are built once with their Dutch beside them (messages.Bi)
and status_payload() picks one by the page's language. Settings notes and the
wizard's checks carry a Dutch twin (Note.nl) that /config/validate picks by
the posted language. The peak forecast label drops "(W)" in EN and NL.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-22 03:21:41 +02:00
co-authored by Claude
parent e077ae7f97
commit 7a392a5367
10 changed files with 1244 additions and 204 deletions
+129 -39
View File
@@ -386,12 +386,23 @@ class Note(NamedTuple):
# also "ok" and "bad" (setup_checks)
keys: tuple
text: str
nl: str = "" # UX-46: the same sentence in Dutch
def _advise(notes, tier: str, keys, text: str) -> None:
"""Add a Note when the caller collects them (the settings page)."""
def _advise(notes, tier: str, keys, text: str, nl: str = "") -> None:
"""Add a Note when the caller collects them (the settings page). `nl` is
the Dutch twin of `text`; the page picks one (`note_text()`)."""
if notes is not None:
notes.append(Note(tier, tuple(keys), text))
notes.append(Note(tier, tuple(keys), text, nl))
def note_text(note, lang: str = "en") -> str:
"""The note's sentence in the page's language, English when it has no
Dutch twin."""
if lang == "nl":
return getattr(note, "nl", "") or messages.NOTE_NL.get(
note.text, note.text)
return note.text
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
@@ -628,6 +639,11 @@ def S(key: str, where: str = "") -> str:
return messages.setting(key, where.strip().strip("()"))
def SN(key: str, where: str = "") -> str:
"""`S()` with the Dutch label, for the Dutch twin of a note."""
return messages.setting(key, where.strip().strip("()"), "nl")
def _labels(keys, joiner: str = "and") -> str:
"""Several settings, each with its key: the config panel files a refusal
under every key it names (`configui.problems_by_field()`)."""
@@ -1216,7 +1232,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
integ_w, max_w)
_advise(notes, "warn", ("integrator_max_w",),
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
"adds overshoot, so set it to 0.")
"adds overshoot, so set it to 0.",
nl=f"{SN('integrator_max_w')} is hoger dan {SN('max_w')}, wat "
"alleen doorschieten geeft, dus zet het op 0.")
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
@@ -1244,7 +1262,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
_advise(notes, "warn", ("maintenance_discharge_w",),
f"{S('maintenance_discharge_w')} is above "
f"{S('max_w', maint_where)}, so the battery empties at "
f"{messages.watts(maint_cap)} and the cycle takes longer.")
f"{messages.watts(maint_cap)} and the cycle takes longer.",
nl=f"{SN('maintenance_discharge_w')} is hoger dan "
f"{SN('max_w', maint_where)}, dus de batterij loopt leeg met "
f"{messages.watts(maint_cap)} en de cyclus duurt langer.")
_LOG.debug(
"Not refused: the drain phase sets the setpoint, the arbiter "
"clamps it to %.0f W and names the clamp, and the drain phase has "
@@ -1473,7 +1494,9 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
TAXES_PROFILE_OPTION, negative)
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
"lower the price, and that is usually a sign error.")
"lower the price, and that is usually a sign error.",
nl=f"{SN(TAXES_PROFILE_OPTION)} heeft negatieve bedragen, "
"die de prijs verlagen, en dat is meestal een tekenfout.")
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00.
@@ -1532,7 +1555,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
_advise(notes, "warn", ("price_formula", "price_provider"),
f"{S('price_formula')} is not used, because "
f"{S('price_provider')} is not 'custom_formula', so clear "
"it or choose that source.")
"it or choose that source.",
nl=f"{SN('price_formula')} wordt niet gebruikt, omdat "
f"{SN('price_provider')} niet 'custom_formula' is, dus wis "
"ze of kies die bron.")
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1547,7 +1573,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
# cannot start.
_LOG.warning("%s", aged)
_advise(notes, "warn", ("price_formula",),
_card_note("price_formula", spec))
_card_note("price_formula", spec),
nl=_card_note("price_formula", spec, "nl"))
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
#
@@ -1630,7 +1657,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
xspec.offset_eur_kwh)
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
"only the shape of its numbers is checked.")
"only the shape of its numbers is checked.",
nl=f"{SN(EXPORT_FORMULA_OPTION)} heeft geen kaartblok, "
"dus alleen de vorm van de getallen wordt "
"gecontroleerd.")
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
@@ -1643,7 +1673,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
if xaged:
_LOG.warning("%s", xaged)
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
_card_note(EXPORT_FORMULA_OPTION, xspec))
_card_note(EXPORT_FORMULA_OPTION, xspec),
nl=_card_note(EXPORT_FORMULA_OPTION, xspec, "nl"))
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
@@ -1657,13 +1688,16 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
"fallback.", pid)
_advise(notes, "warn", ("static_profile",),
f"{S('static_profile')} is empty, so the site has no tariff "
"at all while the price feed is down.")
"at all while the price feed is down.",
nl=f"{SN('static_profile')} is leeg, dus de installatie heeft "
"helemaal geen tarief zolang de prijsfeed uitvalt.")
# PRICE-01: not a fault, so an info note and no log line here (the add-on
# logs it once at start). With `price_service` the fixed tariff is only the
# plan's last fallback; the cheap window and the gate do not read it.
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE)
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE,
nl=messages.FIXED_TARIFF_NOTE_NL)
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot
@@ -1735,7 +1769,11 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
return problems
def _card_note(option: str, spec) -> str:
def _card_note(option: str, spec, lang: str = "en") -> str:
if lang == "nl":
return (f"{SN(option)} gebruikt een tariefkaart van "
f"{spec.card_month}, dus neem de nieuwe cijfers over als uw "
"contract veranderde.")
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
"the new figures if your contract changed.")
@@ -1810,7 +1848,10 @@ def config_notes(opts: dict, *, entities: dict | None = None,
if month_peak_w is not None and cap is not None and cap < month_peak_w:
_advise(notes, "warn", ("peak_cap_w",),
f"This month's peak is already {messages.watts(month_peak_w)}, "
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
f"so a lower {S('peak_cap_w')} saves nothing until next month.",
nl=f"De piek van deze maand is al "
f"{messages.watts(month_peak_w)}, dus een lagere "
f"{SN('peak_cap_w')} bespaart niets tot volgende maand.")
return notes
@@ -1823,31 +1864,43 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
if row is None:
_advise(notes, "bad" if key in bad else "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
f"{S(key, where)} names.",
nl=f"Home Assistant heeft geen entiteit {entity}, terwijl "
f"{SN(key, where)} die noemt.")
continue
unit = str(row.get("unit") or "")
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
"taken as watts, so pick a sensor in W.",
nl=f"{entity} meldt in {unit}, maar {SN(key, where)} wordt "
"als watt gelezen, dus kies een sensor in W.")
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
_advise(notes, "warn", (key,),
messages.peak_forecast_unit(unit) + " Pick a sensor in "
"W or kW.")
"W or kW.",
nl=messages.peak_forecast_unit(unit, "nl")
+ " Kies een sensor in W of kW.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "
"forecast sensor in kWh.")
"forecast sensor in kWh.",
nl=f"{entity} meldt niet in kWh, dus kies een sensor voor "
"de zonneprognose in kWh.")
today = solarfc.entities(opts)
tomorrow = solarfc.entities(opts, tomorrow=True)
if (today or tomorrow) and \
opts.get("battery_modes_enabled") is not True:
_advise(notes, "info", ("pv_forecast_entity",),
"The solar forecast is used only with battery modes on.")
"The solar forecast is used only with battery modes on.",
nl="De zonneprognose wordt alleen gebruikt met batterijmodi "
"aan.")
if tomorrow and not today:
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
f"{S('pv_forecast_entity')} too.",
nl="De prognose voor morgen wordt alleen met die van vandaag "
f"gebruikt, dus stel ook {SN('pv_forecast_entity')} in.")
# --------------------------------------------------------------------------- #
@@ -1926,7 +1979,9 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
if entities is None:
# Red: only the list can tell a missing power request entity (SAFETY-77).
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
"answering, so press Check again once it is back.")
"answering, so press Check again once it is back.",
nl="Home Assistant antwoordt niet, dus druk op Opnieuw "
"nakijken zodra het terug is.")
else:
# A missing meter entity is red too (UX-15): with the kettle test
# skipped, nothing else on the way to the save would catch it.
@@ -1948,13 +2003,16 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
solar.append(None)
_advise(notes, "warn", ("pv_invert_entities",),
f"{entity} reads below zero now, so turn on its "
"“Reads negative” switch in the Solar step.")
"“Reads negative” switch in the Solar step.",
nl=f"{entity} meet nu onder nul, dus zet de schakelaar "
"“Meet negatief” aan bij de stap Zonnepanelen.")
else:
w = -w if entity in flipped else w
solar.append(w)
_advise(notes, "ok", ("pv_entity",),
f"{entity} reads {_signed(w)} of solar production "
"now.")
"now.",
nl=f"{entity} meet nu {_signed(w)} zonneproductie.")
# A battery sensor that reads the wrong way while giving power makes the
# house use less than nothing. Only with every term known: a missing
# solar reading could be the difference.
@@ -1964,7 +2022,10 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
_advise(notes, "warn", ("batt_invert",),
f"The readings add up to {_signed(sum(known))} of house "
"use, so check each "
"battery's “Battery power sign is reversed” switch in step 2.")
"battery's “Battery power sign is reversed” switch in step 2.",
nl=f"De metingen tellen op tot {_signed(sum(known))} "
"huisverbruik, dus controleer bij elke batterij de schakelaar "
"“Teken batterijvermogen omkeren” in stap 2.")
_price_check(notes, opts, running, prices_known)
return notes
@@ -1988,7 +2049,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
return float(meter_now_w)
_advise(notes, "info", ("meter_source",), "The add-on reads "
"this meter itself, so Home shows its reading after "
"the restart.")
"the restart.",
nl="De add-on leest deze meter zelf, dus Start toont de "
"meting na de herstart.")
return None
if entities is None:
return None
@@ -2001,7 +2064,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
if w is None:
_advise(notes, "warn", ("meter_source",),
f"{entity} reads {text} now, so check it in Home "
"Assistant before you start.")
"Assistant before you start.",
nl=f"{entity} meldt nu {text}, dus controleer ze in Home "
"Assistant voor u start.")
return None
total += sign * w
if source == "off" and opts.get("meter_invert"):
@@ -2012,12 +2077,14 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
def _meter_line(notes, w) -> None:
if abs(w) < 1.0:
text = "The meter reads 0 W now."
text, nl = "The meter reads 0 W now.", "De meter meldt nu 0 W."
elif w > 0:
text = f"The meter reads {messages.watts(w)} taken from the grid now."
nl = f"De meter meldt nu {messages.watts(w)} afname van het net."
else:
text = f"The meter reads {messages.watts(w)} fed into the grid now."
_advise(notes, "ok", ("meter_source",), text)
nl = f"De meter meldt nu {messages.watts(w)} injectie op het net."
_advise(notes, "ok", ("meter_source",), text, nl=nl)
def _card_checks(notes, who, entry, entities):
@@ -2025,40 +2092,55 @@ def _card_checks(notes, who, entry, entities):
positive while giving power, or None when it is not known."""
if entities is None:
return None
wie = who.replace(" for ", " voor ", 1)
setpoint = str(entry.get("setpoint_entity") or "").strip()
if setpoint and setpoint in entities:
_advise(notes, "ok", ("setpoint_entity",),
f"Home Assistant has {setpoint}, the power request "
f"entity{who}.")
f"entity{who}.",
nl=f"Home Assistant heeft {setpoint}, de entiteit voor de "
f"vermogensvraag{wie}.")
soc = str(entry.get("soc_entity") or "").strip()
if not soc:
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
f"level entity{who}, so pick one in step 2 or the add-on "
"holds 0 W.")
"holds 0 W.",
nl=f"Er is geen entiteit voor het laadniveau{wie}, dus kies er "
"een in stap 2 of de add-on houdt 0 W aan.")
elif soc in entities:
level, _unit = _reading(entities, soc)
if level is None or not 0.0 <= level <= 100.0:
_advise(notes, "warn", ("soc_entity",), f"The battery "
f"level{who} reads nothing now, so check {soc}.")
f"level{who} reads nothing now, so check {soc}.",
nl=f"Het laadniveau{wie} meldt niets, dus controleer "
f"{soc}.")
else:
_advise(notes, "ok", ("soc_entity",),
f"The battery level{who} reads {level:.0f} %.")
f"The battery level{who} reads {level:.0f} %.",
nl=f"Het laadniveau{wie} meldt {level:.0f} %.")
batt = str(entry.get("batt_entity") or "").strip()
if batt and batt in entities:
w, _unit = _reading(entities, batt)
if w is None:
_advise(notes, "warn", ("batt_entity",), f"The battery "
f"power{who} reads nothing now, so check {batt}.")
f"power{who} reads nothing now, so check {batt}.",
nl=f"Het batterijvermogen{wie} meldt niets, dus controleer "
f"{batt}.")
return None
w = -w if entry.get("batt_invert") else w
if abs(w) < IDLE_W:
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
"idle now, so check its direction on Home later.")
"idle now, so check its direction on Home later.",
nl=f"De batterij{wie} staat nu stil, dus controleer later "
"op Start de richting.")
else:
doing = "giving power" if w > 0 else "charging"
doet = "levert" if w > 0 else "laadt"
_advise(notes, "info", ("batt_invert",),
f"The battery{who} reads {messages.watts(w)} {doing} "
"now, so check the inverter shows the same.")
"now, so check the inverter shows the same.",
nl=f"De batterij{wie} {doet} nu {messages.watts(w)}, dus "
"controleer of de omvormer hetzelfde toont.")
return w
return None
@@ -2070,20 +2152,28 @@ def _price_check(notes, opts, running, prices_known) -> None:
if profile:
_advise(notes, "ok", ("static_profile",),
"Your prices come from your contract, so they are "
"always known.")
"always known.",
nl="Uw prijzen komen uit uw contract, dus ze zijn altijd "
"bekend.")
else:
_advise(notes, "info", ("price_provider",),
"No prices are set, so the batteries do not charge "
"on price.")
"on price.",
nl="Er zijn geen prijzen ingesteld, dus de batterijen "
"laden niet op prijs.")
return
same = (isinstance(running, dict)
and str(running.get("price_provider") or "") == provider)
if same and prices_known:
_advise(notes, "ok", ("price_provider",),
f"Prices for today are in, for {prices_known} of 24 hours.")
f"Prices for today are in, for {prices_known} of 24 hours.",
nl=f"De prijzen van vandaag zijn binnen, voor {prices_known} "
"van 24 uur.")
else:
_advise(notes, "info", ("price_provider",),
"Prices are fetched after the restart, and Home shows them.")
"Prices are fetched after the restart, and Home shows them.",
nl="De prijzen worden na de herstart opgehaald, en Start toont "
"ze.")
def _margin_problems(opts: dict) -> list[str]: