diff --git a/STYLE.md b/STYLE.md index 1014aae..d86db9a 100644 --- a/STYLE.md +++ b/STYLE.md @@ -7,7 +7,7 @@ This applies to everything a home owner or installer reads: notifications, phone - **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost. - **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body. - **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md. -- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words from `app/homeview.py`, checked by `test_messages.py` part 8. +- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words in `app/ui_text/<lang>.json` under `home_words`, built by `app/homeview.py`, checked by `test_messages.py` part 8. The page's own words, English and Dutch, are checked by `test_ui_text.py`. - **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals. - **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3). - **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON. diff --git a/docs/img/ux13/README.md b/docs/img/ux13/README.md new file mode 100644 index 0000000..ad3fbd8 --- /dev/null +++ b/docs/img/ux13/README.md @@ -0,0 +1,9 @@ +# UX-13 screenshots + +Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not +edit them by hand: run the script again. + +- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup. +- `settings-*.png`: Settings (Installer view), and the time-window editor with an overlap. +- `setup-step-1*.png`: Setup, step 1. +- Battery modes (the Batteries screen and the Simple view): not built yet (UX-13 P3), so no screenshot. diff --git a/docs/img/ux13/home-phone.png b/docs/img/ux13/home-phone.png new file mode 100644 index 0000000..0a3788f Binary files /dev/null and b/docs/img/ux13/home-phone.png differ diff --git a/docs/img/ux13/home-running-nl.png b/docs/img/ux13/home-running-nl.png new file mode 100644 index 0000000..21f5a47 Binary files /dev/null and b/docs/img/ux13/home-running-nl.png differ diff --git a/docs/img/ux13/home-running.png b/docs/img/ux13/home-running.png new file mode 100644 index 0000000..51d972d Binary files /dev/null and b/docs/img/ux13/home-running.png differ diff --git a/docs/img/ux13/home-setup-only.png b/docs/img/ux13/home-setup-only.png new file mode 100644 index 0000000..be38b56 Binary files /dev/null and b/docs/img/ux13/home-setup-only.png differ diff --git a/docs/img/ux13/home-shadow.png b/docs/img/ux13/home-shadow.png new file mode 100644 index 0000000..a16bfa7 Binary files /dev/null and b/docs/img/ux13/home-shadow.png differ diff --git a/docs/img/ux13/settings-installer-nl.png b/docs/img/ux13/settings-installer-nl.png new file mode 100644 index 0000000..74dd7c9 Binary files /dev/null and b/docs/img/ux13/settings-installer-nl.png differ diff --git a/docs/img/ux13/settings-installer.png b/docs/img/ux13/settings-installer.png new file mode 100644 index 0000000..57258b8 Binary files /dev/null and b/docs/img/ux13/settings-installer.png differ diff --git a/docs/img/ux13/settings-time-windows-overlap.png b/docs/img/ux13/settings-time-windows-overlap.png new file mode 100644 index 0000000..9dc9ca9 Binary files /dev/null and b/docs/img/ux13/settings-time-windows-overlap.png differ diff --git a/docs/img/ux13/settings-time-windows.png b/docs/img/ux13/settings-time-windows.png new file mode 100644 index 0000000..1614338 Binary files /dev/null and b/docs/img/ux13/settings-time-windows.png differ diff --git a/docs/img/ux13/setup-step-1-nl.png b/docs/img/ux13/setup-step-1-nl.png new file mode 100644 index 0000000..4727b1b Binary files /dev/null and b/docs/img/ux13/setup-step-1-nl.png differ diff --git a/docs/img/ux13/setup-step-1.png b/docs/img/ux13/setup-step-1.png new file mode 100644 index 0000000..a51f31b Binary files /dev/null and b/docs/img/ux13/setup-step-1.png differ diff --git a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md index 310e5b6..e5d16b3 100644 --- a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md +++ b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md @@ -70,6 +70,23 @@ What UX-02 made authoritative stays authoritative: Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks are rules, so they are in `validate.py` (`setup_checks`). settings.js still names no key. See [[UX-13-setup-wizard]]. +- P5: the page words are in `app/ui_text/en.json` and `nl.json`, one key set, + loaded by `app/uitext.py` with an English fallback per key. The page picks + Dutch when the browser's language is `nl`, as the classic page does. Home's + sentences moved from `homeview.py` into `home_words`, so `/status?lang=nl` + builds them in Dutch. Status rows, banners, hints and validator sentences + (`messages.py`, `validate.py`) stay English: they are shared with + notifications, the log and the start refusal. `test_ui_text.py` checks both + files against STYLE.md and each other, using the rules in + `tools/style_rules.py`, which it shares with test_messages.py. +- P5: "Other time windows" is a list editor (`ui.periods`, control + `period_list`). `uimodel._periods_json()` builds `static_profile` and names + the first bad time, empty price, overlap or gap. Its tiling rule matches + `static_tariff._check_tiling`. A stored profile whose days the editor has no + choice for (for example "mon,wed") is still kept as stored and edited in the + Configuration tab. Supervisor 2026.08.0's `AppOptions` accepted all 133 + wizard blobs, 17 of them with time windows + (`tools/ux13_wizard_blobs.py --supervisor`, run offline on 2026-09-28). ## 4. What this costs diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 8a8712b..72bb441 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -2886,6 +2886,12 @@ works under hourly pricing is an EMS for a minority. `static_profile` is how a two-period day/night tariff or a single fixed price becomes a first-class configuration rather than "turn the price feature off and hope". +On the add-on page, choose **Other time windows** under Settings, Tariff and +prices, or in Setup step 4. Add one row per time window, with its start, end, +days and price. The page names any overlap, gap or empty price before you can +save. You only need the JSON below for days the page cannot choose, such as +Monday and Wednesday only. + It is one option, holding a JSON object, on one line: ```json @@ -3221,6 +3227,18 @@ action log (`Controller.events`). The page no longer shows that log, but the page and add `status` to its address. The action-log tables in this document refer to it.* +### Language and screenshots + +The page is in Dutch when your browser's language is Dutch, and in English +otherwise. Status lines, alarms and the start checks' messages stay in English. + +Screenshots of each screen are in the add-on's repository, under +`docs/img/ux13/`. The battery-mode screens are not built yet, so there are no +screenshots of them. + +*For installers: regenerate the screenshots with +`tools/ux13_screenshots.sh`, which serves the page from test fixtures.* + ### Setup, for a new install The **Setup** tab walks an installer through six steps: the meter, the diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index c6c62c6..1361661 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -16,7 +16,7 @@ section at the bottom and is held to STYLE.md by test_messages.py. from datetime import datetime, timedelta, timezone -from . import arbitrage, messages, peakguard +from . import arbitrage, messages, peakguard, uitext from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD from .maintenance import IDLE as M_IDLE from .price import Absence, Uncertainty, brussels @@ -54,18 +54,6 @@ def hhmm(t: datetime) -> str: return f"{brussels(t):%H:%M}" -def _kw(w) -> str: - return f"{abs(float(w)) / 1000:.1f} kW" - - -def _eur(v) -> str: - return f"€{float(v):.2f}" - - -def _kwh(v) -> str: - return f"{float(v):.1f} kWh" - - # --------------------------------------------------------------------------- # # Now # --------------------------------------------------------------------------- # @@ -490,8 +478,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True): "day": brussels(now).date().isoformat(), "now_h": round(_hours(now, day0), 3), "hours": int(round(_hours(day1, day0))), - "verdict": (VERDICT[verdict_key] if settled or verdict_key != "held" - else ZERO_GOING), + "verdict": verdict_words(verdict_key, settled), "verdict_key": verdict_key, "head": plan_head(verdict_key, shadow, settled), "prices": bars, @@ -537,7 +524,7 @@ def battery_rows(c, shadow, running, held=False): "stored_kwh": (None if cap is None or inv.soc is None else round(cap * float(inv.soc) / 100.0, 2)), "state": flow(inv.batt, shadow=shadow), - "mode": MODE_HOUSE, + "mode": mode_house(), "maintenance": phase, "now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow, running=running, held=held, @@ -549,28 +536,6 @@ def battery_rows(c, shadow, running, held=False): return rows -# Row id -> the title on Home. The row's own sentence is the body. -ATTN_TITLES = { - "controller": "Control is not running", - "meter": "Meter reading", - "meter_frozen": messages.T_METER_FROZEN, - "freeze_check": "Stuck meter check", - "month_peak": "This month's peak", - "peak_guard": "Peak limit", - "peak_forecast": "Peak limit forecast", - "sensor": "Battery readings", - "control": "Inverter control", - "power_too_high": messages.T_OUTPUT_CLAMP, - "not_keeping_up": messages.T_LOOP_BOUND, - "write": "Power request not accepted", - "direction": "Power went the wrong way", - "site_direction": "Power went the wrong way", - "circulating": messages.T_CIRCULATING, - "delivery": messages.S_NOT_DELIVERING, - "estop": "Emergency stop", - "pv": "Solar reading", - None: "Inverter connection", -} # Row id -> the DOCS.md heading under Notifications, where one exists. ATTN_DOCS = { "meter_frozen": messages.T_METER_FROZEN, @@ -594,7 +559,7 @@ def attention(checks): sev = INFO if ok else (WARNING if warn else ALARM) rid = row.get("id") out.append({"severity": sev, "id": rid, - "title": ATTN_TITLES.get(rid, ATTN_TITLES[None]), + "title": attn_title(rid), "body": row.get("text", ""), "docs": ATTN_DOCS.get(rid)}) out.sort(key=lambda a: rank[a["severity"]]) @@ -605,7 +570,7 @@ def month_peak(c, now): """This month's peak against the limit, and the 15-minute period so far.""" limit_w = c.peak.cap_w # the limit the controller parsed at start local = brussels(now) - out = {"month": f"{local:%B}", "limit_w": limit_w, "peak_w": None, + out = {"month": month_name(local), "limit_w": limit_w, "peak_w": None, "pct": None, "period_w": None, "state": "not_covered", "line": ""} if not c.p1_enabled: @@ -644,8 +609,18 @@ def state_of(c, level, shadow): return "running" -def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict: - """The structured half of `/status`. Pure reads of the controller.""" +def build(c, *, level: str, checks: list, now: datetime | None = None, + lang: str = "en") -> dict: + """The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of + the controller.""" + _LANG[0] = uitext.lang_of(lang) + try: + return _build(c, level=level, checks=checks, now=now) + finally: + _LANG[0] = "en" + + +def _build(c, *, level, checks, now): now = now or datetime.now(timezone.utc) shadow = bool(c.shadow) kind = state_of(c, level, shadow) @@ -667,7 +642,7 @@ def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict: "plan": build_plan(c, now, shadow, running, held=held, settled=settled), "attention": ([{"severity": INFO, "id": "shadow", - "title": ATTN_SHADOW, "body": attn_shadow_body(), + "title": attn_shadow(), "body": attn_shadow_body(), "docs": None}] if shadow else []) + attention(checks), "month_peak": month_peak(c, now), "control": {"running": bool(c.auto), "shadow": shadow, @@ -687,279 +662,309 @@ def _docs_url(): # The words. STYLE.md: plain, short, present tense. A plan line is at most # two sentences of at most 12 words each (UX-13 design, section 4); a title at # most 5 words; everything else one sentence of at most 25 words. In shadow -# mode a text says what the add-on WOULD do. test_messages.py renders every -# function below through `text_samples()` and checks it. +# mode a text says what the add-on WOULD do. The sentences themselves are in +# app/ui_text/<lang>.json under `home_words` (UX-13 P5: English and Dutch). +# test_messages.py and test_ui_text.py render every function below through +# `text_samples()` in each language and check it. # =========================================================================== # -MODE_HOUSE = "Follows the house" -ATTN_SHADOW = "Shadow mode is on" +_LANG = ["en"] # the language of the build in progress (build()) +_WORDS: dict = {} + + +def _hw() -> dict: + lang = _LANG[0] + if lang not in _WORDS: + _WORDS[lang] = uitext.words(lang) + return _WORDS[lang] + + +def _t(path, **values) -> str: + node = _hw()["home_words"] + for k in path.split("."): + node = node[k] + return uitext.say(node, **values) + + +def _dec(text) -> str: + """A decimal comma in Dutch.""" + return text.replace(".", ",") if _LANG[0] == "nl" else text + + +def _kw(w) -> str: + return _dec(f"{abs(float(w)) / 1000:.1f}") + " kW" + + +def _eur(v) -> str: + return "€" + _dec(f"{float(v):.2f}") + + +def _kwh(v) -> str: + return _dec(f"{float(v):.1f}") + " kWh" + + +# Row id -> the title on Home. The row's own sentence is the body, and it stays +# in English (app/messages.py). Five titles are the notification's own title, +# which test_ui_text.py checks. +_EN = uitext.words("en")["home_words"] +ATTN_TITLES = {(None if k == "none" else k): v for k, v in _EN["attn"].items()} +ATTN_FROM_MESSAGES = { + "meter_frozen": messages.T_METER_FROZEN, + "power_too_high": messages.T_OUTPUT_CLAMP, + "not_keeping_up": messages.T_LOOP_BOUND, + "circulating": messages.T_CIRCULATING, + "delivery": messages.S_NOT_DELIVERING, +} +VERDICT = dict(_EN["verdict"]) +MODE_HOUSE, ATTN_SHADOW = _EN["mode_house"], _EN["attn_shadow"] +ZERO_HELD, ZERO_GOING = _EN["zero_held"], _EN["zero_going"] + + +def mode_house() -> str: + return _t("mode_house") + + +def attn_shadow() -> str: + return _t("attn_shadow") def attn_shadow_body() -> str: - return "The add-on decides everything, but every inverter stays at 0 W." - -ZERO_HELD, ZERO_GOING = "Held at 0 W", "Going to 0 W" -VERDICT = { - "held": ZERO_HELD, - "stopped": "Stopped", - "cycle": "Monthly cycle today", - "trade": "Trading on price", - "house": "Follows the house", -} + return _t("attn_shadow_body") -def _w(shadow, live, would): - return would if shadow else live +def verdict_words(key, settled=True) -> str: + return _t("verdict." + key) if settled or key != "held" \ + else _t("zero_going") + + +def attn_title(rid) -> str: + table = _hw()["home_words"]["attn"] + return table.get(rid or "none", table["none"]) + + +def month_name(local) -> str: + return _hw()["home_words"]["months"][local.month - 1] + + +def _w(shadow, key, **values) -> str: + """`key`, or its `key_would` form in shadow mode.""" + return _t(key + "_would" if shadow else key, **values) def pill(kind) -> str: - return {"running": "Running", "shadow": "Shadow mode", - "stopped": "Stopped", "alarm": "Needs you now", - "warning": "Running, with a warning"}[kind] + return _t("pill." + kind) def banner_sub(kind) -> str: - return { - "running": "The batteries follow the plan below.", - "shadow": "The plans below say what the add-on would do.", - "stopped": "Start control below when you are ready.", - "alarm": "See Needs attention below for what to do.", - "warning": "See Needs attention below.", - }[kind] + return _t("banner_sub." + kind) def tile_solar(pv_w, has_solar) -> str: if not has_solar: - return "Not set up" + return _t("tile.not_set_up") if pv_w is None: - return "No reading" - return "Producing" if pv_w > IDLE_W else "Not producing" + return _t("tile.no_reading") + return _t("tile.producing") if pv_w > IDLE_W else _t("tile.not_producing") def tile_house(house_w, has_solar, disagree=False) -> str: if not has_solar: - return "Needs a solar reading" + return _t("tile.needs_solar") if disagree: - return "Readings do not add up" - return "No reading" if house_w is None else "Using" + return _t("tile.not_adding_up") + return _t("tile.no_reading") if house_w is None else _t("tile.using") def tile_grid(grid_w) -> str: if grid_w is None: - return "No reading" + return _t("tile.no_reading") if grid_w > IDLE_W: - return "Taking from the grid" + return _t("tile.taking") if grid_w < -IDLE_W: - return "Feeding in" - return "Balanced" + return _t("tile.feeding") + return _t("tile.balanced") def _zero(settled) -> str: - return ZERO_HELD if settled else ZERO_GOING + return _t("zero_held") if settled else _t("zero_going") def tile_batt(batt_w, shadow, settled=True) -> str: if shadow: return _zero(settled) - return {"giving": "Giving power", "charging": "Charging", - "resting": "Resting", None: "No reading"}[flow(batt_w)] + return _t("tile." + {"giving": "giving", "charging": "charging", + "resting": "resting", + None: "no_reading"}[flow(batt_w)]) def battery_now(soc, batt_w, *, phase, shadow, running, held=False, would_w=None, settled=True) -> str: if soc is None or batt_w is None: - return "No reading from this battery." + return _t("now.no_reading") zero = _zero(settled) if shadow: if would_w is None or abs(would_w) <= IDLE_W: - return f"{zero}. Would rest now." - verb = "give" if would_w > 0 else "charge" - return f"{zero}. Would {verb} {_kw(would_w)} now." + return _t("now.would_rest", zero=zero) + verb = "would_give" if would_w > 0 else "would_charge" + return _t("now." + verb, zero=zero, kw=_kw(would_w)) if held: - return f"{zero} until the alarm clears." + return _t("now.held", zero=zero) if not running: - return f"{zero} while control is stopped." + return _t("now.stopped", zero=zero) if phase != M_IDLE: - return {M_DRAIN: "Monthly cycle: emptying.", - M_CHARGE: "Monthly cycle: refilling.", - M_HOLD: "Monthly cycle: holding full."}.get( - phase, "Monthly cycle under way.") + return _t("now." + {M_DRAIN: "cycle_drain", M_CHARGE: "cycle_charge", + M_HOLD: "cycle_hold"}.get(phase, "cycle")) st = flow(batt_w) if st == "giving": - return f"Giving {_kw(batt_w)} now." + return _t("now.giving", kw=_kw(batt_w)) if st == "charging": - return f"Charging {_kw(batt_w)} now." - return "Resting now." + return _t("now.charging", kw=_kw(batt_w)) + return _t("now.resting") def plan_head(key, shadow, settled=True) -> str: if key == "held": - return ("Every inverter is at 0 W until the alarm clears." if settled - else "Every inverter goes to 0 W until the alarm clears.") + return _t("head.held" if settled else "head.held_going") if key == "stopped": - return "Control is stopped, so nothing is planned." + return _t("head.stopped") if not shadow: - return { - "cycle": "A battery runs its monthly cycle today.", - "trade": "Charges in the cheapest hour, gives power in the dearest.", - "house": "Charges from spare solar and covers the house.", - }[key] - return "Shadow mode: this is what the add-on would do. " + { - "cycle": "A battery would run its monthly cycle today.", - "trade": "Would charge when cheapest and give power when dearest.", - "house": "Would charge from spare solar and cover the house.", - }[key] + return _t("head." + key) + return _t("head.shadow") + " " + _t("head." + key + "_would") def run_grid_what(kwh, shadow) -> str: - return _w(shadow, "Charges", "Would charge") + \ - f" up to {_kwh(kwh)} from the grid." + return _w(shadow, "run.grid_what", kwh=_kwh(kwh)) # The trade legs and the price line use the feed's own points (a 15-minute # period on a quarter-hourly feed), so both name the same price and time. def run_grid_why(price) -> str: - return f"Lowest published price, {_eur(price)} per kWh." + return _t("run.grid_why", eur=_eur(price)) def run_grid_amount(eur) -> str: - return f"Costs up to {_eur(eur)}." + return _t("run.grid_amount", eur=_eur(eur)) def run_give_what(kwh, shadow) -> str: - return _w(shadow, "Gives", "Would give") + \ - f" up to {_kwh(kwh)}, to the house first." + return _w(shadow, "run.give_what", kwh=_kwh(kwh)) def run_give_why(price) -> str: - return f"Highest published price, {_eur(price)} per kWh." + return _t("run.give_why", eur=_eur(price)) def run_give_amount(eur) -> str: - return f"Worth up to {_eur(eur)} at that price." + return _t("run.give_amount", eur=_eur(eur)) def run_cycle_what(phase, shadow) -> str: - if phase == M_IDLE: - return _w(shadow, "Starts", "Would start") + \ - " the monthly battery cycle." - return _w(shadow, "Runs", "Would run") + " the monthly battery cycle now." + return _w(shadow, "run.cycle_start" if phase == M_IDLE else "run.cycle_now") def run_cycle_why(shadow=False) -> str: - return _w(shadow, "Empties, refills and holds full", - "Would empty, refill and hold full") + \ - ", so the level reads right." + return _w(shadow, "run.cycle_why") def run_cycle_amount(hours) -> str: if hours is None: - return "How long depends on the battery." - return f"Takes about {max(1, round(hours))} hours." + return _t("run.cycle_amount_unknown") + return _t("run.cycle_amount", hours=max(1, round(hours))) def run_peak_what(limit_w, shadow) -> str: - return _w(shadow, "Gives", "Would give") + \ - f" power to stay under {_kw(limit_w)}." + return _w(shadow, "run.peak_what", kw=_kw(limit_w)) def run_peak_why() -> str: - return "The peak limit comes before everything else." + return _t("run.peak_why") def run_house_what(shadow) -> str: - return _w(shadow, "Follows", "Would follow") + " the house." + return _w(shadow, "run.house_what") def run_house_why(shadow=False) -> str: - return _w(shadow, "Charges from spare solar, gives power", - "Would charge from spare solar, give power") + \ - " when the house needs it." + return _w(shadow, "run.house_why") def run_house_amount() -> str: - return "Amounts depend on the sun and the house." + return _t("run.house_amount") def line_no_trade(key) -> str: - return { - "off": "", - "not_covered": "No trading on price: nothing measures the 15-minute " - "peak.", - "no_prices": "No trading on price yet: no prices have arrived.", - "peak": "No trading on price now: the peak limit comes first.", - "not_firm": "No trading on price yet: the prices are not published.", - "not_worth": "No trading on price today. The price gap is too small.", - "no_level": "No trading on price now: a battery level is unknown.", - }[key] + return "" if key == "off" else _t("no_trade." + key) def line_trade_tomorrow(kind, at, price, shadow) -> str: - verb = {"grid": _w(shadow, "Charges", "Would charge"), - "give": _w(shadow, "Gives power", "Would give power")}[kind] - return f"Tomorrow: {verb.lower()} at {at}, {_eur(price)} per kWh." + return _w(shadow, "tomorrow_trade." + kind, at=at, eur=_eur(price)) def line_cycle_waits(name) -> str: - return f"{name} waits its turn for the monthly cycle." + return _t("cycle_waits", name=name) def line_peak_limit(limit_w, shadow) -> str: - return _w(shadow, "The batteries step in", "The batteries would step in") \ - + f" above {_kw(limit_w)}." + return _w(shadow, "step_in", kw=_kw(limit_w)) def line_prices(now_eur, lo, hi) -> str: parts = [] if now_eur is not None: - parts.append(f"Price now {_eur(now_eur)} per kWh, all in.") + parts.append(_t("prices.now", eur=_eur(now_eur))) if lo is not None and hi is not None: - parts.append(f"Today: lowest {_eur(lo['eur_kwh'])} at " - f"{lo['label']}, highest {_eur(hi['eur_kwh'])} at " - f"{hi['label']}.") - return " ".join(parts) or "No prices for today." + parts.append(_t("prices.today", lo=_eur(lo["eur_kwh"]), + lo_at=lo["label"], hi=_eur(hi["eur_kwh"]), + hi_at=hi["label"])) + return " ".join(parts) or _t("prices.none") def line_tomorrow(known) -> str: - return ("Tomorrow's prices are in." if known else - "Tomorrow's prices usually arrive after 13:00.") + return _t("tomorrow.known" if known else "tomorrow.unknown") def line_region(rid, year) -> str: if rid in REGION_PRESETS and year: - return f"Levies and VAT: {REGION_WORDS.get(rid, rid)}, {year} figures." - return "Levies and VAT: your own figure." - - -REGION_WORDS = {"flanders": "Flanders, Fluvius Antwerpen"} + name = _hw()["choices"]["region"].get(rid, rid) + return _t("region.preset", region=name, year=year) + return _t("region.own") def line_peak_not_covered() -> str: - return "No peak limit: this meter does not measure 15-minute periods." + return _t("peak_not_covered") def line_period(period_w, limit_w, shadow) -> str: - step = _w(shadow, "The batteries step in", "The batteries would step in") + step = _w(shadow, "step_in", kw=_kw(limit_w)) if period_w is None: - return f"{step} above {_kw(limit_w)}." - return (f"This 15-minute period is heading for {_kw(period_w)}. " - f"{step} above {_kw(limit_w)}.") + return step + return _t("period", kw=_kw(period_w)) + " " + step def control_text(running, shadow) -> str: if shadow: - return "Shadow mode: every inverter stays at 0 W." + return _t("control.shadow") if running: - return "Control is running." - return "Control is stopped. Every inverter is at 0 W." + return _t("control.running") + return _t("control.stopped") -def text_samples() -> list: - """`(kind, text)` for every sentence above, rendered with real values, so - test_messages.py can hold each to its STYLE limit. Kinds: title, plan - (2 sentences of 12 words), line (1 sentence of 25 words).""" - out = [("title", t) for t in VERDICT.values()] + [("title", ZERO_GOING)] - out += [("title", t) for t in ATTN_TITLES.values()] - out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW), +def text_samples(lang="en") -> list: + """`(kind, text)` for every sentence above in `lang`, rendered with real + values, so test_messages.py can hold each to its STYLE limit. Kinds: title, + plan (2 sentences of 12 words), line (1 sentence of 25 words).""" + _LANG[0] = uitext.lang_of(lang) + try: + return _samples() + finally: + _LANG[0] = "en" + + +def _samples() -> list: + words = _hw()["home_words"] + out = [("title", t) for t in words["verdict"].values()] + out += [("title", _t("zero_going")), ("title", _t("zero_held"))] + out += [("title", t) for t in words["attn"].values()] + out += [("title", mode_house()), ("title", attn_shadow()), ("line", attn_shadow_body())] for k in ("running", "shadow", "stopped", "alarm", "warning"): out += [("title", pill(k)), ("line", banner_sub(k))] diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index bdce450..d92ceb7 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -5544,7 +5544,7 @@ class Controller: _inv_list.index(ent) + 1)}) return out - def status_payload(self) -> dict: + def status_payload(self, lang: str = "en") -> dict: checks = self.checks() bad = [c for c in checks if not c["ok"] and not c["warn"]] # --- SAFETY-10, AND IT IS FIRST IN THE CASCADE ----------------------- @@ -5730,7 +5730,8 @@ class Controller: # never cost the page its rows: a fault here is logged and the old # fields are served alone (the page then shows the installer details). try: - out.update(homeview.build(self, level=level, checks=checks)) + out.update(homeview.build(self, level=level, checks=checks, + lang=lang)) except Exception: # noqa: BLE001 # Once per process at ERROR: the page polls every 3 s. if not getattr(self, "_home_failed", False): diff --git a/goodwe_controller/app/setuponly.py b/goodwe_controller/app/setuponly.py index 0e0a6b2..bb4ce3e 100644 --- a/goodwe_controller/app/setuponly.py +++ b/goodwe_controller/app/setuponly.py @@ -52,7 +52,7 @@ class SetupOnly: if hint is not None: self.hint = f"{datetime.now().astimezone():%H:%M} {hint}" - def status_payload(self) -> dict: + def status_payload(self, lang: str = "en") -> dict: return {"setup_only": True, "mode": "setup_only", "banner": messages.banner_setup_only(), "level": "warn", "rows": [["Control", "not started, in setup"]], diff --git a/goodwe_controller/app/static/app.css b/goodwe_controller/app/static/app.css index d766298..6e4d52f 100644 --- a/goodwe_controller/app/static/app.css +++ b/goodwe_controller/app/static/app.css @@ -206,6 +206,10 @@ input:disabled{opacity:.6} .ent-all{display:flex;gap:6px;padding:6px 10px;align-items:center;min-height:44px} .elist .erow{padding:6px 0;border-bottom:1px dashed var(--line)} .elist>.btn{margin-top:8px} +.plist .prow{display:grid;gap:6px} +.plist .pf{display:flex;flex-direction:column;gap:2px} +.plist .pf>select.sel{width:100%} +input.txt.time{width:5.5em;text-align:center;font-variant-numeric:tabular-nums} .inv{border:1px solid var(--line);border-radius:10px;padding:0 12px;margin:10px 0} .inv>.hd{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-weight:600;flex-wrap:wrap} .inv>.f:nth-child(2){border-top:0} diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index 2aed6b4..f872379 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -5,7 +5,19 @@ labels) or from /status (sentences built by app/homeview.py), so STYLE.md is checked in Python. Data is always set as text, never as HTML. */ (function () { - const T = JSON.parse(document.getElementById("ui-text").textContent).home; + // P5: English or Dutch, by the browser's language. A word missing in Dutch + // falls back to English, key by key (app/uitext.py does the same). + const ALL = JSON.parse(document.getElementById("ui-text").textContent); + const LANG = /^nl\b/i.test(navigator.language || "") ? "nl" : "en"; + const merge = (a, b) => { + if (!b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a)) return b ?? a; + const out = Object.assign({}, a); + for (const k in b) out[k] = k in a ? merge(a[k], b[k]) : b[k]; + return out; + }; + const WORDS = LANG === "en" ? ALL.en : merge(ALL.en, ALL[LANG]); + document.documentElement.lang = LANG; + const T = WORDS.home; const $ = (s) => document.querySelector(s); const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T); function h(tag, attrs, ...kids) { @@ -24,8 +36,9 @@ } return e; } - const kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1); - const eur = (v) => "€" + Number(v).toFixed(2); + const dec = (x) => (LANG === "nl" ? x.replace(".", ",") : x); + const kw = (w) => dec((Math.abs(Number(w)) / 1000).toFixed(1)); + const eur = (v) => "€" + dec(Number(v).toFixed(2)); // Fixed labels from ui_text. document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; }); @@ -55,7 +68,7 @@ /* ---------------- data ---------------- */ async function getStatus() { - const r = await fetch("status", { cache: "no-store" }); + const r = await fetch("status?lang=" + LANG, { cache: "no-store" }); if (!r.ok) throw new Error("status " + r.status); return r.json(); } @@ -87,7 +100,7 @@ const st = S.state || null; const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn") : (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn"); - const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner; + const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner; $("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words)); } @@ -96,7 +109,7 @@ const structured = S.now && S.plan && !S.home_error; // UX-15: setup-only mode commands nothing and has no control to offer. const setup = S.setup_only === true; - banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null); + banner($("#home-banner"), S.level, setup ? t("setup_only.name") : S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null); ["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; }); if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); } renderControl(structured, setup); @@ -303,7 +316,7 @@ levelBar(b.level_pct, b.name), h("dl", { class: "kv" }, h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"), - b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, b.stored_kwh.toFixed(1) + " kWh")] : null, + b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, dec(b.stored_kwh.toFixed(1)) + " kWh")] : null, h("dt", {}, t("batteries.now")), h("dd", {}, b.now), h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode))))); } @@ -339,7 +352,7 @@ // Shared with settings.js, which loads after this file. // `first`: the page opened with no route, so settings.js may open the setup // wizard on a new install (UX-13 P4). - window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash }; + window.UX13 = { h, ask, toast, t, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash }; /* ---------------- tabs: hash routes, arrow keys ---------------- */ const TABS = [...document.querySelectorAll(".tab")]; diff --git a/goodwe_controller/app/static/settings.js b/goodwe_controller/app/static/settings.js index aa3e9ff..ba3164a 100644 --- a/goodwe_controller/app/static/settings.js +++ b/goodwe_controller/app/static/settings.js @@ -9,9 +9,9 @@ P4: the Setup tab's wizard is the same page model, drawn one step at a time from each field's `wizard` step, and saved through the same routes. */ (function () { - const ROOT = JSON.parse(document.getElementById("ui-text").textContent); + const ROOT = window.UX13.words; // English or Dutch (app.js) const W = ROOT.settings; - const { h, ask, toast, maint } = window.UX13; + const { h, ask, toast, maint, lang } = window.UX13; const $ = (s) => document.querySelector(s); const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W); const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x))); @@ -288,8 +288,45 @@ box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar)); return box; } + // P5: time windows, each a start, an end, its days and a price. The add-on + // builds the tariff from the rows and names the first overlap or gap. + function periodList(f, card) { + const P = W.periods; + const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []); + const box = h("div", { class: "elist plist" }); + if (!Array.isArray(get(f, card))) return h("div", { class: "msg info" }, W.derive.custom_profile.description); + arr().forEach((row, i) => { + const set = (k, v) => { const a = clone(arr()); a[i][k] = v; put(f, card, a); }; + const say = (what) => P.window + " " + (i + 1) + ", " + what; + const time = (k) => { + const e = h("input", { class: "txt time", type: "text", inputmode: "numeric", maxlength: "5", placeholder: "00:00", + value: row[k] == null ? "" : row[k], autocomplete: "off", spellcheck: "false", "aria-label": say(P[k]) }); + e.addEventListener("input", () => set(k, e.value.trim())); + return e; + }; + const days = h("select", { class: "sel", "aria-label": say(P.days) }); + for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label)); + days.addEventListener("change", () => set("days", days.value)); + const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01", + value: row.price == null ? "" : row.price, "aria-label": say(P.price) }); + price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value)); + const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids); + box.append(h("div", { class: "erow prow" }, + h("div", { class: "row spread" }, h("strong", { class: "small" }, P.window + " " + (i + 1)), + h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove)), + h("div", { class: "row" }, cell(P.from, time("from")), cell(P.to, time("to")), + cell(P.price, h("span", { class: "row" }, price, f.unit ? h("span", { class: "unit" }, f.unit) : null))), + cell(P.days, days))); + }); + box.append(h("button", { type: "button", class: "btn", onclick: () => { + const a = arr(), last = a[a.length - 1]; + put(f, card, clone(a).concat([{ from: last ? last.to : "00:00", to: "24:00", days: last ? last.days : "mon-sun", price: null }]), true); + } }, "+ " + P.add)); + return box; + } const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true), - select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList }; + select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList, + period_list: periodList }; /* ---------------- rows, cards and groups ---------------- */ function keyText(f, card) { @@ -304,7 +341,7 @@ const msg = h("div", { class: "msgs" }); const make = CONTROLS[f.control] || text; const row = h("div", { class: "f" + (f.control === "toggle" ? " inline" : ""), "data-key": f.key, "data-card": card }, - h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && f.control !== "slider" && f.control !== "number" && f.control !== "locked" ? " (" + f.unit + ")" : ""), + h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && !["slider", "number", "locked", "period_list"].includes(f.control) ? " (" + f.unit + ")" : ""), keys ? h("div", { class: "key" }, keys) : null), h("div", { class: "ctl" }, make(f, card, lid)), f.help ? h("div", { class: "help" }, f.help) : null, msg); @@ -469,7 +506,7 @@ const seq = ++vseq; try { const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(Object.assign({ model: V }, extra || {})) }); + body: JSON.stringify(Object.assign({ model: V, lang }, extra || {})) }); const b = await r.json(); if (seq !== vseq) return null; MSG = b.messages || []; @@ -498,7 +535,7 @@ async function load(after) { let m; try { - const r = await fetch("config/model?lang=en", { cache: "no-store" }); + const r = await fetch("config/model?lang=" + lang, { cache: "no-store" }); if (!r.ok) throw new Error("model " + r.status); m = await r.json(); if (!m.layout || !m.ui) throw new Error("no layout"); @@ -538,7 +575,7 @@ // what is stored now (UX-15 F2). async function postSave(extra) { const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP }, extra || {})) }); + body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP, lang }, extra || {})) }); const b = await r.json().catch(() => ({})); if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP; return { r, b }; diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 833286e..8903e83 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -24,7 +24,8 @@ "fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."}, "fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."}, "day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."}, - "night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."} + "night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."}, + "periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill."} }, "choices": { "contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"}, @@ -34,7 +35,8 @@ "meter_phases": {"1": "1 phase", "3": "3 phases"}, "shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"}, "price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"}, - "log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"} + "log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"}, + "period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"} }, "applies": { "restart": {"name": "Applies after a restart"}, @@ -79,7 +81,7 @@ }, "toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."}, "offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."}, - "setup_only": {"description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"}, + "setup_only": {"name": "Setup: nothing is controlled yet", "description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"}, "batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"}, "settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"} }, @@ -127,6 +129,7 @@ "remove": "Remove", "add_row": "Add a row", "add_solar": "Add a solar inverter", + "periods": {"window": "Window", "from": "From", "to": "To", "days": "Days", "price": "Price", "add": "Add a time window"}, "reversed": "Reads negative", "phase": "Phase", "raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."}, @@ -156,6 +159,13 @@ "no_source": {"description": "Choose where the dynamic prices come from."}, "custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."}, "no_price": {"description": "Enter a price per kWh."}, + "no_periods": {"description": "Add at least one time window."}, + "period_time": {"description": "Window {row}: write each time as hours and minutes, like 07:00 or 24:00."}, + "period_zero": {"description": "Window {row} starts and ends at the same time, so change one of them."}, + "period_price": {"description": "Window {row}: enter a price per kWh."}, + "period_price_range": {"description": "Window {row}: enter the price in euro per kWh, like 0.34."}, + "period_overlap": {"description": "Windows {a} and {b} both cover {day} {from} to {to}, so change one of them."}, + "period_gap": {"description": "No window covers {day} {from} to {to}, so add one or widen one."}, "unknown": {"description": "The page cannot store this choice, so reload the page."} }, "setup": { @@ -215,5 +225,160 @@ "restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."}, "home": "Open Home" } + }, + "home_words": { + "mode_house": "Follows the house", + "attn_shadow": "Shadow mode is on", + "attn_shadow_body": "The add-on decides everything, but every inverter stays at 0 W.", + "zero_held": "Held at 0 W", + "zero_going": "Going to 0 W", + "verdict": { + "held": "Held at 0 W", + "stopped": "Stopped", + "cycle": "Monthly cycle today", + "trade": "Trading on price", + "house": "Follows the house" + }, + "pill": { + "running": "Running", + "shadow": "Shadow mode", + "stopped": "Stopped", + "alarm": "Needs you now", + "warning": "Running, with a warning" + }, + "banner_sub": { + "running": "The batteries follow the plan below.", + "shadow": "The plans below say what the add-on would do.", + "stopped": "Start control below when you are ready.", + "alarm": "See Needs attention below for what to do.", + "warning": "See Needs attention below." + }, + "tile": { + "not_set_up": "Not set up", + "no_reading": "No reading", + "producing": "Producing", + "not_producing": "Not producing", + "needs_solar": "Needs a solar reading", + "not_adding_up": "Readings do not add up", + "using": "Using", + "taking": "Taking from the grid", + "feeding": "Feeding in", + "balanced": "Balanced", + "giving": "Giving power", + "charging": "Charging", + "resting": "Resting" + }, + "now": { + "no_reading": "No reading from this battery.", + "would_rest": "{zero}. Would rest now.", + "would_give": "{zero}. Would give {kw} now.", + "would_charge": "{zero}. Would charge {kw} now.", + "held": "{zero} until the alarm clears.", + "stopped": "{zero} while control is stopped.", + "cycle_drain": "Monthly cycle: emptying.", + "cycle_charge": "Monthly cycle: refilling.", + "cycle_hold": "Monthly cycle: holding full.", + "cycle": "Monthly cycle under way.", + "giving": "Giving {kw} now.", + "charging": "Charging {kw} now.", + "resting": "Resting now." + }, + "head": { + "held": "Every inverter is at 0 W until the alarm clears.", + "held_going": "Every inverter goes to 0 W until the alarm clears.", + "stopped": "Control is stopped, so nothing is planned.", + "cycle": "A battery runs its monthly cycle today.", + "trade": "Charges in the cheapest hour, gives power in the dearest.", + "house": "Charges from spare solar and covers the house.", + "shadow": "Shadow mode: this is what the add-on would do.", + "cycle_would": "A battery would run its monthly cycle today.", + "trade_would": "Would charge when cheapest and give power when dearest.", + "house_would": "Would charge from spare solar and cover the house." + }, + "run": { + "grid_what": "Charges up to {kwh} from the grid.", + "grid_what_would": "Would charge up to {kwh} from the grid.", + "grid_why": "Lowest published price, {eur} per kWh.", + "grid_amount": "Costs up to {eur}.", + "give_what": "Gives up to {kwh}, to the house first.", + "give_what_would": "Would give up to {kwh}, to the house first.", + "give_why": "Highest published price, {eur} per kWh.", + "give_amount": "Worth up to {eur} at that price.", + "cycle_start": "Starts the monthly battery cycle.", + "cycle_start_would": "Would start the monthly battery cycle.", + "cycle_now": "Runs the monthly battery cycle now.", + "cycle_now_would": "Would run the monthly battery cycle now.", + "cycle_why": "Empties, refills and holds full, so the level reads right.", + "cycle_why_would": "Would empty, refill and hold full, so the level reads right.", + "cycle_amount_unknown": "How long depends on the battery.", + "cycle_amount": "Takes about {hours} hours.", + "peak_what": "Gives power to stay under {kw}.", + "peak_what_would": "Would give power to stay under {kw}.", + "peak_why": "The peak limit comes before everything else.", + "house_what": "Follows the house.", + "house_what_would": "Would follow the house.", + "house_why": "Charges from spare solar, gives power when the house needs it.", + "house_why_would": "Would charge from spare solar, give power when the house needs it.", + "house_amount": "Amounts depend on the sun and the house." + }, + "no_trade": { + "not_covered": "No trading on price: nothing measures the 15-minute peak.", + "no_prices": "No trading on price yet: no prices have arrived.", + "peak": "No trading on price now: the peak limit comes first.", + "not_firm": "No trading on price yet: the prices are not published.", + "not_worth": "No trading on price today. The price gap is too small.", + "no_level": "No trading on price now: a battery level is unknown." + }, + "tomorrow_trade": { + "grid": "Tomorrow: charges at {at}, {eur} per kWh.", + "grid_would": "Tomorrow: would charge at {at}, {eur} per kWh.", + "give": "Tomorrow: gives power at {at}, {eur} per kWh.", + "give_would": "Tomorrow: would give power at {at}, {eur} per kWh." + }, + "cycle_waits": "{name} waits its turn for the monthly cycle.", + "step_in": "The batteries step in above {kw}.", + "step_in_would": "The batteries would step in above {kw}.", + "period": "This 15-minute period is heading for {kw}.", + "prices": { + "now": "Price now {eur} per kWh, all in.", + "today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.", + "none": "No prices for today." + }, + "tomorrow": { + "known": "Tomorrow's prices are in.", + "unknown": "Tomorrow's prices usually arrive after 13:00." + }, + "region": { + "preset": "Levies and VAT: {region}, {year} figures.", + "own": "Levies and VAT: your own figure." + }, + "peak_not_covered": "No peak limit: this meter does not measure 15-minute periods.", + "control": { + "shadow": "Shadow mode: every inverter stays at 0 W.", + "running": "Control is running.", + "stopped": "Control is stopped. Every inverter is at 0 W." + }, + "attn": { + "controller": "Control is not running", + "meter": "Meter reading", + "meter_frozen": "Meter reading may be stuck", + "freeze_check": "Stuck meter check", + "month_peak": "This month's peak", + "peak_guard": "Peak limit", + "peak_forecast": "Peak limit forecast", + "sensor": "Battery readings", + "control": "Inverter control", + "power_too_high": "Power request too high", + "not_keeping_up": "Battery is not keeping up", + "write": "Power request not accepted", + "direction": "Power went the wrong way", + "site_direction": "Power went the wrong way", + "circulating": "Inverters working against each other", + "delivery": "An inverter is not delivering", + "estop": "Emergency stop", + "pv": "Solar reading", + "none": "Inverter connection" + }, + "months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] } } diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json new file mode 100644 index 0000000..fc616b0 --- /dev/null +++ b/goodwe_controller/app/ui_text/nl.json @@ -0,0 +1,307 @@ +{ + "groups": { + "meter": {"name": "Meter", "description": "Waar de meting van het net vandaan komt."}, + "inverters": {"name": "Omvormers en batterijen", "description": "De omvormers die de add-on regelt, en uw zonnepanelen."}, + "tariff": {"name": "Tarief en prijzen", "description": "Wat u per kWh betaalt en terugkrijgt."}, + "safety": {"name": "Veiligheidsgrenzen", "description": "De piekgrens, starten en stoppen, en de veiligheidsstops."}, + "maintenance": {"name": "Onderhoud", "description": "De maandelijkse batterijcyclus en het logboek."}, + "tuning": {"name": "Afstelling van de regeling", "description": "Alleen voor support, want deze zijn gemeten op echte hardware."} + }, + "parts": { + "meter.timing": {"name": "Timing"}, + "inverters.solar": {"name": "Zonnepanelen"}, + "tariff.selling": {"name": "Laden en verkopen op prijs"}, + "tariff.feed": {"name": "Details van de prijsfeed"}, + "safety.peak": {"name": "Piekgrens"}, + "safety.start": {"name": "Starten en stoppen"}, + "safety.stops": {"name": "Veiligheidsstops", "description": "Gemeten op echte hardware, dus laat ze zoals ze zijn."}, + "maintenance.support": {"name": "Support"} + }, + "fields": { + "solar": {"name": "Omvormers van de zonnepanelen", "description": "De vermogenssensor van elke zonne-omvormer, die de add-on bijhoudt maar niet gebruikt om te regelen."}, + "contract": {"name": "Uw contract", "description": "Hoe uw leverancier elke kWh prijst die u van het net afneemt."}, + "region": {"name": "Regio", "description": "Vult nettarieven, heffingen en btw in voor het getoonde jaar, nog niet voor Wallonië of Brussel, dus kijk uw factuur na."}, + "fallback": {"name": "Prijs als de feed wegvalt", "description": "Gebruikt als de prijsbron niet antwoordt."}, + "fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen."}, + "day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur."}, + "night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur."}, + "periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur."} + }, + "choices": { + "contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "custom": "Andere tijdvakken"}, + "fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"}, + "region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"}, + "meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"}, + "meter_phases": {"1": "1 fase", "3": "3 fasen"}, + "shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"}, + "price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"}, + "log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"}, + "period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"} + }, + "applies": {"restart": {"name": "Geldt na een herstart"}, "now": {"name": "Geldt meteen"}}, + "home": { + "app": {"name": "Batterij-add-on"}, + "tabs": {"home": "Start", "batteries": "Batterijen", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"}, + "now": {"name": "Nu", "description": "Elke 3 seconden bijgewerkt.", "solar": "Zonnepanelen", "house": "Huis", "grid": "Net", "batteries": "Batterijen"}, + "plan": { + "name": "Plan voor vandaag", + "description": "Wijs een staaf of blok aan voor details, of tik erop op een telefoon.", + "cheap": "Lage prijs", + "dear": "Hoge prijs", + "estimate": "Schatting", + "grid": "Laadt van het net", + "give": "Levert stroom", + "cycle": "Maandelijkse batterijcyclus", + "peak": "Piekgrens", + "house": "Volgt het huis", + "now": "nu", + "time_to": "tot", + "per_kwh": "per kWh", + "cheap_tip": "laag voor vandaag", + "dear_tip": "hoog voor vandaag", + "estimate_tip": "schatting op basis van gisteren", + "none_tip": "Geen prijs bekend", + "no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."}, + "list": "Het plan in woorden", + "average_tip": "gemiddelde van het uur" + }, + "peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"}, + "attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"}, + "control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"}, + "details": {"name": "Details voor installateurs", "rows": "Statusregels", "checks": "Controles", "event": "Laatste gebeurtenis"}, + "dialog": { + "cancel": "Annuleren", + "stop": {"name": "Regeling stoppen?", "description": "Elke omvormer gaat naar 0 W tot u de regeling opnieuw start.", "yes": "Regeling stoppen"}, + "start": {"name": "Regeling starten?", "description": "De add-on regelt de batterijen weer en heft een veiligheidsstop op.", "yes": "Regeling starten"}, + "maint_start": {"name": "Batterijcyclus starten?", "description": "De batterij loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, + "maint_abort": {"name": "Batterijcyclus stoppen?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"} + }, + "toast": {"started": "Regeling gestart.", "stopped": "Regeling gestopt.", "done": "Klaar.", "failed": "Dat lukte niet. Probeer opnieuw."}, + "offline": {"name": "Add-on niet bereikbaar", "description": "De pagina probeert het elke 3 seconden opnieuw."}, + "setup_only": {"name": "Installatie: nog niets wordt geregeld", "description": "Er gaat niets naar een omvormer tot de installatie bewaard is en de add-on herstart.", "control": "De regeling start na de installatie.", "open": "Installatie openen"}, + "batteries": {"name": "Batterijen", "description": "Batterijmodi en aan- en uitschakelaars komen in een latere update.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen"}, + "settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"} + }, + "choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}}, + "choice_help": { + "meter_source": { + "off": {"description": "Een Home Assistant-entiteit met het netvermogen in W, positief als u van het net afneemt."}, + "ha_dsmr": {"description": "Twee Home Assistant-entiteiten van de DSMR-integratie, een voor afname en een voor injectie."}, + "mqtt_p1": {"description": "De meetwaarden komen via MQTT binnen op een topic dat u opgeeft."}, + "ha_signed": {"description": "Eén Home Assistant-entiteit met het nettovermogen, positief bij afname."}, + "homewizard_local": {"description": "De add-on leest een HomeWizard-meter op uw netwerk en merkt een vastgelopen meter op."} + } + }, + "settings": { + "name": "Instellingen", + "description": "Elke instelling, per taak, en de eenvoudige weergave volgt in een latere update.", + "classic": "Klassieke instellingen", + "search": {"name": "Instelling zoeken", "description": "Zoek een instelling, bijvoorbeeld piek of meter."}, + "no_match": {"description": "Geen instelling past bij uw zoekopdracht."}, + "loading": "Instellingen laden", + "load_failed": {"name": "Instellingen niet leesbaar", "description": "De add-on kon zijn bewaarde instellingen niet lezen, dus gebruik Klassieke instellingen of het tabblad Configuratie."}, + "footer": {"description": "Elke wijziging gaat door de startcontroles van de add-on voor ze bewaard wordt."}, + "to_fix": "op te lossen", + "to_check": "na te kijken", + "changed": "gewijzigd", + "on": "Aan", + "off": "Uit", + "not_set": "Niet ingesteld", + "locked": "Vergrendeld", + "change": "Wijzigen", + "support": {"description": "Deze zijn gemeten op echte hardware, dus wijzig ze alleen als support erom vraagt."}, + "picker": {"choose": "Kies een entiteit", "search": {"description": "Zoek op naam of entiteit-id."}, "all": "Alle entiteiten tonen", "use": "Deze entiteit-id gebruiken", "clear": "Wissen", "missing": "Niet in Home Assistant", "none": {"description": "Niets gevonden, dus kijk de entiteit na in Ontwikkelhulpmiddelen."}}, + "add_inverter": "Omvormer toevoegen", + "inverter": "Omvormer", + "remove": "Verwijderen", + "add_row": "Regel toevoegen", + "add_solar": "Zonne-omvormer toevoegen", + "periods": {"window": "Tijdvak", "from": "Van", "to": "Tot", "days": "Dagen", "price": "Prijs", "add": "Tijdvak toevoegen"}, + "reversed": "Meet negatief", + "phase": "Fase", + "raw_roster": {"description": "Deze lijst met omvormers kan alleen op het tabblad Configuratie wijzigen."}, + "region_year": "cijfers van {year}", + "hour": "{hour}:00 UTC", + "preset": {"description": "Ingevuld door de regio hierboven, dus kies een eigen bedrag om het te wijzigen."}, + "cycle": {"name": "Een cyclus met de hand starten", "description": "Dezelfde acties als op Start, die elk eerst om bevestiging vragen."}, + "savebar": {"label": "Niet bewaarde wijzigingen", "one": "wijziging niet bewaard", "many": "wijzigingen niet bewaard", "discard": "Verwerpen", "save": "Bewaren", "saving": "Bewaren"}, + "saved": {"name": "Bewaard, nog niet actief", "description": "De add-on werkt met de instellingen waarmee hij startte tot u hem herstart."}, + "differs": {"name": "Bewaarde instellingen zijn niet actief", "description": "Sommige bewaarde instellingen verschillen van wat nu draait, dus herstart de add-on om ze toe te passen."}, + "stale": {"name": "Elders gewijzigd", "description": "Deze instellingen zijn elders gewijzigd terwijl u bewerkte, dus bewaar opnieuw om de uwe te houden of verwerp om de andere te laden."}, + "other": "Andere meldingen", + "restart": "Nu herstarten", + "dialog": {"name": "Add-on herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Nu herstarten"}, + "toast": {"saved": "Bewaard. Herstart om toe te passen.", "discarded": "Wijzigingen verworpen.", "failed": "Dat lukte niet. Probeer opnieuw.", "blocked": {"description": "Los de rood gemarkeerde instellingen op en bewaar opnieuw."}, "restarting": {"description": "Herstart gevraagd, dus deze pagina antwoordt even niet."}}, + "tier": {"bad": "Oplossen", "warn": "Nakijken", "info": "Opmerking", "ok": "In orde"}, + "derive": { + "raw_roster": {"description": "Deze lijst met omvormers kan alleen op het tabblad Configuratie wijzigen."}, + "no_inverter": {"description": "Voeg minstens één omvormer toe."}, + "flat_named": {"description": "Voeg een tweede omvormer toe voor u deze een naam geeft."}, + "no_source": {"description": "Kies waar de dynamische prijzen vandaan komen."}, + "custom_profile": {"description": "Deze tijdvakken kunnen alleen op het tabblad Configuratie wijzigen."}, + "no_price": {"description": "Vul een prijs per kWh in."}, + "no_periods": {"description": "Voeg minstens één tijdvak toe."}, + "period_time": {"description": "Tijdvak {row}: schrijf elk tijdstip als uren en minuten, zoals 07:00 of 24:00."}, + "period_zero": {"description": "Tijdvak {row} begint en eindigt op hetzelfde uur, dus wijzig een van beide."}, + "period_price": {"description": "Tijdvak {row}: vul een prijs per kWh in."}, + "period_price_range": {"description": "Tijdvak {row}: vul de prijs in euro per kWh in, zoals 0,34."}, + "period_overlap": {"description": "Tijdvakken {a} en {b} vallen allebei op {day} {from} tot {to}, dus wijzig er een."}, + "period_gap": {"description": "Geen tijdvak dekt {day} {from} tot {to}, dus voeg er een toe of maak er een breder."}, + "unknown": {"description": "De pagina kan deze keuze niet bewaren, dus laad de pagina opnieuw."} + }, + "setup": { + "name": "Installatie", + "description": "Voor installateurs: zes stappen naar een werkende installatie, en elke stap kan later wijzigen onder Instellingen.", + "done_before": {"description": "Deze installatie is al ingesteld, dus een wijziging hier vervangt de bewaarde instellingen."}, + "step": "Stap {n} van {total}", + "back": "Terug", + "next": "Volgende", + "skip": "Volgende, of overslaan", + "finish": "Bewaren en herstarten", + "not_now": "Niet nu", + "steps": { + "1": {"name": "Uw meter", "description": "De add-on stuurt de netmeting naar nul, dus eerst is uw meter nodig."}, + "2": {"name": "Omvormers en batterijen", "description": "Eén kaart per omvormer met een batterij, met zijn entiteiten in Home Assistant."}, + "3": {"name": "Zonnepanelen", "description": "Niet verplicht, dus sla deze stap over als de add-on de zonneproductie niet moet bijhouden."}, + "4": {"name": "Prijzen", "description": "Wat u per kWh betaalt, zodat de batterijen laden als stroom goedkoop is."}, + "5": {"name": "Piekgrens", "description": "Het net blijft onder deze grens, gemiddeld over elk kwartier."}, + "6": {"name": "Nakijken en starten", "description": "De add-on kijkt alles na met zijn eigen startcontroles en herstart dan."} + }, + "modes_later": {"description": "Een modus kiezen per batterij komt in een latere update."}, + "kettle": { + "name": "Richting nakijken", + "description": "Lees de meter af, zet een waterkoker aan en lees de meter opnieuw af.", + "read": "Meter aflezen", + "again": "Opnieuw aflezen", + "skip": "Deze test overslaan", + "before": {"description": "De meter geeft nu {w} aan, dus zet een waterkoker aan en druk op Opnieuw aflezen."}, + "unread": {"description": "De meter is nu niet leesbaar, dus kijk de entiteit na en probeer opnieuw."}, + "none": {"description": "De meting veranderde maar {d}, dus zet de waterkoker aan en lees opnieuw af."}, + "ok": {"description": "Goed, de meting steeg met {d} toen de waterkoker aanging."}, + "flip": {"description": "De meting daalde met {d}, dus de add-on keert het teken voor u om."}, + "wrong_signed": {"description": "De meting daalde met {d}, dus kies hierboven “Eén entiteit voor netvermogen”, die het kan omkeren."}, + "wrong_dsmr": {"description": "De meting daalde met {d}, dus kijk na of afname en injectie niet omgewisseld zijn."}, + "self": {"description": "De add-on leest deze meter zelf, dus Start toont de richting na de herstart."}, + "skipped": {"description": "De richting van de meter is niet getest, dus kijk ze na op Start na de herstart."}, + "skipped_wrong": {"description": "Bij de waterkokertest daalde de meting, dus kijk de richting na in stap 1 voor u de regeling start."}, + "skipped_down": {"description": "De meting daalde toen de waterkoker aanging, dus kijk de richting van de meter na voor u de regeling start."}, + "tested": {"description": "De richting van de meter is getest met een waterkoker."}, + "needed": {"description": "Doe eerst de waterkokertest, of sla hem over."} + }, + "check": {"name": "Wat de add-on nakeek", "running": {"description": "Bezig met de startcontroles van de add-on."}, "pass": {"description": "De startcontroles van de add-on zijn in orde."}, "failed": {"description": "De controle liep niet, dus probeer opnieuw."}, "again": "Opnieuw nakijken", "blocked": {"description": "Los de rood gemarkeerde punten op en kijk dan opnieuw na."}}, + "shadow": {"name": "Starten in schaduwmodus", "description": "Aanbevolen, want dan blijft elke omvormer op 0 W tot u de schaduwmodus uitzet onder Instellingen."}, + "dialog": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, en de schaduwmodus houdt ze daarna op 0 W.", "yes": "Bewaren en herstarten"}, + "dialog_live": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Bewaren en herstarten"}, + "dialog_setup": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en de schaduwmodus houdt elke omvormer op 0 W.", "yes": "Bewaren en herstarten"}, + "dialog_setup_live": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en dan gelden ze.", "yes": "Bewaren en herstarten"}, + "done": {"name": "Installatie is klaar", "description": "De add-on herstart nu in schaduwmodus, dus open Start over een minuut om te zien wat hij zou doen."}, + "done_live": {"name": "Installatie is klaar", "description": "De add-on herstart nu, dus open Start over een minuut om de regeling te starten."}, + "restart_failed": {"name": "Bewaard, nog niet actief", "description": "De herstart lukte niet, dus druk op Nu herstarten om de instellingen toe te passen."}, + "home": "Start openen" + } + }, + "home_words": { + "mode_house": "Volgt het huis", + "attn_shadow": "Schaduwmodus staat aan", + "attn_shadow_body": "De add-on beslist alles, maar elke omvormer blijft op 0 W.", + "zero_held": "Op 0 W gehouden", + "zero_going": "Gaat naar 0 W", + "verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"}, + "pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "warning": "Actief, met een waarschuwing"}, + "banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "warning": "Zie Vraagt aandacht hieronder."}, + "tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet samen", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"}, + "now": { + "no_reading": "Geen meting van deze batterij.", + "would_rest": "{zero}. Zou nu rusten.", + "would_give": "{zero}. Zou nu {kw} leveren.", + "would_charge": "{zero}. Zou nu {kw} laden.", + "held": "{zero} tot het alarm weg is.", + "stopped": "{zero} zolang de regeling gestopt is.", + "cycle_drain": "Maandelijkse cyclus: leegmaken.", + "cycle_charge": "Maandelijkse cyclus: opladen.", + "cycle_hold": "Maandelijkse cyclus: vol houden.", + "cycle": "Maandelijkse cyclus bezig.", + "giving": "Levert nu {kw}.", + "charging": "Laadt nu {kw}.", + "resting": "Rust nu." + }, + "head": { + "held": "Elke omvormer staat op 0 W tot het alarm weg is.", + "held_going": "Elke omvormer gaat naar 0 W tot het alarm weg is.", + "stopped": "De regeling is gestopt, dus er is niets gepland.", + "cycle": "Een batterij doet vandaag zijn maandelijkse cyclus.", + "trade": "Laadt in het goedkoopste uur, levert stroom in het duurste.", + "house": "Laadt met zonne-overschot en dekt het huis.", + "shadow": "Schaduwmodus: dit is wat de add-on zou doen.", + "cycle_would": "Een batterij zou vandaag zijn maandelijkse cyclus doen.", + "trade_would": "Zou laden als het goedkoopst is en leveren als het duurst is.", + "house_would": "Zou laden met zonne-overschot en het huis dekken." + }, + "run": { + "grid_what": "Laadt tot {kwh} van het net.", + "grid_what_would": "Zou tot {kwh} van het net laden.", + "grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.", + "grid_amount": "Kost tot {eur}.", + "give_what": "Levert tot {kwh}, eerst aan het huis.", + "give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.", + "give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.", + "give_amount": "Tot {eur} waard aan die prijs.", + "cycle_start": "Start de maandelijkse batterijcyclus.", + "cycle_start_would": "Zou de maandelijkse batterijcyclus starten.", + "cycle_now": "Doet nu de maandelijkse batterijcyclus.", + "cycle_now_would": "Zou nu de maandelijkse batterijcyclus doen.", + "cycle_why": "Maakt leeg, laadt op en houdt vol, zodat het laadniveau klopt.", + "cycle_why_would": "Zou leegmaken, opladen en vol houden, zodat het laadniveau klopt.", + "cycle_amount_unknown": "Hoe lang hangt af van de batterij.", + "cycle_amount": "Duurt ongeveer {hours} uur.", + "peak_what": "Levert stroom om onder {kw} te blijven.", + "peak_what_would": "Zou stroom leveren om onder {kw} te blijven.", + "peak_why": "De piekgrens gaat voor alles.", + "house_what": "Volgt het huis.", + "house_what_would": "Zou het huis volgen.", + "house_why": "Laadt met zonne-overschot, levert als het huis vraagt.", + "house_why_would": "Zou laden met zonne-overschot, leveren als het huis vraagt.", + "house_amount": "Hoeveel hangt af van de zon en het huis." + }, + "no_trade": { + "not_covered": "Geen handel op prijs: niets meet de kwartierpiek.", + "no_prices": "Nog geen handel op prijs: er zijn geen prijzen binnen.", + "peak": "Nu geen handel op prijs: de piekgrens gaat voor.", + "not_firm": "Nog geen handel op prijs: de prijzen zijn niet gepubliceerd.", + "not_worth": "Vandaag geen handel op prijs. Het prijsverschil is te klein.", + "no_level": "Nu geen handel op prijs: een laadniveau is onbekend." + }, + "tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."}, + "cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.", + "step_in": "De batterijen springen bij boven {kw}.", + "step_in_would": "De batterijen zouden bijspringen boven {kw}.", + "period": "Dit kwartier gaat richting {kw}.", + "prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."}, + "tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."}, + "region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."}, + "peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.", + "control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."}, + "attn": { + "controller": "Regeling is niet actief", + "meter": "Meting van de meter", + "meter_frozen": "Meter staat mogelijk vast", + "freeze_check": "Controle op vastgelopen meter", + "month_peak": "Piek van deze maand", + "peak_guard": "Piekgrens", + "peak_forecast": "Prognose voor de piekgrens", + "sensor": "Metingen van de batterij", + "control": "Regeling van de omvormer", + "power_too_high": "Vermogensvraag te hoog", + "not_keeping_up": "Batterij kan niet volgen", + "write": "Vermogensvraag niet aanvaard", + "direction": "Vermogen de verkeerde kant op", + "site_direction": "Vermogen de verkeerde kant op", + "circulating": "Omvormers werken elkaar tegen", + "delivery": "Een omvormer levert niet", + "estop": "Noodstop", + "pv": "Meting van de zonnepanelen", + "none": "Verbinding met de omvormer" + }, + "months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"] + } +} diff --git a/goodwe_controller/app/uilayout.py b/goodwe_controller/app/uilayout.py index 4eafd2e..0c5b30e 100644 --- a/goodwe_controller/app/uilayout.py +++ b/goodwe_controller/app/uilayout.py @@ -23,8 +23,9 @@ CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = ( "choice", "entity", "entity_list", "toggle", "segmented", "slider") NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = ( "number", "text", "select", "locked", "inverter_list", "solar_list") +PERIOD_LIST = "period_list" # P5: time windows, each with its own price CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER, - TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST) + TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST, PERIOD_LIST) # What an entity picker offers first. `power_w` is a power sensor the add-on # reads as watts with no unit conversion, so validate.py warns on a kW sensor. ENTITY_KINDS = ("power_w", "power", "energy", "pct", "number", "any") @@ -154,6 +155,8 @@ GROUPS = ( show=(("ui.fallback", "==", "daynight"),), wizard=4), Field("ui.night_price", NUMBER, "€/kWh", step=0.01, show=(("ui.fallback", "==", "daynight"),), wizard=4), + Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01, + show=(("ui.fallback", "==", "custom"),), wizard=4), )), Part("selling", ( # OPT-11: derived from the batteries' selling switches once battery diff --git a/goodwe_controller/app/uimodel.py b/goodwe_controller/app/uimodel.py index ad72b97..332a864 100644 --- a/goodwe_controller/app/uimodel.py +++ b/goodwe_controller/app/uimodel.py @@ -2,8 +2,8 @@ `options_to_ui()` turns the stored blob into what the page edits: always a list of inverter cards, solar rows with a "reads negative" toggle, a contract and -simple prices instead of `static_profile` JSON, and a region preset instead of -the levies figure. `ui_to_options()` turns it back, so the validator and the +simple prices or time windows (P5) instead of `static_profile` JSON, and a +region preset instead of the levies figure. `ui_to_options()` turns it back, so the validator and the Supervisor always see the real blob. The rule that makes the round trip exact: the model carries the stored values @@ -16,12 +16,14 @@ See DECISIONS/UX-13-one-layout-table.md. import copy import json +import re -from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH -from .static_tariff import ProfileInvalid, parse_static_profile +from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH, MAX_PLAUSIBLE_EUR_KWH +from .static_tariff import (DAY_NAMES, MINUTES_PER_DAY, Period, ProfileInvalid, + parse_static_profile) __all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial", - "REGION_PRESETS", + "REGION_PRESETS", "PERIOD_DAYS", "arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"] @@ -30,9 +32,10 @@ class DeriveError(ValueError): the sentence the page shows (app/ui_text `derive.<code>`), and `field` the page field it is shown beside.""" - def __init__(self, text, code="", field=""): + def __init__(self, text, code="", field="", **values): super().__init__(text) self.code, self.field = code, field + self.values = values # the sentence's {placeholders} (P5) self.keys = () # the option keys the failed derivation writes @@ -213,14 +216,125 @@ def _profile_decode(raw): return "custom", None, None, None +# --- 3b. the time-window editor (UX-13 P5) ---------------------------------- # +# Each row is {"from", "to", "days", "price"} and, when read from a stored +# profile, its "name". `days` is one of these; a profile with any other day set +# is edited in the Configuration tab only (`ui.periods` is None). +PERIOD_DAYS = ("mon-sun", "mon-fri", "sat,sun") + DAY_NAMES +_DAYSETS = {d: frozenset(range(7)) if d == "mon-sun" else + frozenset(range(5)) if d == "mon-fri" else + frozenset((5, 6)) if d == "sat,sun" else + frozenset((DAY_NAMES.index(d),)) for d in PERIOD_DAYS} +_TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*$") + + +def _hhmm(minute) -> str: + return f"{minute // 60:02d}:{minute % 60:02d}" + + +def _periods_decode(raw): + """The rows for a stored profile: [] when unset, None when it cannot be + shown as rows (unparsable, or days the editor has no choice for).""" + try: + prof = parse_static_profile(raw) if isinstance(raw, str) else None + except ProfileInvalid: + return None + if prof is None: + return [] if isinstance(raw, str) else None + rows = [] + for p in prof.periods: + days = next((d for d, s in _DAYSETS.items() if s == p.days), None) + if days is None: + return None + rows.append({"from": _hhmm(p.start_min), "to": _hhmm(p.end_min), + "days": days, "price": p.eur_per_kwh, "name": p.name}) + return rows + + +def _minute(text, *, end): + """"07:00" -> 420, "24:00" -> 1440 as an end, else None.""" + m = _TIME.match(str(text or "")) + if not m: + return None + h, mi = int(m.group(1)), int(m.group(2)) + if mi > 59 or h > 24 or (h == 24 and (mi or not end)): + return None + return h * 60 + mi + + +def _periods_json(rows) -> str: + """The static_profile for the editor's rows, or DeriveError naming the + first problem: a bad time, an empty price, an overlap or a gap.""" + if not rows: + raise DeriveError("add at least one time window", "no_periods", + "ui.periods") + periods = [] + for i, row in enumerate(rows, start=1): + row = row if isinstance(row, dict) else {} + start = _minute(row.get("from"), end=False) + end = _minute(row.get("to"), end=True) + if start is None or end is None: + raise DeriveError(f"window {i} has a bad time", "period_time", + "ui.periods", row=i) + end = end or MINUTES_PER_DAY # "00:00" as an end + if start == end: + raise DeriveError(f"window {i} is empty", "period_zero", + "ui.periods", row=i) + price = _num(row.get("price")) + if price is None: + raise DeriveError(f"window {i} needs a price", "period_price", + "ui.periods", row=i) + if abs(price) > MAX_PLAUSIBLE_EUR_KWH: + raise DeriveError(f"window {i}'s price is out of range", + "period_price_range", "ui.periods", row=i) + if row.get("days") not in _DAYSETS: + raise DeriveError(f"window {i} has no days", "unknown", + "ui.periods") + name = str(row.get("name") or "").strip() or f"window{i}" + periods.append(Period(name=name, start_min=start, end_min=end, + days=_DAYSETS[row["days"]], + eur_per_kwh=price)) + _tiling(periods) + return json.dumps({"periods": [ + {"name": p.name, "from": _hhmm(p.start_min), + "to": _hhmm(p.end_min), "days": row["days"], + "eur_per_kwh": p.eur_per_kwh} + for p, row in zip(periods, rows)]}) + + +def _tiling(periods): + """Each minute of the week in exactly one window, as static_tariff.py + requires. The first overlap or gap, by day and time, is the error.""" + for day in range(7): + start, kind, rows = None, None, None + for minute in range(MINUTES_PER_DAY + 1): + here = [i for i, p in enumerate(periods, start=1) + if minute < MINUTES_PER_DAY and p.covers(day, minute)] + now = ("gap" if not here else "overlap" if len(here) > 1 + else None) if minute < MINUTES_PER_DAY else "end" + if kind is not None and (now != kind or (kind == "overlap" + and here != rows)): + values = {"day": DAY_NAMES[day], "from": _hhmm(start), + "to": _hhmm(minute)} + if kind == "gap": + raise DeriveError("a time has no window", "period_gap", + "ui.periods", **values) + raise DeriveError("two windows cover one time", + "period_overlap", "ui.periods", + a=rows[0], b=rows[1], **values) + if now in ("gap", "overlap") and kind is None: + start, kind, rows = minute, now, here + + def _tariff_decode(o): - kind, fixed, day, night = _profile_decode(o.get("static_profile", "")) + raw = o.get("static_profile", "") + kind, fixed, day, night = _profile_decode(raw) provider = o.get("price_provider") dynamic = not _blank(provider) and provider != "off" return {"ui.contract": "dynamic" if dynamic else kind, "price_provider": provider, "ui.fallback": kind, "ui.fixed_price": fixed, "ui.day_price": day, - "ui.night_price": night} + "ui.night_price": night, "ui.periods": _periods_decode(raw)} def _tariff_encode(m, keep): @@ -243,11 +357,16 @@ def _tariff_encode(m, keep): out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"), _price_of(m, "ui.night_price")) elif kind == "custom": - if "static_profile" not in keep: + rows = m.get("ui.periods") + if rows is not None: + out["static_profile"] = _periods_json(rows) + elif "static_profile" not in keep: raise DeriveError("odd tariff windows are edited in the " "Configuration tab", "custom_profile", "ui.contract") - out["static_profile"] = keep["static_profile"] + else: + # Days the editor cannot show: kept as stored. + out["static_profile"] = keep["static_profile"] else: raise DeriveError(f"unknown contract {kind!r}", "unknown", "ui.contract") diff --git a/goodwe_controller/app/uipage.py b/goodwe_controller/app/uipage.py index e4564b2..e91e0b3 100644 --- a/goodwe_controller/app/uipage.py +++ b/goodwe_controller/app/uipage.py @@ -9,26 +9,23 @@ are (DECISIONS/UX-13-one-layout-table.md). """ import copy -import io -import json import os import re from . import uilayout as L +from . import uitext from . import uimodel as U from .configui import help_of, label_of, problems_by_field, split_group from .validate import power_request_entities -UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text", - "en.json") -# A choice the page cannot make, only keep: odd tariff windows (P5 editor). -ONLY_IF_HELD = ("custom",) +# A choice the page cannot make, only keep. None since P5's period editor. +ONLY_IF_HELD = () _TIERS = ("bad", "warn", "info") -def ui_text() -> dict: - with io.open(UI_TEXT, encoding="utf-8") as f: - return json.load(f) +def ui_text(lang="en") -> dict: + """The page words in `lang`, English where Dutch has no key (P5).""" + return copy.deepcopy(uitext.words(lang)) def _roster(): @@ -122,6 +119,10 @@ def _field(fld, schema_ui, translations, lang, T): if hints: out["hints"] = {v: e.get("description", "") for v, e in hints.items()} + if fld.control == L.PERIOD_LIST: + # P5: the day choices of one time window, in the editor's order. + names = T["choices"]["period_days"] + out["days"] = [[d, names.get(d, d)] for d in U.PERIOD_DAYS] if fld.mirror: out["mirror"] = fld.mirror if fld.fills: @@ -145,7 +146,7 @@ def _field(fld, schema_ui, translations, lang, T): def layout(schema_ui, translations, lang="en") -> dict: """Every group, part and field of the Installer view, as the page draws it. Built from the table only: a field the table places is on the page.""" - T = ui_text() + T = ui_text(lang) groups = [] for g in L.GROUPS: parts = [] @@ -220,14 +221,19 @@ def _settle(out: dict, stored: dict | None) -> dict: | {k: v for k, v in out.items() if k not in stored}) -def derive_message(err: U.DeriveError) -> dict: - words = ui_text()["settings"]["derive"] +def derive_message(err: U.DeriveError, lang="en") -> dict: + T = uitext.words(lang) + words = T["settings"]["derive"] text = words.get(err.code, words["unknown"])["description"] + args = dict(getattr(err, "values", None) or {}) + # A time window's day is shown by name (P5 period editor). + if "day" in args: + args["day"] = T["choices"]["period_days"].get(args["day"], args["day"]) return {"field": err.field or None, "card": None, "tier": "bad", - "text": text} + "text": uitext.say(text, **args)} -def derive_messages(errors, problems, notes, model, keys): +def derive_messages(errors, problems, notes, model, keys, lang="en"): """The page's messages when part of the model could not become options: each conversion's own sentence beside its field, then the checks of the rest. A refusal or note about a key the failed conversion writes is left @@ -238,7 +244,7 @@ def derive_messages(errors, problems, notes, model, keys): for t in texts] + list(unanchored) kept = [p for p in problems if p in kept] notes = [n for n in notes if not set(n.keys or ()) & failed] - return ([derive_message(err) for err in errors] + return ([derive_message(err, lang) for err in errors] + anchors(kept, notes, model, keys)) diff --git a/goodwe_controller/app/uitext.py b/goodwe_controller/app/uitext.py new file mode 100644 index 0000000..d6caf53 --- /dev/null +++ b/goodwe_controller/app/uitext.py @@ -0,0 +1,51 @@ +"""UX-13 P5: the page's words, app/ui_text/<lang>.json, in English or Dutch. + +One file per language with the same keys. A key missing from a language falls +back to English, key by key, the way the Supervisor's translations do. +test_ui_text.py holds both files to STYLE.md and to each other. +""" + +import copy +import io +import json +import os + +DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text") +LANGS = ("en", "nl") +_CACHE: dict = {} + + +def lang_of(value) -> str: + """"nl", "nl-BE" or "nl_NL" -> "nl"; anything else -> "en".""" + head = str(value or "").strip().lower().replace("_", "-").split("-")[0] + return head if head in LANGS else "en" + + +def raw(lang: str) -> dict: + """One language file as written, without the English fallback.""" + if lang not in _CACHE: + with io.open(os.path.join(DIR, f"{lang}.json"), encoding="utf-8") as f: + _CACHE[lang] = json.load(f) + return _CACHE[lang] + + +def _merge(base, over): + if not (isinstance(base, dict) and isinstance(over, dict)): + return copy.deepcopy(over) + out = copy.deepcopy(base) + for k, v in over.items(): + out[k] = _merge(base.get(k), v) if k in base else copy.deepcopy(v) + return out + + +def words(lang="en") -> dict: + """Every word in `lang`, English where a key is missing.""" + lang = lang_of(lang) + return raw("en") if lang == "en" else _merge(raw("en"), raw(lang)) + + +def say(template: str, **values) -> str: + """Fill `{name}` placeholders, as settings.js `say()` does.""" + for k, v in values.items(): + template = template.replace("{" + k + "}", str(v)) + return template diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index e21029c..007fbdb 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -43,7 +43,7 @@ from datetime import datetime, timezone from aiohttp import web -from . import messages, selfconfig, uipage +from . import messages, selfconfig, uipage, uitext from .haentities import EntityCache from .homeview import region_of from .validate import config_notes, setup_checks @@ -54,13 +54,12 @@ from .uimodel import DeriveError, options_to_ui _LOG = logging.getLogger("goodwe.web") # UX-13 P1: the page lives in app/static/ as three files - index.html, -# app.css, app.js - plus the page words from app/ui_text/en.json. They are +# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English +# and, since P5, Dutch; the page picks by the browser's language). They are # joined into ONE response here, once, so the page needs no second request # and no route of its own: the route list below is unchanged, and every URL # the page uses stays relative (ingress). No build step, no external file. STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") -UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text", - "en.json") def _read(path): @@ -71,7 +70,8 @@ def _read(path): def page() -> str: """The assembled index page. `</` is escaped inside the two inline scripts, so no text in them can close the tag early.""" - ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False) + ui_text = json.dumps({lang: uitext.raw(lang) for lang in uitext.LANGS}, + ensure_ascii=False) scripts = {} for name in ("app.js", "settings.js"): scripts[name] = _read(os.path.join(STATIC, name)) @@ -129,8 +129,12 @@ def build_app(controller, selfcfg=None) -> web.Application: async def index(_request): return web.Response(text=index_page, content_type="text/html") - async def status(_request): - return web.json_response(controller.status_payload()) + async def status(request): + # UX-13 P5: Home's sentences in the page's language, English or Dutch. + lang = uitext.lang_of(request.query.get("lang")) + if lang == "en": + return web.json_response(controller.status_payload()) + return web.json_response(controller.status_payload(lang=lang)) async def action(request): try: @@ -264,7 +268,9 @@ def build_app(controller, selfcfg=None) -> web.Application: if model is not None: payload["messages"] = ( uipage.derive_messages(errors, problems, notes, model, - _keys_of(options)) if errors + _keys_of(options), + uitext.lang_of(_body.get("lang"))) + if errors else uipage.anchors(problems, notes, model, _keys_of(options))) payload["steps"] = uipage.step_problems(payload["messages"], model) if _body.get("setup"): @@ -316,7 +322,8 @@ def build_app(controller, selfcfg=None) -> web.Application: except DeriveError: model = posted["model"] _opts, errors = uipage.to_options_partial(model, stored_now) - messages_ = [uipage.derive_message(e) for e in errors] + lang = uitext.lang_of(posted.get("lang")) + messages_ = [uipage.derive_message(e, lang) for e in errors] return web.json_response( {"kind": "invalid", "problems": [], "by_key": {}, "unanchored": [], "messages": messages_, diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py index 2a69591..adfe652 100644 --- a/goodwe_controller/test_home.py +++ b/goodwe_controller/test_home.py @@ -546,12 +546,13 @@ def resolve(path): return node -check("the page is assembled: no placeholder is left and the words parse", +_WORDS = json.loads(PAGE.split('id="ui-text">', 1)[1] + .split("</script>", 1)[0].replace("<\\/", "</")) +check("the page is assembled: no placeholder is left and the words parse, " + "English and Dutch (UX-13 P5)", "/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE and "/*UI_TEXT*/" not in PAGE - and json.loads(PAGE.split('id="ui-text">', 1)[1] - .split("</script>", 1)[0].replace("<\\/", "</"))["home"] - == UI) + and _WORDS["en"]["home"] == UI and set(_WORDS) == {"en", "nl"}) check("four tabs with the four hash routes, in a keyboard tablist", re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries", "settings", "setup"] @@ -563,7 +564,7 @@ check(f"no external file and no web font: no URL leaves the add-on " and "<link" not in PAGE and "src=" not in PAGE) _abs = re.findall(r"""(?:fetch\(|href=)["'](/[^"']*)["']""", PAGE) check(f"every fetch and link is relative, for ingress ({_abs})", - not _abs and 'fetch("status"' in JS and 'fetch("action"' in JS) + not _abs and 'fetch("status?lang=' in JS and 'fetch("action"' in JS) _keys = set(re.findall(r'data-t(?:-label)?="([\w.]+)"', PAGE)) | set( re.findall(r'''\bt\("([\w.]+)"\)''', JS)) _missing = sorted(k for k in _keys if not isinstance(resolve(k), str)) @@ -610,6 +611,8 @@ async def _serve(): out["page"] = (r.status, r.content_type, await r.text()) async with s.get(f"http://127.0.0.1:{port}/status") as r: out["status"] = await r.json() + async with s.get(f"http://127.0.0.1:{port}/status?lang=nl-BE") as r: + out["status_nl"] = await r.json() async with s.get(f"http://127.0.0.1:{port}/config/model") as r: out["model"] = await r.json() finally: @@ -618,6 +621,14 @@ async def _serve(): E = asyncio.run(_serve()) +_nl_words = json.load(io.open(os.path.join(HERE, "app", "ui_text", "nl.json"), + encoding="utf-8"))["home_words"] +check("UX-13 P5: /status?lang=nl-BE builds Home in Dutch, and plain /status " + "stays English", + E["status_nl"]["state"]["pill"] in _nl_words["pill"].values() + and E["status_nl"]["control"]["text"] in _nl_words["control"].values() + and E["status"]["state"]["pill"] not in _nl_words["pill"].values() + and set(E["status_nl"]) == set(E["status"])) check("GET / serves the assembled page as HTML", E["page"][0] == 200 and E["page"][1] == "text/html" and E["page"][2] == PAGE) check("GET status over HTTP carries both halves", diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index fe882ca..543bc03 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -47,57 +47,10 @@ def check(name, cond): fails.append(name) -# --- the rule (STYLE.md sections 1 and 4) -------------------------------------- -BANNED = re.compile( - r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|" - r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|" - r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|" - r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|" - r"capaciteitstarief|journalctl|ssh|grep|" - r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I) -ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)" - r"\.[a-z0-9_*]+") -SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b") -SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b") -PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]") - - -def words(text): - return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text)) - if re.search(r"[A-Za-z0-9]", w)] - - -def sentences(text): - """Sentence count: a stop followed by a space and a capital, or the end.""" - t = PLACEHOLDER.sub("X", text.strip()) - t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t) - return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()]) - - -def title_problems(title): - out = [] - if len(words(title)) > 5: - out.append(f"title over 5 words ({len(words(title))})") - if title.lower().startswith("goodwe"): - out.append("title carries a GoodWe prefix") - if title.rstrip().endswith("."): - out.append("title ends in a full stop") - return out + text_problems(title, keys=False, entities=False) - - -def text_problems(text, *, keys, entities): - t = PLACEHOLDER.sub("X", text) - out = [] - hit = sorted({m.group(0) for m in BANNED.finditer(t)}) - if hit: - out.append("banned: " + ", ".join(hit[:4])) - if not entities and ENTITY.search(t): - out.append("entity id: " + ENTITY.search(t).group(0)) - if not keys and SNAKE.search(ENTITY.sub("", t)): - out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0)) - if SHOUT.search(t): - out.append("capitals: " + SHOUT.search(t).group(0)) - return out +# --- the rule (STYLE.md sections 1 and 4): tools/style_rules.py, shared with +# test_ui_text.py since UX-13 P5. +from style_rules import (BANNED, ENTITY, PLACEHOLDER, SHOUT, SNAKE, # noqa: E402,F401 + sentences, text_problems, title_problems, words) def body_problems(body, severity): @@ -483,10 +436,13 @@ def collect(): if isinstance(v.get("fields"), dict): walk(v["fields"], f"{path}{key}.") walk(tr.get("configuration"), "") - # UX-13: the new page's own words, held to the option texts' rule. + # UX-13: the new page's own words, held to the option texts' rule. Home's + # sentences (`home_words`) are templates, checked rendered in part 8. for f in sorted((APP / "ui_text").glob("*.json")): def walk_ui(node, path): for key, v in node.items(): + if not path and key == "home_words": + continue if isinstance(v, dict): walk_ui(v, f"{path}{key}.") elif isinstance(v, str): @@ -1852,12 +1808,15 @@ def home_problems(kind, text): return out -_home = homeview.text_samples() -_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)] if p] -for k, t, p in _home_bad: - print(f" {k}: {t!r}: {p}") -check(f"every Home sentence obeys its STYLE limit ({len(_home)} rendered, " - f"{len(_home_bad)} over)", len(_home) > 80 and not _home_bad) +for _lang in ("en", "nl"): # UX-13 P5: Home in English and Dutch + _home = homeview.text_samples(_lang) + _home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)] + if p] + for k, t, p in _home_bad: + print(f" {_lang} {k}: {t!r}: {p}") + check(f"every Home sentence in {_lang} obeys its STYLE limit " + f"({len(_home)} rendered, {len(_home_bad)} over)", + len(_home) > 80 and not _home_bad) _shadowed = [t for k, t in homeview.text_samples() if "ould" in t] check("in shadow mode the Home plan says what the add-on WOULD do: every " "shadow variant of a plan text carries 'would'", diff --git a/goodwe_controller/test_setup.py b/goodwe_controller/test_setup.py index 4de85cf..00f2efe 100644 --- a/goodwe_controller/test_setup.py +++ b/goodwe_controller/test_setup.py @@ -631,6 +631,12 @@ print(f" {len(_all)} blobs from {len(B.starts())} starts; " check(f"every plan from a new install is accepted by the shipped validator " f"({len(_new)} plans)", len(_new) >= 25 and not [n for n, r in _new if r]) +_windows = [(n, b) for n, b, r in _all if "periods" in n and not r] +check(f"UX-13 P5: the time-window plans are among them and write the " + f"windows as typed ({len(_windows)} blobs)", + len(_windows) >= 12 and all( + len(json.loads(b["static_profile"])["periods"]) in (1, 2, 5) + for _n, b in _windows)) _empty = [(n, r) for n, _b, r in _all if n.startswith("empty-")] check(f"every plan from a new install as shipped (no power request entity, " f"setup-only mode) is accepted ({len(_empty)} plans)", diff --git a/goodwe_controller/test_ui_text.py b/goodwe_controller/test_ui_text.py new file mode 100644 index 0000000..beba82d --- /dev/null +++ b/goodwe_controller/test_ui_text.py @@ -0,0 +1,252 @@ +"""UX-13 P5: the page's words, English and Dutch, follow STYLE.md. `python3 test_ui_text.py` + +app/ui_text/en.json and nl.json hold every word the Home, Batteries, Settings +and Setup screens show that is not an option label (those are +translations/*.yaml, test_translations.py) or a status row (app/messages.py, +test_messages.py). This check fails CI and names the offending string when: + +1. the two files differ in keys, or a string's {placeholders} differ; +2. a string breaks STYLE.md: a name or label over 6 words, a description over + 1 sentence of 25 words, any other line over 2 sentences, a semicolon, a + banned or jargon word (English and Dutch lists), an option key or entity id; +3. a string carries its own details (a link, a section number, a file name) + instead of leaving them to the page's DOCS link; +4. Home's sentences, rendered by app/homeview.py in each language, break + the Home rules of test_messages.py part 8 (checked there too); +5. a Dutch string is left in English, outside the short list of words that + are the same in both; +6. the English words that mirror app/messages.py drift from it. +Each rule is shown to catch a spoiled input (habit 1 of MUTATION.md). +""" + +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "tools")) + +from style_rules import (PLACEHOLDER, sentences, text_problems, # noqa: E402 + title_problems, words) +from app import homeview, messages, uitext # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def load(lang): + with open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"), + encoding="utf-8") as f: + return json.load(f) + + +EN, NL = load("en"), load("nl") + + +def leaves(node, path=()): + """(path, text) for every string, lists included (the month names).""" + if isinstance(node, dict): + for k, v in node.items(): + yield from leaves(v, path + (k,)) + elif isinstance(node, list): + for i, v in enumerate(node): + yield from leaves(v, path + (i,)) + else: + yield path, node + + +def where(lang, path): + return f"{lang}.json:" + ".".join(str(p) for p in path) + + +# ============================================================================ # +print("1. English and Dutch have the same keys and placeholders") +_en, _nl = dict(leaves(EN)), dict(leaves(NL)) +_only_en = sorted(where("en", p) for p in _en if p not in _nl) +_only_nl = sorted(where("nl", p) for p in _nl if p not in _en) +for x in _only_en + _only_nl: + print(f" MISSING in the other language: {x}") +check(f"every one of the {len(_en)} English strings has a Dutch one, and no " + "Dutch string is extra", not _only_en and not _only_nl and len(_en) > 300) +_holes = sorted( + f"{where('nl', p)}: {sorted(PLACEHOLDER.findall(str(_en[p])))} vs " + f"{sorted(PLACEHOLDER.findall(str(t)))}" + for p, t in _nl.items() if p in _en + and sorted(PLACEHOLDER.findall(str(t))) + != sorted(PLACEHOLDER.findall(str(_en[p])))) +for x in _holes: + print(f" PLACEHOLDERS differ: {x}") +check("every Dutch string fills the same {placeholders} as its English one", + not _holes) + + +# ============================================================================ # +print("\n2. every string follows STYLE.md, in both languages") +# STYLE.md section 4, the "Not" column, plus the Dutch words for the same +# things (translations/nl.yaml keeps "setpoint" in two option names, which the +# page never shows). Matched as whole words, case-insensitive. +JARGON = { + "en": r"controllers?|control loop|setpoints?|SoC|state of charge|BMS|PV|" + r"failsafe|trip(?:ped)?|parked|discharg\w*|P1 feed|quarter-hour|" + r"payload|endpoint|JSON|API|timeout|boolean|config", + "nl": r"setpoints?|SoC|BMS|PV|failsafe|regelaar|regellus|" + r"latch|ontlad\w*|payload|endpoint|JSON|API|time-?out|boolean|" + r"config|kwartuur\w*", +} +JARGON = {k: re.compile(r"\b(?:" + v + r")\b", re.I) for k, v in JARGON.items()} +# Details belong behind the page's DOCS link, never in the string itself. +DETAILS = re.compile(r"https?:|www\.|DOCS|documentation, section|§|" + r"\bsee section\b|\bzie sectie\b", re.I) +NAME_WORDS, DESC_WORDS, LINE_SENTENCES = 6, 25, 2 + + +def kind_of(path): + if path[0] == "home_words": + return "template" # part 4 renders and checks these + return "description" if path[-1] == "description" else "name" + + +def problems(lang, path, text): + kind = kind_of(path) + out = [] + if not isinstance(text, str) or not text.strip(): + return ["empty"] + if kind == "name" and len(words(text)) > NAME_WORDS: + out.append(f"name over {NAME_WORDS} words ({len(words(text))})") + if kind == "name" and sentences(text) > LINE_SENTENCES: + out.append(f"over {LINE_SENTENCES} sentences ({sentences(text)})") + if kind == "description": + if sentences(text) > 1: + out.append(f"description over 1 sentence ({sentences(text)})") + if len(words(text)) > DESC_WORDS: + out.append(f"description over {DESC_WORDS} words " + f"({len(words(text))})") + if ";" in text: + out.append("semicolon") + hit = JARGON[lang].search(PLACEHOLDER.sub("X", text)) + if hit: + out.append(f"jargon: {hit.group(0)!r}") + if DETAILS.search(text): + out.append(f"details belong behind the DOCS link: " + f"{DETAILS.search(text).group(0)!r}") + # The picker's own help names "entity id": the one place it is the word. + ok_entity = path[:2] == ("settings", "picker") + return out + [p for p in text_problems(text, keys=False, entities=False) + if not (ok_entity and p.startswith("entity id"))] + + +_bad = [] +for lang, data in (("en", EN), ("nl", NL)): + for path, text in leaves(data): + for p in problems(lang, path, text): + _bad.append(f"{where(lang, path)}: {p} :: {text!r}") +for x in _bad: + print(f" STYLE {x}") +check(f"every string in en.json and nl.json follows STYLE.md " + f"({len(_en) + len(_nl)} strings, {len(_bad)} breaking it)", not _bad) + + +# ============================================================================ # +print("\n3. the rule catches a spoiled input") +check("a 7-word name is caught", + problems("en", ("fields", "x", "name"), + "One two three four five six seven")) +check("a 2-sentence description is caught", + problems("nl", ("fields", "x", "description"), "Eén zin. Twee zinnen.")) +check("jargon is caught in English and in Dutch", + problems("en", ("x",), "Setpoint sent") and + problems("nl", ("x",), "De SoC is laag")) +check("a link or section in a string is caught", + problems("en", ("x", "description"), + "See https://example.org for the rest.")) +check("a snake_case option key is caught", + problems("nl", ("x",), "Zet peak_cap_w hoger")) + + +# ============================================================================ # +print("\n4. Home's sentences, rendered in each language (as test_messages 8)") +PLAN_WORDS, PLAN_SENTENCES = 12, 2 + + +def home_problems(kind, text): + if "{" in text or "}" in text: + return ["a placeholder was not filled"] + if kind == "title": + return title_problems(text) + out = text_problems(text, keys=False, entities=False) + if kind == "line": + if sentences(text) > 1 or len(words(text)) > 25: + out.append("line over 1 sentence of 25 words") + return out + if sentences(text) > PLAN_SENTENCES: + out.append(f"plan line over {PLAN_SENTENCES} sentences") + out += [f"plan sentence over {PLAN_WORDS} words: {x!r}" + for x in re.split(r"(?<=[.!?])\s+", text) + if len(words(x)) > PLAN_WORDS] + return out + + +for lang in ("en", "nl"): + _samples = homeview.text_samples(lang) + _hb = [f"{lang} {k}: {p} :: {t!r}" for k, t in _samples + for p in home_problems(k, t)] + _hb += [f"{lang}: jargon {JARGON[lang].search(t).group(0)!r} :: {t!r}" + for _k, t in _samples if JARGON[lang].search(t)] + for x in _hb: + print(f" HOME {x}") + check(f"{lang}: every Home sentence follows its rule " + f"({len(_samples)} rendered, {len(_hb)} breaking it)", + len(_samples) > 80 and not _hb) +_en_s = [t for _k, t in homeview.text_samples("en")] +_nl_s = [t for _k, t in homeview.text_samples("nl")] +check("Home in Dutch is not Home in English: most sentences differ, and " + "numbers use a decimal comma", + sum(a != b for a, b in zip(_en_s, _nl_s)) > 0.9 * len(_en_s) + and "2,5 kW" in homeview.text_samples("nl").__repr__() + and "2.5 kW" in _en_s.__repr__()) +check("the language resets after a Dutch build, so English callers stay " + "English", homeview.pill("running") == EN["home_words"]["pill"]["running"]) + + +# ============================================================================ # +print("\n5. no Dutch string is left in English") +# Words that are the same in both, or names. Anything else equal is untranslated. +SAME = {"Meter", "Timing", "Support", "Details", "Alarm", "Info", "per kWh", + "{hour}:00 UTC", "Flanders, Fluvius Antwerpen", "Modus", "Status"} +_same = sorted(f"{where('nl', p)} = {t!r}" for p, t in _nl.items() + if p in _en and t == _en[p] and t not in SAME + and PLACEHOLDER.sub("", t).strip(" .:,")) +for x in _same: + print(f" ENGLISH {x}") +check(f"every Dutch string is Dutch ({len(_same)} still English)", not _same) +check("mutation: an English string in nl.json is caught", + "Save" != NL["settings"]["savebar"]["save"] and "Save" not in SAME) + + +# ============================================================================ # +print("\n6. the words that mirror app/messages.py") +check("the setup-only banner on Home is messages.banner_setup_only()", + EN["home"]["setup_only"]["name"] == messages.banner_setup_only()) +_mirror = {k: (EN["home_words"]["attn"][k], v) + for k, v in homeview.ATTN_FROM_MESSAGES.items()} +_drift = {k: v for k, v in _mirror.items() if v[0] != v[1]} +check(f"five Home titles are the notification's own title ({_drift})", + len(_mirror) == 5 and not _drift) +check("app/uitext.py falls back to English per key and reads nl-BE as nl", + uitext.lang_of("nl-BE") == "nl" and uitext.lang_of("fr") == "en" + and uitext.words("nl")["home"]["tabs"]["home"] + == NL["home"]["tabs"]["home"] + and uitext._merge({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}}) + == {"a": {"b": 3, "c": 2}}) + +print() +if fails: + print(f"{len(fails)} FAILED") + sys.exit(1) +print("all passed") diff --git a/goodwe_controller/test_uimodel.py b/goodwe_controller/test_uimodel.py index 62702d0..7bb32fa 100644 --- a/goodwe_controller/test_uimodel.py +++ b/goodwe_controller/test_uimodel.py @@ -217,6 +217,109 @@ check("the hook answers None while battery modes do not exist", check("so the toggle is stored as the page has it", edit(DEFAULTS, arbitrage_enabled=True)["arbitrage_enabled"] is True) +print("\n7. the time-window editor (UX-13 P5)") + + +def rows(*spec): + return [{"from": a, "to": b, "days": d, "price": p} for a, b, d, p in spec] + + +def periods(r, base=SITE): + return edit(base, ui__contract="custom", ui__fallback="custom", + ui__periods=r) + + +def refused(r): + """(code, values) of the DeriveError the rows give, or None.""" + try: + periods(r) + except U.DeriveError as err: + return err.code, err.values + return None + + +# Five windows the day/night preset cannot say: a weekday peak, a weekday +# night, Saturday split at noon, and Sunday. +_five = rows(("06:30", "21:15", "mon-fri", 0.31), ("21:15", "06:30", "mon-fri", 0.19), + ("00:00", "12:00", "sat", 0.2), ("12:00", "00:00", "sat", 0.25), + ("00:00", "24:00", "sun", 0.18)) +_p5 = periods(_five) +_prof5 = parse_static_profile(_p5["static_profile"]) +check("five odd windows write a profile the tariff parser reads, with every " + "price in place", [p.eur_per_kwh for p in _prof5.periods] + == [0.31, 0.19, 0.2, 0.25, 0.18] and _prof5.periods[3].end_min == 1440) +check("...that the shipped start checks accept, and that reads back as the " + "same rows", not validate_startup_config(_p5) + and U.options_to_ui(_p5)["ui.contract"] == "custom" + and [(r["from"], r["to"], r["days"], r["price"]) + for r in U.options_to_ui(_p5)["ui.periods"]] + == [("06:30", "21:15", "mon-fri", 0.31), ("21:15", "06:30", "mon-fri", 0.19), + ("00:00", "12:00", "sat", 0.2), ("12:00", "24:00", "sat", 0.25), + ("00:00", "24:00", "sun", 0.18)]) +_named = dict(SITE, static_profile=json.dumps({"periods": [ + {"name": "peak", "from": "08:00", "to": "20:00", "days": "mon-sun", + "eur_per_kwh": 0.33}, + {"name": "offpeak", "from": "20:00", "to": "08:00", "days": "mon-sun", + "eur_per_kwh": 0.21}]})) +_rows = U.options_to_ui(_named)["ui.periods"] +_rows[1]["price"] = 0.2 +check("a stored profile's window names are kept when a price changes", + [p.name for p in parse_static_profile(periods(_rows, base=_named) + ["static_profile"]).periods] + == ["peak", "offpeak"]) +check("the day and night preset shows as three rows, so 'Other time windows' " + "starts from what is stored", + [r["days"] for r in U.options_to_ui( + edit(SITE, ui__contract="daynight", ui__day_price=0.3, + ui__night_price=0.2))["ui.periods"]] + == ["mon-fri", "mon-fri", "sat,sun"]) +check("an overlap is named: the two windows, the day and the times", + refused(rows(("07:00", "23:00", "mon-fri", 0.3), + ("22:00", "07:00", "mon-fri", 0.2), + ("00:00", "24:00", "sat,sun", 0.2))) + == ("period_overlap", {"day": "mon", "from": "22:00", "to": "23:00", + "a": 1, "b": 2})) +check("a gap is named: the day and the times", + refused(rows(("07:00", "22:00", "mon-sun", 0.3), + ("22:00", "24:00", "mon-sun", 0.2))) + == ("period_gap", {"day": "mon", "from": "00:00", "to": "07:00"})) +check("a gap on one day only is found on that day", + refused(rows(("00:00", "24:00", "mon-fri", 0.3), + ("00:00", "24:00", "sat", 0.2))) + == ("period_gap", {"day": "sun", "from": "00:00", "to": "24:00"})) +check("bad times are named by window: '7h', '25:00', '07:60', '24:00' as a " + "start", all(refused(rows(("00:00", "24:00", "mon-sun", 0.2), (a, b, "mon", 0.1))) + == ("period_time", {"row": 2}) for a, b in + (("7h", "08:00"), ("25:00", "08:00"), ("07:60", "08:00"), + ("24:00", "08:00"), ("07:00", ""))) + and refused(rows(("7:00", "07:00", "mon-sun", 0.2))) + == ("period_zero", {"row": 1})) +check("an empty price, and a price in cents, are named by window", + refused(rows(("00:00", "24:00", "mon-sun", None))) + == ("period_price", {"row": 1}) + and refused(rows(("00:00", "24:00", "mon-sun", 34))) + == ("period_price_range", {"row": 1})) +check("no windows at all is refused", refused([]) == ("no_periods", {})) +from app import uipage # noqa: E402 +_msg = uipage.derive_message(U.DeriveError("x", "period_overlap", "ui.periods", + day="sat", a=1, b=3, **{ + "from": "06:00", "to": "07:00"})) +_nl = uipage.derive_message(U.DeriveError("x", "period_gap", "ui.periods", + day="sun", **{"from": "00:00", + "to": "07:00"}), "nl") +check(f"the page shows the error beside the editor, with the day by name " + f"({_msg['text']!r}, {_nl['text']!r})", + _msg["field"] == "ui.periods" and "Windows 1 and 3" in _msg["text"] + and "Saturday 06:00 to 07:00" in _msg["text"] + and "zondag 00:00 tot 07:00" in _nl["text"]) +_raw = json.dumps({"periods": [ + {"from": "00:00", "to": "24:00", "days": "mon,wed", "eur_per_kwh": 0.2}, + {"from": "00:00", "to": "24:00", "days": "tue,thu-sun", "eur_per_kwh": 0.3}]}) +check("days the editor has no choice for stay in the Configuration tab, and " + "survive a save", U.options_to_ui(dict(SITE, static_profile=_raw)) + ["ui.periods"] is None and edit(dict(SITE, static_profile=_raw), gain=0.5) + ["static_profile"] == _raw) + print() if fails: print(f"{len(fails)} FAILED") diff --git a/goodwe_controller/tools/style_rules.py b/goodwe_controller/tools/style_rules.py new file mode 100644 index 0000000..2fb29dd --- /dev/null +++ b/goodwe_controller/tools/style_rules.py @@ -0,0 +1,58 @@ +"""STYLE.md sections 1 and 4 as code: the banned words and the counts. + +Shared by test_messages.py (UX-08) and test_ui_text.py (UX-13 P5), so the two +checks cannot drift apart. Moved here unchanged from test_messages.py. +""" + +import re + +BANNED = re.compile( + r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|" + r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|" + r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|" + r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|" + r"capaciteitstarief|journalctl|ssh|grep|" + r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I) +ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)" + r"\.[a-z0-9_*]+") +SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b") +SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b") +PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]") + + +def words(text): + return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text)) + if re.search(r"[A-Za-z0-9]", w)] + + +def sentences(text): + """Sentence count: a stop followed by a space and a capital, or the end.""" + t = PLACEHOLDER.sub("X", text.strip()) + t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t) + return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()]) + + +def title_problems(title): + out = [] + if len(words(title)) > 5: + out.append(f"title over 5 words ({len(words(title))})") + if title.lower().startswith("goodwe"): + out.append("title carries a GoodWe prefix") + if title.rstrip().endswith("."): + out.append("title ends in a full stop") + return out + text_problems(title, keys=False, entities=False) + + +def text_problems(text, *, keys, entities): + t = PLACEHOLDER.sub("X", text) + out = [] + hit = sorted({m.group(0) for m in BANNED.finditer(t)}) + if hit: + out.append("banned: " + ", ".join(hit[:4])) + if not entities and ENTITY.search(t): + out.append("entity id: " + ENTITY.search(t).group(0)) + if not keys and SNAKE.search(ENTITY.sub("", t)): + out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0)) + if SHOUT.search(t): + out.append("capitals: " + SHOUT.search(t).group(0)) + return out diff --git a/goodwe_controller/tools/ux13_demo.py b/goodwe_controller/tools/ux13_demo.py index d430ec9..9b8e0e6 100644 --- a/goodwe_controller/tools/ux13_demo.py +++ b/goodwe_controller/tools/ux13_demo.py @@ -1,7 +1,8 @@ """UX-13: serve the real page and routes over a fixture controller, for a headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]` -STATE is running, shadow or alarm. The controller is the shipped `Controller` +STATE is running, shadow, alarm, new (P4) or setup (UX-15's setup-only +mode, P5). The controller is the shipped `Controller` with its readings set by hand (two batteries, solar, today's prices in the mockup's shape, trading on). Nothing here talks to Home Assistant or to an inverter: `/action` changes only this fixture. Used for the P1 render check @@ -35,6 +36,7 @@ from aiohttp import web as aioweb # noqa: E402 from app import homeview, web # noqa: E402 from app.main import Controller # noqa: E402 +from app.setuponly import SetupOnly # noqa: E402 from app.peakguard import PeakDecision # noqa: E402 from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402 from app.static_tariff import TariffPlan # noqa: E402 @@ -203,8 +205,9 @@ class Supervisor: async def info(self): return {"options": json.loads(json.dumps(self.options)), "schema": schema_ui(CONFIG), - "translations": {"en": TL.parse_translation_file( - os.path.join(HERE, "translations", "en.yaml"))}} + "translations": {lang: TL.parse_translation_file( + os.path.join(HERE, "translations", f"{lang}.yaml")) + for lang in ("en", "nl")}} async def write_options(self, options): self.options = json.loads(json.dumps(options)) @@ -255,7 +258,20 @@ def controller(state): return c +class ReadOnlyHass(Hass): + read_only = True # app/setuponly.py serves only on a read-only HA + + async def main(state, port, host="127.0.0.1"): + if state == "setup": + # UX-15's setup-only mode: shipped defaults, nothing controlled. + stored = defaults(CONFIG) + c = SetupOnly(dict(stored), ReadOnlyHass(), Store()) + runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored))) + await runner.setup() + await aioweb.TCPSite(runner, host, port).start() + print(f"serving setup on http://127.0.0.1:{port}/", flush=True) + await asyncio.Event().wait() if state == "new": stored = dict(defaults(CONFIG), setpoint_entity="number.a") store = Store() diff --git a/goodwe_controller/tools/ux13_screenshots.py b/goodwe_controller/tools/ux13_screenshots.py new file mode 100644 index 0000000..6955338 --- /dev/null +++ b/goodwe_controller/tools/ux13_screenshots.py @@ -0,0 +1,96 @@ +"""UX-13 P5: the DOCS screenshots of the new screens, taken from the real page. + +Run by tools/ux13_screenshots.sh, which serves tools/ux13_demo.py fixtures and +runs this inside the Playwright image. Each shot fails the run on a page +error, so a broken screen is never pictured. + + python3 ux13_screenshots.py OUT_DIR # DEMO_<STATE> = the fixture's URL + +The battery-mode screens (P3) are not built, so there is no shot of them: +DOCS.md says so instead of showing a mock-up. +""" + +import os +import sys + +from playwright.sync_api import sync_playwright + +OUT = sys.argv[1] +DESKTOP = {"width": 1000, "height": 900} +PHONE = {"width": 390, "height": 844} + + +def url(state): + return os.environ[f"DEMO_{state.upper()}"] + + +def shot(browser, name, state, route, *, lang="en-GB", size=DESKTOP, + full=True, act=None, only=None): + ctx = browser.new_context(locale=lang, viewport=size, + color_scheme="light", reduced_motion="reduce") + page = ctx.new_page() + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.goto(url(state) + route) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(1500) # the first /status and checks + if act: + act(page) + page.wait_for_timeout(1500) # the debounced start checks + path = os.path.join(OUT, name + ".png") + if only: + page.locator(only).screenshot(path=path) + else: + page.screenshot(path=path, full_page=full) + ctx.close() + if errors: + raise SystemExit(f"{name}: page errors {errors}") + print(f" {name}.png") + + +def open_tariff(page): + page.locator("details.grp[data-g=tariff] > summary").click() + page.locator("details.grp[data-g=meter] > summary").click() # close it + # The fixture site is on a dynamic contract: its fallback gets windows. + page.get_by_role("radiogroup", name="Price when the feed is down") \ + .get_by_role("radio", name="Other time windows").click() + + +def windows(page, overlap=False): + open_tariff(page) + add = page.get_by_role("button", name="+ Add a time window") + rows = (("07:00", "23:00" if overlap else "22:00", "mon-fri", "0.34"), + ("22:00", "07:00", "mon-fri", "0.26"), + ("00:00", "24:00", "sat,sun", "0.24")) + for i, (a, b, days, price) in enumerate(rows, start=1): + add.click() + page.get_by_label(f"Window {i}, From").fill(a) + page.get_by_label(f"Window {i}, To").fill(b) + page.get_by_label(f"Window {i}, Days").select_option(days) + page.get_by_label(f"Window {i}, Price").fill(price) + page.locator("[data-key='ui.periods']").scroll_into_view_if_needed() + + +def main(): + os.makedirs(OUT, exist_ok=True) + with sync_playwright() as p: + b = p.chromium.launch() + shot(b, "home-running", "running", "#home") + shot(b, "home-running-nl", "running", "#home", lang="nl-BE") + shot(b, "home-shadow", "shadow", "#home") + shot(b, "home-phone", "running", "#home", size=PHONE) + shot(b, "home-setup-only", "setup", "#home", full=False) + shot(b, "settings-installer", "running", "#settings") + shot(b, "settings-installer-nl", "running", "#settings", lang="nl-BE") + tariff = "details.grp[data-g=tariff]" + shot(b, "settings-time-windows", "running", "#settings", + act=windows, only=tariff) + shot(b, "settings-time-windows-overlap", "running", "#settings", + act=lambda page: windows(page, overlap=True), only=tariff) + shot(b, "setup-step-1", "new", "#setup") + shot(b, "setup-step-1-nl", "new", "#setup", lang="nl-BE") + b.close() + + +if __name__ == "__main__": + main() diff --git a/goodwe_controller/tools/ux13_screenshots.sh b/goodwe_controller/tools/ux13_screenshots.sh new file mode 100755 index 0000000..b71ddf0 --- /dev/null +++ b/goodwe_controller/tools/ux13_screenshots.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# UX-13 P5: regenerate the DOCS screenshots in docs/img/ux13/ from the real page. +# +# sh goodwe_controller/tools/ux13_screenshots.sh [OUT_DIR] +# +# Serves tools/ux13_demo.py's fixtures (running, shadow, new, setup) in the CI +# test image, then runs tools/ux13_screenshots.py in the Playwright image on a +# private Docker network (the image has the browsers; the matching Python +# package is installed into the container on each run). Nothing talks to Home Assistant or an inverter. The +# prices and the "now" line follow the clock, so the pictures change a little +# from run to run. Needs Docker and PyPI access; pulls the Playwright image once. +set -eu +HERE=$(cd "$(dirname "$0")/.." && pwd) +OUT=${1:-$HERE/../docs/img/ux13} +PW=mcr.microsoft.com/playwright/python:v1.55.0-noble +NET=ux13-shots-$$ +STATES="running shadow new setup" +mkdir -p "$OUT" +OUT=$(cd "$OUT" && pwd) + +cleanup() { + for s in $STATES; do docker rm -f "$NET-$s" >/dev/null 2>&1 || true; done + docker network rm "$NET" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +docker build -q -t goodwe-addon-checks:1 "$HERE/../.gitea/ci" >/dev/null +docker network create "$NET" >/dev/null +ENVS="" +for s in $STATES; do + docker run -d --name "$NET-$s" --network "$NET" -e PYTHONDONTWRITEBYTECODE=1 \ + -v "$HERE:/w:ro" goodwe-addon-checks:1 \ + python3 /w/tools/ux13_demo.py "$s" 8099 0.0.0.0 >/dev/null + ENVS="$ENVS -e DEMO_$(echo "$s" | tr a-z A-Z)=http://$NET-$s:8099/" +done +sleep 3 +# shellcheck disable=SC2086 +docker run --rm --network "$NET" --user "$(id -u):$(id -g)" -e HOME=/tmp \ + $ENVS -v "$HERE/tools:/t:ro" -v "$OUT:/out" "$PW" \ + sh -euc 'pip install -q --disable-pip-version-check --target /tmp/pw \ + playwright==1.55.0 + PYTHONPATH=/tmp/pw python3 /t/ux13_screenshots.py /out' +echo "screenshots in $OUT" diff --git a/goodwe_controller/tools/ux13_wizard_blobs.py b/goodwe_controller/tools/ux13_wizard_blobs.py index d3f7d71..b219b2e 100644 --- a/goodwe_controller/tools/ux13_wizard_blobs.py +++ b/goodwe_controller/tools/ux13_wizard_blobs.py @@ -10,7 +10,8 @@ own validator run over them. The wizard edits the page model one step at a time and saves it through uipage.to_options(), like the Installer view. `blobs()` walks each step's choices (meter source and sign, one to three inverters, solar rows, every -contract and price source, region, peak limit, shadow mode) from a new +contract and price source, time windows (P5), region, peak limit, shadow +mode) from a new install as shipped, one with a power request entity, and the TEST-23 sites, and keeps what the shipped validator accepts, because the wizard saves nothing it refuses. test_setup.py checks the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions` @@ -103,6 +104,29 @@ PRICES = { "csv": {"ui.contract": "dynamic", "price_provider": "manual_csv", "price_csv_path": "/share/prices.csv", "ui.fallback": "fixed", "ui.fixed_price": 0.3}, + # P5: the time-window editor, odd windows the day/night preset cannot say. + "periods": {"ui.contract": "custom", "ui.fallback": "custom", + "ui.periods": [ + {"from": "06:30", "to": "21:15", "days": "mon-fri", + "price": 0.31}, + {"from": "21:15", "to": "06:30", "days": "mon-fri", + "price": 0.19}, + {"from": "00:00", "to": "12:00", "days": "sat", + "price": 0.2}, + {"from": "12:00", "to": "24:00", "days": "sat", + "price": 0.25}, + {"from": "00:00", "to": "24:00", "days": "sun", + "price": 0.18}]}, + "periods_one": {"ui.contract": "custom", "ui.fallback": "custom", + "ui.periods": [{"from": "00:00", "to": "00:00", + "days": "mon-sun", "price": 0.29}]}, + "dynamic_periods": {"ui.contract": "dynamic", + "price_provider": "generic_dayahead", + "ui.fallback": "custom", "ui.periods": [ + {"from": "07:00", "to": "23:00", + "days": "mon-sun", "price": 0.32}, + {"from": "23:00", "to": "07:00", + "days": "mon-sun", "price": 0.21}]}, } REGIONS = {"flanders": {"ui.region": "flanders"}, "own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}} @@ -139,6 +163,9 @@ def plans(): "shadow": "on"}, {"meter": "mqtt", "roster": "two", "solar": "none", "prices": "formula", "region": "flanders", "peak": "low", + "shadow": "on"}, + {"meter": "dsmr", "roster": "two", "solar": "one", + "prices": "periods", "region": "flanders", "peak": "forecast", "shadow": "on"}): seen.append(plan) return seen