From 7a392a5367365ef1f11f6421d7739de13d2b1e2f Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 22 Oct 2026 03:21:41 +0200 Subject: [PATCH 1/3] 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 --- goodwe_controller/DOCS.md | 2 +- goodwe_controller/app/main.py | 18 +- goodwe_controller/app/messages.py | 648 +++++++++++++++++++------ goodwe_controller/app/setuponly.py | 6 +- goodwe_controller/app/uipage.py | 4 +- goodwe_controller/app/validate.py | 168 +++++-- goodwe_controller/app/web.py | 20 +- goodwe_controller/test_ux46.py | 578 ++++++++++++++++++++++ goodwe_controller/translations/en.yaml | 2 +- goodwe_controller/translations/nl.yaml | 2 +- 10 files changed, 1244 insertions(+), 204 deletions(-) create mode 100644 goodwe_controller/test_ux46.py diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index fb1be9c..27b7b20 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -211,7 +211,7 @@ The label and description each setting shows on the Configuration tab, in tab or | Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. | | Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. | | Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. | -| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. | +| Limits | 15-minute demand forecast entity | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. | | Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. | | Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. | | Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. | diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 1a301a0..290068d 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -7464,7 +7464,8 @@ class Controller: level, banner = "warn", messages.banner_stopped() elif runs: level, running_alarm = "bad", True - banner = runs[0].get("banner") or messages.headline(runs[0]["text"]) + banner = runs[0].get("banner") or messages.headline_bi( + runs[0]["text"]) elif any(i.delivery_wd.tripped for i in self.inverters): # ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE, # WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE. @@ -7487,11 +7488,11 @@ class Controller: # character; at two, "Maintenance: drain" without a name is a # banner an operator cannot act on, because the remedy is at one # device and the site has two. - level, banner = "warn", f"Maintenance: {self.maint.phase}" - if len(self.inverters) > 1: - banner += f" ({self.maint_unit.name})" + level, banner = "warn", messages.banner_maintenance( + self.maint.phase, + self.maint_unit.name if len(self.inverters) > 1 else "") else: - level, banner = "ok", "Running — holding grid at zero" + level, banner = "ok", messages.banner_running() def w(v): return "—" if v is None else f"{v:,.0f} W".replace(",", " ") @@ -7595,6 +7596,13 @@ class Controller: now=datetime.now(timezone.utc), why=self.why_words)), ] held = level == "bad" and not running_alarm + # UX-46. The rows were built in English with their Dutch beside them + # (`messages.Bi`); the page's language is chosen once, here, for the + # Home sentences, the checks list and the rows that carry one. The + # banner above stays English: it is the plain fallback. + checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks] + rows = [(k, messages.pick(v, lang)) for k, v in rows] + banner = messages.pick(banner, lang) out = { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 0c1344f..f8c350a 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -31,6 +31,54 @@ def duration(seconds) -> str: return f"{s / 60:.0f} minutes" +class Bi(str): + """UX-46. A status row in both languages. It is its English text, so every + caller and every log line sees English, and `.nl` is the Dutch. The Home + page picks one with `pick()` where it knows the page's language.""" + + def __new__(cls, en, nl=None): + out = super().__new__(cls, en) + out.nl = nl + return out + + def __getnewargs__(self): + return (str(self), self.nl) + + +def pick(text, lang="en"): + """`text` in `lang`. A `Bi` gives its Dutch for "nl"; any other text, and + any language but "nl", gives the text as it is.""" + if lang == "nl" and isinstance(text, Bi) and text.nl: + return str(text.nl) + return str(text) if isinstance(text, Bi) else text + + +def seconden(value) -> str: + n = f"{float(value):.0f}" + return f"{n} seconde" if n == "1" else f"{n} seconden" + + +def duur(value) -> str: + s = float(value) + return f"{s:.0f} seconden" if s < 120 else f"{s / 60:.0f} minuten" + + +def keer(n) -> str: + return f"{int(n)} keer" + + +def voor(unit) -> str: + """` voor inverter-b` on a site with several inverters, else nothing.""" + return f" voor {unit}" if unit else "" + + +def namen(items) -> str: + items = list(items) + if len(items) <= 1: + return "".join(items) + return ", ".join(items[:-1]) + " en " + items[-1] + + def names(items) -> str: items = list(items) if len(items) <= 1: @@ -129,6 +177,13 @@ S_NOT_DELIVERING = "An inverter is not delivering" T_METER_FROZEN = "Meter reading may be stuck" T_MAINT_FAILED = "Battery cycle did not finish" T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond" +# UX-46: the Dutch of the titles the rows repeat. The Home page's own titles +# (ui_text/nl.json, "attn") are the same words. +T_LOOP_BOUND_NL = "Batterij kan niet volgen" +T_OUTPUT_CLAMP_NL = "Vermogensvraag te hoog" +T_PEAK_SATURATED_NL = "Belasting te hoog voor de batterij" +T_METER_FROZEN_NL = "Meter staat mogelijk vast" +TITLE_NL = {T_LOOP_BOUND: T_LOOP_BOUND_NL, T_OUTPUT_CLAMP: T_OUTPUT_CLAMP_NL} T_NO_PRICES = "No prices" T_NO_PRICES_NL = "Prijzen ontbreken" @@ -387,13 +442,18 @@ def fixed_tariff_card_gone() -> Dismiss: def row_no_prices(since=None, now=None) -> str: """PRICE-01: the status row while no price covers now.""" if since is not None: - return f"No prices since {since_text(since, now)}, maintenance waits." - return "No prices yet, maintenance waits." + return Bi(f"No prices since {since_text(since, now)}, maintenance " + "waits.", + f"Geen prijzen sinds {since_text(since, now, 'nl')}, " + "onderhoud wacht.") + return Bi("No prices yet, maintenance waits.", + "Nog geen prijzen, onderhoud wacht.") def row_maint_waits(until_text: str) -> str: """MAINT-13: the status row while a failed cycle waits.""" - return f"Waits until {until_text} after a cycle that did not finish." + return Bi(f"Waits until {until_text} after a cycle that did not finish.", + f"Wacht tot {until_text} na een cyclus die niet af was.") # --- the status page (UX-08 phase 2b) ---------------------------------------- @@ -405,6 +465,8 @@ def row_maint_waits(until_text: str) -> str: # DOCS.md, "Status page", explains every row. NOT_READY = "Not ready" +BANNER_RUNNING = ("Running — holding grid at zero", + "Actief — het net blijft op nul") DOCS_STATUS = "Status page" @@ -462,42 +524,72 @@ def headline(text: str) -> str: # Banners (the headline at the top of the page). +def headline_bi(text): + """`headline()` of a row, in both languages.""" + return Bi(headline(text), headline(getattr(text, "nl", None) or text)) + + def banner_not_ready(row: dict) -> str: - return f"{NOT_READY} — {row.get('banner') or headline(row['text'])}" + part = row.get("banner") or headline_bi(row["text"]) + return Bi(f"{NOT_READY} — {part}", + f"Niet klaar — {getattr(part, 'nl', None) or part}") def banner_circulating() -> str: - return "Inverters worked against each other. Every inverter is set to 0 W." + return Bi("Inverters worked against each other. Every inverter is set to " + "0 W.", + "Omvormers werkten elkaar tegen. Elke omvormer staat op 0 W.") def banner_site_direction() -> str: - return "Total power went the wrong way. All inverters at 0 W." + return Bi("Total power went the wrong way. All inverters at 0 W.", + "Totaal vermogen ging de verkeerde kant op. Alle omvormers op " + "0 W.") def banner_direction(several: bool) -> str: if several: - return "An inverter's own power went the wrong way. Set to 0 W." - return "The meter saw power go the wrong way. Inverter at 0 W." + return Bi("An inverter's own power went the wrong way. Set to 0 W.", + "Eigen vermogen van een omvormer ging verkeerd. Op 0 W " + "gezet.") + return Bi("The meter saw power go the wrong way. Inverter at 0 W.", + "De meter zag het vermogen verkeerd gaan. Omvormer op 0 W.") def banner_not_delivering(units) -> str: units = list(units) verb = "are" if len(units) > 1 else "is" - return f"{names(units)} {verb} not delivering. Control keeps running." + werkwoord = "leveren" if len(units) > 1 else "levert" + return Bi(f"{names(units)} {verb} not delivering. Control keeps running.", + f"{namen(units)} {werkwoord} niet. De regeling blijft draaien.") + + +def banner_maintenance(phase: str, unit: str = "") -> str: + where = f" ({unit})" if unit else "" + return Bi(f"Maintenance: {phase}{where}", f"Onderhoud: {phase}{where}") + + +def banner_running() -> str: + return Bi(*BANNER_RUNNING) def banner_stopped() -> str: - return "Stopped — the inverters are set to 0 W" + return Bi("Stopped — the inverters are set to 0 W", + "Gestopt — de omvormers staan op 0 W") def banner_setup_only() -> str: """UX-15: setup-only mode, where the add-on serves the page and commands nothing (app/setuponly.py).""" - return "Setup: nothing is controlled yet" + return Bi("Setup: nothing is controlled yet", + "Installatie: nog niets wordt geregeld") def row_setup_only() -> str: - return "No inverter is named yet, so finish Setup and restart the add-on." + return Bi("No inverter is named yet, so finish Setup and restart the " + "add-on.", + "Er is nog geen omvormer benoemd, dus rond de installatie af " + "en herstart de add-on.") def banner_shadow(latched: bool) -> str: @@ -508,84 +600,117 @@ def banner_shadow(latched: bool) -> str: clears. """ if latched: - return ("Shadow mode: battery not controlled. A safety stop stays on, " - "see below.") - return "Shadow mode: the battery is not controlled. Inverters stay at 0 W." + return Bi("Shadow mode: battery not controlled. A safety stop stays " + "on, see below.", + "Schaduwmodus: batterij niet geregeld. Een veiligheidsstop " + "blijft aan, zie hieronder.") + return Bi("Shadow mode: the battery is not controlled. Inverters stay at " + "0 W.", + "Schaduwmodus: de batterij wordt niet geregeld. Omvormers " + "blijven op 0 W.") def banner_ha_waiting() -> str: """SAFETY-112: the Supervisor passes nothing on until Home Assistant has finished starting, so the add-on can neither read nor write.""" - return "Waiting for Home Assistant to finish starting" + return Bi("Waiting for Home Assistant to finish starting", + "Wachten tot Home Assistant is opgestart") def row_ha_waiting() -> str: - return ("Home Assistant is not answering, perhaps it is still starting. " - "Control resumes by itself when it answers.") + return Bi("Home Assistant is not answering, perhaps it is still " + "starting. Control resumes by itself when it answers.", + "Home Assistant antwoordt niet, misschien start het nog op. " + "De regeling gaat vanzelf verder zodra het antwoordt.") # Rows: control and the meter. "Control" is what the add-on does, as in # "start control" (STYLE.md section 4). def row_controller_starting() -> str: - return "Control: starting" + return Bi("Control: starting", "Regeling: start op") def row_controller_ran(age_s) -> str: - return f"Control: last ran {seconds(age_s)} ago" + return Bi(f"Control: last ran {seconds(age_s)} ago", + f"Regeling: laatst uitgevoerd {seconden(age_s)} geleden") def row_controller_stalled(age_s, veto_s) -> str: - return (f"Control has not run for {seconds(age_s)}. The control board " - f"sets the inverter to 0 W after {seconds(veto_s)}.") + return Bi(f"Control has not run for {seconds(age_s)}. The control board " + f"sets the inverter to 0 W after {seconds(veto_s)}.", + f"De regeling is al {seconden(age_s)} niet uitgevoerd. De " + f"stuurmodule zet de omvormer na {seconden(veto_s)} op 0 W.") def row_meter_waiting(limit_s) -> str: """UX-10: no reading yet since the start, and still inside the limit.""" - return f"Meter: waiting for the first reading, limit {seconds(limit_s)}" + return Bi(f"Meter: waiting for the first reading, limit " + f"{seconds(limit_s)}", + f"Meter: wacht op de eerste meting, limiet {seconden(limit_s)}") def row_meter_stale(age_s, limit_s) -> str: - return (f"Meter: no reading for {seconds(age_s)}, limit " - f"{seconds(limit_s)}. The add-on log shows why.") + return Bi(f"Meter: no reading for {seconds(age_s)}, limit " + f"{seconds(limit_s)}. The add-on log shows why.", + f"Meter: al {seconden(age_s)} geen meting, limiet " + f"{seconden(limit_s)}. De log van de add-on toont waarom.") def row_meter(net_w, age_s, rejected=0, unchanged_s=None) -> str: - return (f"Meter: {signed_watts(net_w)}, {seconds(age_s)} old" - + (f", {rejected} readings refused" if rejected else "") - + ("" if unchanged_s is None - else f", unchanged for {duration(unchanged_s)}")) + return Bi(f"Meter: {signed_watts(net_w)}, {seconds(age_s)} old" + + (f", {rejected} readings refused" if rejected else "") + + ("" if unchanged_s is None + else f", unchanged for {duration(unchanged_s)}"), + f"Meter: {signed_watts(net_w)}, {seconden(age_s)} oud" + + (f", {rejected} metingen geweigerd" if rejected else "") + + ("" if unchanged_s is None + else f", ongewijzigd gedurende {duur(unchanged_s)}")) def row_meter_frozen() -> str: - return (f"{T_METER_FROZEN}. Control still follows it. Check the meter, " - f"and stop control if it is stuck.") + return Bi(f"{T_METER_FROZEN}. Control still follows it. Check the meter, " + f"and stop control if it is stuck.", + f"{T_METER_FROZEN_NL}. De regeling volgt hem nog. Controleer " + f"de meter en stop de regeling als hij vastzit.") # Settings are named by their labels in translations/en.yaml. METER_SOURCE = "“Where the meter reading comes from”" +METER_SOURCE_NL = "“Waar de meterstand vandaan komt”" FREEZE_OFF = { "source": f"it needs {METER_SOURCE} set to HomeWizard local", "rating": "“Inverter rating” is not set", "range": "the inverter reports no usable power range", } +FREEZE_OFF_NL = { + "source": f"vereist {METER_SOURCE_NL} op HomeWizard local", + "rating": "“Vermogen van de omvormer” is niet ingesteld", + "range": "de omvormer meldt geen bruikbaar vermogensbereik", +} def row_freeze_off(why: str) -> str: """`why` is a key of FREEZE_OFF: why the stuck-meter check cannot run.""" - return f"Stuck-meter check: off, {FREEZE_OFF[why]}" + return Bi(f"Stuck-meter check: off, {FREEZE_OFF[why]}", + f"Controle op vastgelopen meter: uit, {FREEZE_OFF_NL[why]}") def row_freeze_on(hold_s, rail_w, own_limit: bool) -> str: - return (f"Stuck-meter check: alerts after {duration(hold_s)} unchanged " - f"above {watts(rail_w)}" - + (", the inverter's own limit" if own_limit else "")) + return Bi(f"Stuck-meter check: alerts after {duration(hold_s)} unchanged " + f"above {watts(rail_w)}" + + (", the inverter's own limit" if own_limit else ""), + f"Vastgelopen meter: alarm na {duur(hold_s)} gelijk boven " + f"{watts(rail_w)}" + + (", limiet van de omvormer" if own_limit else "")) # Rows: the peak guard. def row_peak_saturated(ceiling_w) -> str: - return (f"{T_PEAK_SATURATED}. Switch off large loads to stay under the " - f"{watts(ceiling_w)} peak limit.") + return Bi(f"{T_PEAK_SATURATED}. Switch off large loads to stay under the " + f"{watts(ceiling_w)} peak limit.", + f"{T_PEAK_SATURATED_NL}. Schakel grote verbruikers uit om onder " + f"de piekgrens van {watts(ceiling_w)} te blijven.") def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, @@ -593,95 +718,142 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, """SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay. `others`: as in `peak_reserve()` (SAFETY-87).""" if soc_unread: - why = "Battery level cannot be read" + why, why_nl = ("Battery level cannot be read", + "Het laadniveau is niet leesbaar") elif floor_unknown: - why = "A battery cut-out level is not set" + why, why_nl = ("A battery cut-out level is not set", + "Een uitschakelniveau van de batterij is niet ingesteld") else: - why = (f"{names(packs)} battery at its peak reserve" if packs - else "Battery at its peak reserve") + why, why_nl = ((f"{names(packs)} battery at its peak reserve", + f"Batterij van {namen(packs)} op de piekreserve") + if packs else + ("Battery at its peak reserve", + "Batterij op de piekreserve")) if others: - return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak " - f"limit alone. Home use still draws it lower, to the cut-out " - f"level.") - return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home " - f"use still draws the battery below it, down to the cut-out level.") + return Bi(f"{why}. The others cannot hold the {watts(ceiling_w)} peak " + f"limit alone. Home use still draws it lower, to the " + f"cut-out level.", + f"{why_nl}. De andere kunnen de piekgrens van " + f"{watts(ceiling_w)} niet alleen houden. Het huis trekt de " + f"batterij nog leger, tot het uitschakelniveau.") + return Bi(f"{why}, so the {watts(ceiling_w)} peak limit will be passed. " + f"Home use still draws the battery below it, down to the " + f"cut-out level.", + f"{why_nl}, dus de piekgrens van {watts(ceiling_w)} wordt " + f"overschreden. Het huis trekt de batterij nog leger, tot het " + f"uitschakelniveau.") def row_peak_guard_off() -> str: """`meter_source` is off: the 15-minute average cannot be measured.""" - return f"Peak guard: off, set {METER_SOURCE} to anything but off" + return Bi(f"Peak guard: off, set {METER_SOURCE} to anything but off", + f"Piekbewaking: uit, zet {METER_SOURCE_NL} op iets anders dan " + f"uit") def banner_peak_reserve() -> str: - return T_PEAK_RESERVE + return Bi(T_PEAK_RESERVE, "Batterij beperkt de piek niet meer") # Rows: the sensors and the inverter control. LABEL_SOC = "Battery level" LABEL_BATTERY = "Battery power" LABEL_GRID = "Grid power" +# UX-46: the same three in Dutch, and the settings that name them. +LABEL_NL = {LABEL_SOC: "Laadniveau", LABEL_BATTERY: "Batterijvermogen", + LABEL_GRID: "Netvermogen"} +LABEL_ENTITY_NL = {LABEL_SOC: "Entiteit laadniveau", + LABEL_BATTERY: "Entiteit batterijvermogen", + LABEL_GRID: "Entiteit netvermogen"} def row_sensor(label, value, unit=None) -> str: shown = f"{float(value):g} %" if label.startswith(LABEL_SOC) \ else signed_watts(value) - return f"{label}{for_unit(unit)}: {shown}" + return Bi(f"{label}{for_unit(unit)}: {shown}", + f"{LABEL_NL.get(label, label)}{voor(unit)}: {shown}") def row_sensor_unset(label, unit=None) -> str: """The setting is "