UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English fallback per key; the page picks by browser language. Home's sentences move into ui_text `home_words`, so /status?lang=nl builds them in Dutch. - test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon lists for both languages, no inline details, untranslated Dutch caught. Shared rules moved unchanged to tools/style_rules.py. - tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot. - "Other time windows": a list editor (ui.periods) that builds static_profile and names bad times, empty prices, overlaps and gaps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+181
-176
@@ -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))]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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_,
|
||||
|
||||
Reference in New Issue
Block a user