Files
goodwe-addon/goodwe_controller/app/uitext.py
T
glenn schrooyenandClaude a1be9c48b3
checks / unit (pull_request) Successful in 24m9s
infra-pin / fresh (pull_request) Successful in 1s
SMA-10: two idle states, hold 0 W or hand the inverter back
A driver now says whether its release() hands the inverter back
(Driver.hands_back; GoodWe is False, so release() stays write(0) and the
Controller never calls it). command_site_release() sits next to
command_site_zero(): the Stop button and shutdown() (so SIGTERM, restart,
control task death and a late config refusal) hand a hand-back unit back,
once, and zero every other unit as before. Safety stops and refusals still
hold 0 W for every unit. Shadow mode releases a hand-back unit. After a
release nothing is written to the unit (heartbeat and cycle included) until
control starts again. release() is bounded by RELEASE_BUDGET_S (= the write
budget), runs concurrently per unit, and a raising or hanging release on one
unit does not stop the others.

The "set to 0 W" sentences for Stop, restart, shadow mode and the stopped
banner are built from the roster (app/handback.py, EN and NL, words from the
driver); a GoodWe-only roster keeps every old literal. A handed-back unit
shows "Back under <its control>" on Home.

Existing test edited: test_messages.py (two classification entries for the
CRITICAL line that moved into a roster builder, and the new release bound).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-07 18:21:28 +01:00

79 lines
2.4 KiB
Python

"""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 = {}
# SMA-10. The sentences that move with the roster (handback.overlay): empty for
# every GoodWe-only roster, and then `raw()` is the file as it always was.
_IDLE: dict = {}
_GEN = [0]
_MERGED: 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 _file(lang: str) -> dict:
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 set_idle(overlay: dict) -> None:
"""SMA-10: the roster-built sentences, `{lang: nested dict}` (or {})."""
_IDLE.clear()
_IDLE.update(overlay or {})
_MERGED.clear()
_GEN[0] += 1
def generation() -> int:
"""Changes whenever `set_idle()` does, so a cache of words can tell."""
return _GEN[0]
def raw(lang: str) -> dict:
"""One language file as written, without the English fallback (and with the
roster's own sentences laid over it when a unit is handed back)."""
if lang not in _IDLE:
return _file(lang)
if lang not in _MERGED:
_MERGED[lang] = _merge(_file(lang), _IDLE[lang])
return _MERGED[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