Merge pull request 'UX-46: Dutch status rows, banners, notes and wizard lines on NL pages; peak forecast label without (W)' (#205) from UX-46 into release/1.0
infra-pin / fresh (push) Successful in 2s
infra-pin / fresh (push) Successful in 2s
This commit was merged in pull request #205.
This commit is contained in:
@@ -211,7 +211,7 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
|
||||
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
|
||||
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
|
||||
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
|
||||
| Limits | 15-minute demand forecast entity | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
|
||||
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
|
||||
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
|
||||
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
|
||||
|
||||
@@ -1075,9 +1075,9 @@ 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.
|
||||
# Row id -> the title on Home. The row's own sentence is the body, in the
|
||||
# page's language (UX-46, `messages.pick`). 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 = {
|
||||
|
||||
@@ -7464,7 +7464,8 @@ class Controller:
|
||||
level, banner = "warn", messages.banner_stopped()
|
||||
elif runs:
|
||||
level, running_alarm = "bad", True
|
||||
banner = runs[0].get("banner") or messages.headline(runs[0]["text"])
|
||||
banner = runs[0].get("banner") or messages.headline_bi(
|
||||
runs[0]["text"])
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
|
||||
@@ -7487,11 +7488,11 @@ class Controller:
|
||||
# character; at two, "Maintenance: drain" without a name is a
|
||||
# banner an operator cannot act on, because the remedy is at one
|
||||
# device and the site has two.
|
||||
level, banner = "warn", f"Maintenance: {self.maint.phase}"
|
||||
if len(self.inverters) > 1:
|
||||
banner += f" ({self.maint_unit.name})"
|
||||
level, banner = "warn", messages.banner_maintenance(
|
||||
self.maint.phase,
|
||||
self.maint_unit.name if len(self.inverters) > 1 else "")
|
||||
else:
|
||||
level, banner = "ok", "Running — holding grid at zero"
|
||||
level, banner = "ok", messages.banner_running()
|
||||
|
||||
def w(v):
|
||||
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
||||
@@ -7595,6 +7596,13 @@ class Controller:
|
||||
now=datetime.now(timezone.utc), why=self.why_words)),
|
||||
]
|
||||
held = level == "bad" and not running_alarm
|
||||
# UX-46. The rows were built in English with their Dutch beside them
|
||||
# (`messages.Bi`); the page's language is chosen once, here, for the
|
||||
# Home sentences, the checks list, the rows that carry one and the
|
||||
# banner. A plain str (no Dutch twin) stays as it is.
|
||||
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
|
||||
rows = [(k, messages.pick(v, lang)) for k, v in rows]
|
||||
banner = messages.pick(banner, lang)
|
||||
out = {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
"hint": self.hint,
|
||||
|
||||
+500
-142
File diff suppressed because it is too large
Load Diff
@@ -54,10 +54,12 @@ class SetupOnly:
|
||||
|
||||
def status_payload(self, lang: str = "en") -> dict:
|
||||
return {"setup_only": True, "mode": "setup_only",
|
||||
"banner": messages.banner_setup_only(), "level": "warn",
|
||||
"banner": messages.pick(messages.banner_setup_only(),
|
||||
lang), "level": "warn",
|
||||
"rows": [["Control", "not started, in setup"]],
|
||||
"checks": [{"ok": False, "warn": True,
|
||||
"text": messages.row_setup_only()}],
|
||||
"text": messages.pick(messages.row_setup_only(),
|
||||
lang)}],
|
||||
"hint": self.hint, "events": list(self.events)}
|
||||
|
||||
async def handle_action(self, what):
|
||||
|
||||
@@ -17,7 +17,7 @@ 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
|
||||
from .validate import note_text, power_request_entities
|
||||
|
||||
# A choice the page cannot make, only keep. `price_service` needs a token, and
|
||||
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
|
||||
@@ -314,7 +314,7 @@ def anchors(problems, notes, model, keys, lang="en") -> list:
|
||||
add(None, None, "bad", text)
|
||||
for note in notes:
|
||||
tier = note.tier if note.tier in _TIERS else "info"
|
||||
text = messages.NOTE_NL.get(note.text, note.text) if lang == "nl" else note.text
|
||||
text = note_text(note, lang)
|
||||
for key in note.keys or (None,):
|
||||
for field, card in (targets(key, note.text, model) if key
|
||||
else [(None, None)]):
|
||||
|
||||
@@ -386,12 +386,23 @@ class Note(NamedTuple):
|
||||
# also "ok" and "bad" (setup_checks)
|
||||
keys: tuple
|
||||
text: str
|
||||
nl: str = "" # UX-46: the same sentence in Dutch
|
||||
|
||||
|
||||
def _advise(notes, tier: str, keys, text: str) -> None:
|
||||
"""Add a Note when the caller collects them (the settings page)."""
|
||||
def _advise(notes, tier: str, keys, text: str, nl: str = "") -> None:
|
||||
"""Add a Note when the caller collects them (the settings page). `nl` is
|
||||
the Dutch twin of `text`; the page picks one (`note_text()`)."""
|
||||
if notes is not None:
|
||||
notes.append(Note(tier, tuple(keys), text))
|
||||
notes.append(Note(tier, tuple(keys), text, nl))
|
||||
|
||||
|
||||
def note_text(note, lang: str = "en") -> str:
|
||||
"""The note's sentence in the page's language, English when it has no
|
||||
Dutch twin."""
|
||||
if lang == "nl":
|
||||
return getattr(note, "nl", "") or messages.NOTE_NL.get(
|
||||
note.text, note.text)
|
||||
return note.text
|
||||
|
||||
|
||||
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
|
||||
@@ -628,6 +639,11 @@ def S(key: str, where: str = "") -> str:
|
||||
return messages.setting(key, where.strip().strip("()"))
|
||||
|
||||
|
||||
def SN(key: str, where: str = "") -> str:
|
||||
"""`S()` with the Dutch label, for the Dutch twin of a note."""
|
||||
return messages.setting(key, where.strip().strip("()"), "nl")
|
||||
|
||||
|
||||
def _labels(keys, joiner: str = "and") -> str:
|
||||
"""Several settings, each with its key: the config panel files a refusal
|
||||
under every key it names (`configui.problems_by_field()`)."""
|
||||
@@ -1216,7 +1232,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
integ_w, max_w)
|
||||
_advise(notes, "warn", ("integrator_max_w",),
|
||||
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
|
||||
"adds overshoot, so set it to 0.")
|
||||
"adds overshoot, so set it to 0.",
|
||||
nl=f"{SN('integrator_max_w')} is hoger dan {SN('max_w')}, wat "
|
||||
"alleen doorschieten geeft, dus zet het op 0.")
|
||||
|
||||
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
||||
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
|
||||
@@ -1244,7 +1262,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
_advise(notes, "warn", ("maintenance_discharge_w",),
|
||||
f"{S('maintenance_discharge_w')} is above "
|
||||
f"{S('max_w', maint_where)}, so the battery empties at "
|
||||
f"{messages.watts(maint_cap)} and the cycle takes longer.")
|
||||
f"{messages.watts(maint_cap)} and the cycle takes longer.",
|
||||
nl=f"{SN('maintenance_discharge_w')} is hoger dan "
|
||||
f"{SN('max_w', maint_where)}, dus de batterij loopt leeg met "
|
||||
f"{messages.watts(maint_cap)} en de cyclus duurt langer.")
|
||||
_LOG.debug(
|
||||
"Not refused: the drain phase sets the setpoint, the arbiter "
|
||||
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
||||
@@ -1473,7 +1494,9 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
TAXES_PROFILE_OPTION, negative)
|
||||
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
|
||||
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
|
||||
"lower the price, and that is usually a sign error.")
|
||||
"lower the price, and that is usually a sign error.",
|
||||
nl=f"{SN(TAXES_PROFILE_OPTION)} heeft negatieve bedragen, "
|
||||
"die de prijs verlagen, en dat is meestal een tekenfout.")
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
@@ -1532,7 +1555,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
_advise(notes, "warn", ("price_formula", "price_provider"),
|
||||
f"{S('price_formula')} is not used, because "
|
||||
f"{S('price_provider')} is not 'custom_formula', so clear "
|
||||
"it or choose that source.")
|
||||
"it or choose that source.",
|
||||
nl=f"{SN('price_formula')} wordt niet gebruikt, omdat "
|
||||
f"{SN('price_provider')} niet 'custom_formula' is, dus wis "
|
||||
"ze of kies die bron.")
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
@@ -1547,7 +1573,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
# cannot start.
|
||||
_LOG.warning("%s", aged)
|
||||
_advise(notes, "warn", ("price_formula",),
|
||||
_card_note("price_formula", spec))
|
||||
_card_note("price_formula", spec),
|
||||
nl=_card_note("price_formula", spec, "nl"))
|
||||
|
||||
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
|
||||
#
|
||||
@@ -1630,7 +1657,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
xspec.offset_eur_kwh)
|
||||
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
|
||||
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
|
||||
"only the shape of its numbers is checked.")
|
||||
"only the shape of its numbers is checked.",
|
||||
nl=f"{SN(EXPORT_FORMULA_OPTION)} heeft geen kaartblok, "
|
||||
"dus alleen de vorm van de getallen wordt "
|
||||
"gecontroleerd.")
|
||||
_LOG.debug(
|
||||
"Accepted because some suppliers (Eneco) print the "
|
||||
"injection formula with no price beside it. If your card "
|
||||
@@ -1643,7 +1673,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
if xaged:
|
||||
_LOG.warning("%s", xaged)
|
||||
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
|
||||
_card_note(EXPORT_FORMULA_OPTION, xspec))
|
||||
_card_note(EXPORT_FORMULA_OPTION, xspec),
|
||||
nl=_card_note(EXPORT_FORMULA_OPTION, xspec, "nl"))
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
@@ -1657,13 +1688,16 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
"fallback.", pid)
|
||||
_advise(notes, "warn", ("static_profile",),
|
||||
f"{S('static_profile')} is empty, so the site has no tariff "
|
||||
"at all while the price feed is down.")
|
||||
"at all while the price feed is down.",
|
||||
nl=f"{SN('static_profile')} is leeg, dus de installatie heeft "
|
||||
"helemaal geen tarief zolang de prijsfeed uitvalt.")
|
||||
|
||||
# PRICE-01: not a fault, so an info note and no log line here (the add-on
|
||||
# logs it once at start). With `price_service` the fixed tariff is only the
|
||||
# plan's last fallback; the cheap window and the gate do not read it.
|
||||
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
|
||||
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE)
|
||||
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE,
|
||||
nl=messages.FIXED_TARIFF_NOTE_NL)
|
||||
|
||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||
@@ -1735,7 +1769,11 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def _card_note(option: str, spec) -> str:
|
||||
def _card_note(option: str, spec, lang: str = "en") -> str:
|
||||
if lang == "nl":
|
||||
return (f"{SN(option)} gebruikt een tariefkaart van "
|
||||
f"{spec.card_month}, dus neem de nieuwe cijfers over als uw "
|
||||
"contract veranderde.")
|
||||
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
|
||||
"the new figures if your contract changed.")
|
||||
|
||||
@@ -1810,7 +1848,10 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
f"This month's peak is already {messages.watts(month_peak_w)}, "
|
||||
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
|
||||
f"so a lower {S('peak_cap_w')} saves nothing until next month.",
|
||||
nl=f"De piek van deze maand is al "
|
||||
f"{messages.watts(month_peak_w)}, dus een lagere "
|
||||
f"{SN('peak_cap_w')} bespaart niets tot volgende maand.")
|
||||
return notes
|
||||
|
||||
|
||||
@@ -1823,31 +1864,43 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
if row is None:
|
||||
_advise(notes, "bad" if key in bad else "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
f"{S(key, where)} names.",
|
||||
nl=f"Home Assistant kent {entity} niet, maar "
|
||||
f"{SN(key, where)} verwijst ernaar.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
"taken as watts, so pick a sensor in W.",
|
||||
nl=f"{entity} meldt in {unit}, maar {SN(key, where)} wordt "
|
||||
"als watt gelezen, dus kies een sensor in W.")
|
||||
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
|
||||
_advise(notes, "warn", (key,),
|
||||
messages.peak_forecast_unit(unit) + " Pick a sensor in "
|
||||
"W or kW.")
|
||||
"W or kW.",
|
||||
nl=messages.peak_forecast_unit(unit, "nl")
|
||||
+ " Kies een sensor in W of kW.")
|
||||
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} does not read in kWh, so pick a solar "
|
||||
"forecast sensor in kWh.")
|
||||
"forecast sensor in kWh.",
|
||||
nl=f"{entity} meet niet in kWh, dus kies een sensor voor "
|
||||
"de zonneprognose in kWh.")
|
||||
today = solarfc.entities(opts)
|
||||
tomorrow = solarfc.entities(opts, tomorrow=True)
|
||||
if (today or tomorrow) and \
|
||||
opts.get("battery_modes_enabled") is not True:
|
||||
_advise(notes, "info", ("pv_forecast_entity",),
|
||||
"The solar forecast is used only with battery modes on.")
|
||||
"The solar forecast is used only with battery modes on.",
|
||||
nl="De zonneprognose wordt alleen gebruikt met batterijmodi "
|
||||
"aan.")
|
||||
if tomorrow and not today:
|
||||
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
||||
"Tomorrow's forecast is used only with today's, so set "
|
||||
f"{S('pv_forecast_entity')} too.")
|
||||
f"{S('pv_forecast_entity')} too.",
|
||||
nl="De prognose voor morgen wordt alleen met die van vandaag "
|
||||
f"gebruikt, dus stel ook {SN('pv_forecast_entity')} in.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1926,7 +1979,9 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
if entities is None:
|
||||
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
||||
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
||||
"answering, so press Check again once it is back.")
|
||||
"answering, so press Check again once it is back.",
|
||||
nl="Home Assistant antwoordt niet, dus druk op Opnieuw "
|
||||
"nakijken zodra het terug is.")
|
||||
else:
|
||||
# A missing meter entity is red too (UX-15): with the kettle test
|
||||
# skipped, nothing else on the way to the save would catch it.
|
||||
@@ -1948,13 +2003,16 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
solar.append(None)
|
||||
_advise(notes, "warn", ("pv_invert_entities",),
|
||||
f"{entity} reads below zero now, so turn on its "
|
||||
"“Reads negative” switch in the Solar step.")
|
||||
"“Reads negative” switch in the Solar step.",
|
||||
nl=f"{entity} meet nu onder nul, dus zet de schakelaar "
|
||||
"“Meet negatief” aan bij de stap Zonnepanelen.")
|
||||
else:
|
||||
w = -w if entity in flipped else w
|
||||
solar.append(w)
|
||||
_advise(notes, "ok", ("pv_entity",),
|
||||
f"{entity} reads {_signed(w)} of solar production "
|
||||
"now.")
|
||||
"now.",
|
||||
nl=f"{entity} meet nu {_signed(w)} zonneproductie.")
|
||||
# A battery sensor that reads the wrong way while giving power makes the
|
||||
# house use less than nothing. Only with every term known: a missing
|
||||
# solar reading could be the difference.
|
||||
@@ -1964,7 +2022,10 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
_advise(notes, "warn", ("batt_invert",),
|
||||
f"The readings add up to {_signed(sum(known))} of house "
|
||||
"use, so check each "
|
||||
"battery's “Battery power sign is reversed” switch in step 2.")
|
||||
"battery's “Battery power sign is reversed” switch in step 2.",
|
||||
nl=f"De metingen tellen op tot {_signed(sum(known))} "
|
||||
"huisverbruik, dus controleer bij elke batterij de schakelaar "
|
||||
"“Teken batterijvermogen omkeren” in stap 2.")
|
||||
_price_check(notes, opts, running, prices_known)
|
||||
return notes
|
||||
|
||||
@@ -1988,7 +2049,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
return float(meter_now_w)
|
||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||
"this meter itself, so Home shows its reading after "
|
||||
"the restart.")
|
||||
"the restart.",
|
||||
nl="De add-on leest deze meter zelf, dus Start toont de "
|
||||
"meting na de herstart.")
|
||||
return None
|
||||
if entities is None:
|
||||
return None
|
||||
@@ -2001,7 +2064,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("meter_source",),
|
||||
f"{entity} reads {text} now, so check it in Home "
|
||||
"Assistant before you start.")
|
||||
"Assistant before you start.",
|
||||
nl=f"{entity} meldt nu {text}, dus controleer ze in Home "
|
||||
"Assistant voor u start.")
|
||||
return None
|
||||
total += sign * w
|
||||
if source == "off" and opts.get("meter_invert"):
|
||||
@@ -2012,12 +2077,14 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
|
||||
def _meter_line(notes, w) -> None:
|
||||
if abs(w) < 1.0:
|
||||
text = "The meter reads 0 W now."
|
||||
text, nl = "The meter reads 0 W now.", "De meter meldt nu 0 W."
|
||||
elif w > 0:
|
||||
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
||||
nl = f"De meter meldt nu {messages.watts(w)} afname van het net."
|
||||
else:
|
||||
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
||||
_advise(notes, "ok", ("meter_source",), text)
|
||||
nl = f"De meter meldt nu {messages.watts(w)} injectie op het net."
|
||||
_advise(notes, "ok", ("meter_source",), text, nl=nl)
|
||||
|
||||
|
||||
def _card_checks(notes, who, entry, entities):
|
||||
@@ -2025,40 +2092,55 @@ def _card_checks(notes, who, entry, entities):
|
||||
positive while giving power, or None when it is not known."""
|
||||
if entities is None:
|
||||
return None
|
||||
wie = who.replace(" for ", " voor ", 1)
|
||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||
if setpoint and setpoint in entities:
|
||||
_advise(notes, "ok", ("setpoint_entity",),
|
||||
f"Home Assistant has {setpoint}, the power request "
|
||||
f"entity{who}.")
|
||||
f"entity{who}.",
|
||||
nl=f"Home Assistant heeft {setpoint}, de entiteit voor de "
|
||||
f"vermogensvraag{wie}.")
|
||||
soc = str(entry.get("soc_entity") or "").strip()
|
||||
if not soc:
|
||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
||||
f"level entity{who}, so pick one in step 2 or the add-on "
|
||||
"holds 0 W.")
|
||||
"holds 0 W.",
|
||||
nl=f"Er is geen entiteit voor het laadniveau{wie}, dus kies er "
|
||||
"een in stap 2 of de add-on houdt 0 W aan.")
|
||||
elif soc in entities:
|
||||
level, _unit = _reading(entities, soc)
|
||||
if level is None or not 0.0 <= level <= 100.0:
|
||||
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
||||
f"level{who} reads nothing now, so check {soc}.")
|
||||
f"level{who} reads nothing now, so check {soc}.",
|
||||
nl=f"Het laadniveau{wie} meldt niets, dus controleer "
|
||||
f"{soc}.")
|
||||
else:
|
||||
_advise(notes, "ok", ("soc_entity",),
|
||||
f"The battery level{who} reads {level:.0f} %.")
|
||||
f"The battery level{who} reads {level:.0f} %.",
|
||||
nl=f"Het laadniveau{wie} meldt {level:.0f} %.")
|
||||
batt = str(entry.get("batt_entity") or "").strip()
|
||||
if batt and batt in entities:
|
||||
w, _unit = _reading(entities, batt)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
||||
f"power{who} reads nothing now, so check {batt}.")
|
||||
f"power{who} reads nothing now, so check {batt}.",
|
||||
nl=f"Het batterijvermogen{wie} meldt niets, dus controleer "
|
||||
f"{batt}.")
|
||||
return None
|
||||
w = -w if entry.get("batt_invert") else w
|
||||
if abs(w) < IDLE_W:
|
||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
||||
"idle now, so check its direction on Home later.")
|
||||
"idle now, so check its direction on Home later.",
|
||||
nl=f"De batterij{wie} staat nu stil, dus controleer later "
|
||||
"op Start de richting.")
|
||||
else:
|
||||
doing = "giving power" if w > 0 else "charging"
|
||||
doet = "levert" if w > 0 else "laadt"
|
||||
_advise(notes, "info", ("batt_invert",),
|
||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
||||
"now, so check the inverter shows the same.")
|
||||
"now, so check the inverter shows the same.",
|
||||
nl=f"De batterij{wie} {doet} nu {messages.watts(w)}, dus "
|
||||
"controleer of de omvormer hetzelfde toont.")
|
||||
return w
|
||||
return None
|
||||
|
||||
@@ -2070,20 +2152,28 @@ def _price_check(notes, opts, running, prices_known) -> None:
|
||||
if profile:
|
||||
_advise(notes, "ok", ("static_profile",),
|
||||
"Your prices come from your contract, so they are "
|
||||
"always known.")
|
||||
"always known.",
|
||||
nl="Uw prijzen komen uit uw contract, dus ze zijn altijd "
|
||||
"bekend.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"No prices are set, so the batteries do not charge "
|
||||
"on price.")
|
||||
"on price.",
|
||||
nl="Er zijn geen prijzen ingesteld, dus de batterijen "
|
||||
"laden niet op prijs.")
|
||||
return
|
||||
same = (isinstance(running, dict)
|
||||
and str(running.get("price_provider") or "") == provider)
|
||||
if same and prices_known:
|
||||
_advise(notes, "ok", ("price_provider",),
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.")
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.",
|
||||
nl=f"De prijzen van vandaag zijn binnen, voor {prices_known} "
|
||||
"van 24 uur.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"Prices are fetched after the restart, and Home shows them.")
|
||||
"Prices are fetched after the restart, and Home shows them.",
|
||||
nl="De prijzen worden na de herstart opgehaald, en Start toont "
|
||||
"ze.")
|
||||
|
||||
|
||||
def _margin_problems(opts: dict) -> list[str]:
|
||||
|
||||
@@ -51,7 +51,7 @@ from aiohttp import web
|
||||
from . import messages, price, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import priced_hours, region_of
|
||||
from .validate import config_notes, setup_checks
|
||||
from .validate import config_notes, note_text, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
||||
problems_by_field, secret_keys)
|
||||
@@ -105,6 +105,9 @@ def page() -> str:
|
||||
DEVICE_NOTE = (
|
||||
"%s did not report its power range, so two checks were skipped, here and "
|
||||
"at start. If they fail later, the add-on will not start then.")
|
||||
DEVICE_NOTE_NL = (
|
||||
"%s meldde geen vermogensbereik, dus twee controles zijn overgeslagen, "
|
||||
"hier en bij het starten. Falen ze later, dan start de add-on niet.")
|
||||
|
||||
|
||||
def build_app(controller, selfcfg=None) -> web.Application:
|
||||
@@ -301,22 +304,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
# UX-13: warnings and info beside the refusals. Never block a save.
|
||||
# The entity checks use the picker's cached list and never wait on HA.
|
||||
notes += config_notes(options, entities=entities.peek())
|
||||
# UX-46: one language choice for everything below that has words.
|
||||
lang = uitext.lang_of(_body.get("lang"))
|
||||
payload = {"problems": problems, "by_key": by_key,
|
||||
"unanchored": unanchored,
|
||||
"notes": [n._asdict() for n in notes]}
|
||||
if unread:
|
||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
||||
payload["device_note"] = (DEVICE_NOTE_NL if lang == "nl"
|
||||
else DEVICE_NOTE) % ", ".join(
|
||||
sorted(set(unread)))
|
||||
if model is not None:
|
||||
payload["messages"] = (
|
||||
uipage.derive_messages(errors, problems, notes, model,
|
||||
_keys_of(options),
|
||||
uitext.lang_of(_body.get("lang")))
|
||||
_keys_of(options), lang)
|
||||
if errors
|
||||
else uipage.anchors(problems, notes, model, _keys_of(options),
|
||||
uitext.lang_of(_body.get("lang"))))
|
||||
lang))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||
if _body.get("setup"):
|
||||
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
||||
payload["setup"] = [
|
||||
dict(n._asdict(), text=note_text(n, lang))
|
||||
for n in await _setup_checks(options)]
|
||||
return web.json_response(payload)
|
||||
|
||||
async def _setup_checks(options):
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
"""UX-46: the Dutch pages show Dutch status rows, notes and wizard lines.
|
||||
`python3 -B test_ux46.py`
|
||||
|
||||
Before this, Home's status rows, the settings page's notes and the setup
|
||||
wizard's checks were built in English and sent as they were, so the NL pages
|
||||
showed English under Dutch titles (SAFETY-118 I-1, UX-44 I-2, UX-45 I-3).
|
||||
|
||||
1. Rows: every row in `messages.status_samples()` carries a Dutch twin
|
||||
(`messages.Bi`), the Dutch follows STYLE.md, and the Home page's status
|
||||
payload gives it for lang=nl and the English text for everything else.
|
||||
2. Notes: every `_advise()` call in app/validate.py gives a Dutch twin, the
|
||||
notes the validator really builds carry it, and /config/validate sends the
|
||||
page's language, for the settings notes and for the wizard's check list.
|
||||
3. The setting labels the Dutch notes quote are the labels in nl.yaml, and the
|
||||
peak forecast label has no "(W)", since any power unit is read.
|
||||
4. The rules above catch spoiled input.
|
||||
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import translations_lint as TL # noqa: E402
|
||||
import ux13_demo as D # noqa: E402
|
||||
from style_rules import sentences, text_problems, words # noqa: E402
|
||||
from app import messages, uimodel as U, uitext, web # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from named_absence import field, nth, present # noqa: E402
|
||||
from app.validate import (Note, note_text, setup_checks, # noqa: E402
|
||||
config_notes)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# STYLE.md for a Dutch row or note: the same length and shape rules, plus the
|
||||
# Dutch words that test_ui_text.py bans for the same things. A quoted setting
|
||||
# label may keep its own words ("Setpoint-entiteit" is the label in nl.yaml).
|
||||
JARGON_NL = re.compile(
|
||||
r"\b(?:setpoints?|setpunt\w*|SoC|laadtoestand|BMS|PV|failsafe|regelaar|"
|
||||
r"regellus|controller|latch|ontlad\w*|payload|endpoint|JSON|API|"
|
||||
r"time-?out|boolean|config|kwartuur\w*|commando\w*|geparkeerd|ESP32|"
|
||||
r"master)\b", re.I)
|
||||
# English function words that never belong in a Dutch sentence.
|
||||
ENGLISH = re.compile(r"\b(?:the|not|and|for|with|from|reads|check|so|no|set|"
|
||||
r"keeps|stays|has|have|ran|ago|seconds?|minutes?)\b", re.I)
|
||||
QUOTED = re.compile(r"“[^”]*”")
|
||||
|
||||
|
||||
def dutch_problems(text):
|
||||
bare = re.sub(r"\b[a-z_]+\.[a-z0-9_]+\b", "X", QUOTED.sub("X", text))
|
||||
out = []
|
||||
if JARGON_NL.search(bare):
|
||||
out.append("jargon: " + JARGON_NL.search(bare).group(0))
|
||||
names = re.sub(r"\binverter-[a-z0-9]+\b|\b[a-z_]+\.[a-z0-9_]+\b", "X",
|
||||
bare)
|
||||
eng = ENGLISH.search(names)
|
||||
if eng:
|
||||
out.append("English word: " + eng.group(0))
|
||||
return out
|
||||
|
||||
|
||||
def row_problems_nl(colour, text):
|
||||
n_s, n_w = sentences(text), len(words(text))
|
||||
out = []
|
||||
if colour == messages.RED:
|
||||
if n_s > 3 or n_w > 30:
|
||||
out.append(f"red row over 3 sentences / 30 words ({n_s}/{n_w})")
|
||||
elif n_s > 1 or n_w > 15:
|
||||
out.append(f"row over 1 sentence / 15 words ({n_s}/{n_w})")
|
||||
if ";" in text:
|
||||
out.append("semicolon")
|
||||
return out + text_problems(text, keys=False, entities=False) \
|
||||
+ dutch_problems(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. every status row has a Dutch twin, and the page picks one")
|
||||
# --------------------------------------------------------------------------- #
|
||||
ROWS = [(fn, c, t) for fn, c, t in messages.status_samples() if c is not None]
|
||||
check(f"there are rows to grade ({len(ROWS)})", len(ROWS) > 80)
|
||||
_none = [(fn, str(t)) for fn, c, t in ROWS
|
||||
if not (isinstance(t, messages.Bi) and t.nl)]
|
||||
check(f"every row has a Dutch text ({_none})", not _none)
|
||||
_same = [(fn, str(t)) for fn, c, t in ROWS
|
||||
if isinstance(t, messages.Bi) and t.nl == str(t)]
|
||||
check(f"no Dutch row is the English one ({_same})", not _same)
|
||||
_bad = [(fn, p) for fn, c, t in ROWS if isinstance(t, messages.Bi) and t.nl
|
||||
for p in row_problems_nl(c, t.nl)]
|
||||
for x in _bad:
|
||||
print(f" STYLE {x}")
|
||||
check(f"every Dutch row follows STYLE.md ({len(_bad)} problems)", not _bad)
|
||||
BANNERS = [(fn, t) for fn, c, t in messages.status_samples() if c is None]
|
||||
_nob = [(fn, str(t)) for fn, t in BANNERS
|
||||
if not (isinstance(t, messages.Bi) and t.nl and t.nl != str(t))]
|
||||
check(f"every banner has a Dutch text ({_nob})", BANNERS and not _nob)
|
||||
_bb = [(fn, t.nl) for fn, t in BANNERS if isinstance(t, messages.Bi) and t.nl
|
||||
and (len(words(t.nl)) > 12 or ";" in t.nl or dutch_problems(t.nl)
|
||||
or text_problems(t.nl, keys=False, entities=False))]
|
||||
check(f"every Dutch banner is at most 12 words and follows STYLE.md ({_bb})",
|
||||
not _bb)
|
||||
_cat = {k for k, v in vars(messages).items()
|
||||
if callable(v) and k.startswith("row_")}
|
||||
check("the samples cover every row_ function, so none can skip the check",
|
||||
_cat <= {fn for fn, _c, _t in ROWS})
|
||||
_lat = {"row_direction_trip": "herstart heft dit niet op",
|
||||
"row_site_direction_trip": "regeling starten heft dit op",
|
||||
"row_circulating_trip": "regeling starten heft dit op",
|
||||
"row_not_delivering": "stoppen en starten heft dit op"}
|
||||
_unclear = [fn for fn, _c, t in ROWS if fn in _lat and _lat[fn] not in t.nl]
|
||||
check(f"a Dutch latch row says how it clears too ({_unclear})", not _unclear)
|
||||
check("pick() gives the English for en, the Dutch for nl, and leaves other "
|
||||
"text alone",
|
||||
messages.pick(messages.row_estop_fitted(), "en") == "Emergency stop: fitted"
|
||||
and type(messages.pick(messages.row_estop_fitted(), "en")) is str
|
||||
and messages.pick(messages.row_estop_fitted(), "nl") == "Noodstop: geplaatst"
|
||||
and messages.pick("Plain", "nl") == "Plain"
|
||||
and messages.pick(messages.Bi("A"), "nl") == "A")
|
||||
check("a Bi survives copy and pickle, and JSON",
|
||||
copy.deepcopy(messages.row_estop_fitted()).nl == "Noodstop: geplaatst"
|
||||
and json.dumps(messages.row_estop_fitted()) == '"Emergency stop: fitted"')
|
||||
|
||||
|
||||
# --- the page: a real Controller -------------------------------------------- #
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False, "peak_forecast_entity": "sensor.fc"}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500},
|
||||
{"name": "Battery B", "setpoint_entity": "", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500}])
|
||||
|
||||
|
||||
def make(opts, soc=(62.0, 35.0), batt=(-2500.0, -300.0)):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt = soc[i], batt[i]
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = 10.0
|
||||
return c
|
||||
|
||||
|
||||
def attention(payload, rid):
|
||||
return next((a for a in payload["attention"] if a["id"] == rid), None)
|
||||
|
||||
|
||||
c = make(ONE, soc=(62.0,), batt=(960.0,))
|
||||
c._peak_fc_fault = "kWh"
|
||||
s_en, s_nl = c.status_payload(), c.status_payload(lang="nl")
|
||||
def fc_row(payload):
|
||||
return nth([r for r in payload["checks"] if r.get("id") == "peak_forecast"])
|
||||
|
||||
|
||||
_en, _nl = fc_row(s_en), fc_row(s_nl)
|
||||
check("Home, English: the peak forecast row is the English sentence",
|
||||
present(_en) and field(_en, "text") == messages.peak_forecast_unit("kWh")
|
||||
and attention(s_en, "peak_forecast")["body"]
|
||||
== messages.peak_forecast_unit("kWh"))
|
||||
check("Home, Dutch: the row, the Needs-attention entry and the title are "
|
||||
"Dutch",
|
||||
present(_nl) and field(_nl, "text") == messages.peak_forecast_unit("kWh", "nl")
|
||||
and attention(s_nl, "peak_forecast")["body"]
|
||||
== messages.peak_forecast_unit("kWh", "nl")
|
||||
and attention(s_nl, "peak_forecast")["title"]
|
||||
== uitext.words("nl")["home_words"]["attn"]["peak_forecast"])
|
||||
c._peak_fc_fault = ""
|
||||
_nl0 = fc_row(c.status_payload(lang="nl"))
|
||||
check("...and with no unit at all, the Dutch is the 'no unit' sentence",
|
||||
present(_nl0) and field(_nl0, "text") == messages.peak_forecast_unit("", "nl")
|
||||
and messages.peak_forecast_unit("", "nl")
|
||||
!= messages.peak_forecast_unit("kWh", "nl"))
|
||||
check("a language that is not Dutch gets the English, and the default call "
|
||||
"is unchanged",
|
||||
c.status_payload(lang="fr")["checks"] == c.status_payload()["checks"]
|
||||
and all(type(r["text"]) is str for r in c.status_payload()["checks"]))
|
||||
|
||||
# A site with faults of many kinds: every row it shows is Dutch on the NL page.
|
||||
SCEN = {}
|
||||
SCEN["two, one without a setpoint, meter stale"] = make(TWO)
|
||||
SCEN["one, nothing read"] = make(ONE, soc=(None,), batt=(None,))
|
||||
SCEN["one, no forecast set"] = make(dict(ONE, peak_forecast_entity=""))
|
||||
SCEN["one, shadow, estop fitted"] = make(dict(ONE, shadow_mode="on",
|
||||
estop_fitted=True))
|
||||
SCEN["one, solar sources"] = make(dict(ONE, pv_entity="sensor.pv"))
|
||||
SCEN["two, solar list"] = make(dict(TWO, pv_entities=["sensor.a", "sensor.b"]))
|
||||
SCEN["one, peak forecast unit fault"] = c
|
||||
SCEN["one, control stopped"] = make(ONE)
|
||||
SCEN["one, control stopped"].auto = False
|
||||
_English, _rows_seen = [], 0
|
||||
for name, ctl in SCEN.items():
|
||||
raw = ctl.checks()
|
||||
en = ctl.status_payload()["checks"]
|
||||
nl = ctl.status_payload(lang="nl")["checks"]
|
||||
check(f"[{name}] {len(raw)} rows: same rows in both languages",
|
||||
len(raw) == len(en) == len(nl))
|
||||
for r, e, n in zip(raw, en, nl):
|
||||
_rows_seen += 1
|
||||
if not (isinstance(r["text"], messages.Bi) and r["text"].nl):
|
||||
_English.append((name, r.get("id"), str(r["text"])))
|
||||
elif e["text"] != str(r["text"]) or n["text"] != r["text"].nl:
|
||||
_English.append((name, r.get("id"), "page did not pick"))
|
||||
_banners = {n: (ctl.status_payload()["banner"],
|
||||
ctl.status_payload(lang="nl")["banner"])
|
||||
for n, ctl in SCEN.items()}
|
||||
print(f" banners: {_banners}")
|
||||
check("the banner at the top of Home is Dutch on the Dutch page, English on "
|
||||
"the English one", all(e != n and not dutch_problems(n)
|
||||
for e, n in _banners.values()))
|
||||
check(f"{_rows_seen} rows over {len(SCEN)} sites: none shows English on the "
|
||||
f"Dutch page when it has no Dutch text ({_English})", not _English)
|
||||
check("the row's details list in /status is picked the same way",
|
||||
all(isinstance(v, str) and not isinstance(v, messages.Bi)
|
||||
for ctl in SCEN.values()
|
||||
for _k, v in ctl.status_payload(lang="nl")["rows"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. every settings note and wizard line has a Dutch twin")
|
||||
# --------------------------------------------------------------------------- #
|
||||
tree = ast.parse((HERE / "app" / "validate.py").read_text(encoding="utf-8"))
|
||||
_calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call)
|
||||
and ast.unparse(n.func) == "_advise"]
|
||||
_bare = [n.lineno for n in _calls if not any(k.arg == "nl" for k in n.keywords)]
|
||||
check(f"every _advise() call in validate.py gives `nl` ({len(_calls)} calls, "
|
||||
f"without: {_bare})", len(_calls) >= 30 and not _bare)
|
||||
|
||||
OPTS = dict(D.defaults(D.CONFIG), setpoint_entity="number.sp",
|
||||
meter_entity="sensor.grid", soc_entity="sensor.soc",
|
||||
batt_entity="sensor.batt", pv_entity="sensor.pv",
|
||||
peak_forecast_entity="sensor.fc")
|
||||
|
||||
|
||||
def ent(state, unit=None):
|
||||
return {"state": state, "unit": unit}
|
||||
|
||||
|
||||
GOOD = {"number.sp": ent("0"), "sensor.grid": ent("402", "W"),
|
||||
"sensor.soc": ent("62", "%"), "sensor.batt": ent("-800", "W"),
|
||||
"sensor.pv": ent("-1200", "W"), "sensor.fc": ent("820", "kWh")}
|
||||
NOTES = []
|
||||
NOTES += setup_checks(OPTS, entities=GOOD, running=None)
|
||||
NOTES += setup_checks(OPTS, entities=None)
|
||||
NOTES += setup_checks(dict(OPTS, meter_invert=True, pv_invert_entities=["sensor.pv"]),
|
||||
entities=dict(GOOD, **{"sensor.batt": ent("0", "W")}))
|
||||
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.x", batt_entity="sensor.y",
|
||||
setpoint_entity="number.typo"),
|
||||
entities=GOOD)
|
||||
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local"),
|
||||
entities=GOOD, running=None)
|
||||
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local",
|
||||
price_provider="generic_dayahead"),
|
||||
entities=GOOD, running=dict(OPTS, meter_source="homewizard_local",
|
||||
price_provider="generic_dayahead"),
|
||||
meter_now_w=-350.0, prices_known=18)
|
||||
NOTES += setup_checks(dict(OPTS, price_provider="generic_dayahead"), entities=GOOD,
|
||||
running=None)
|
||||
NOTES += setup_checks(dict(OPTS, price_provider="off", static_profile=""),
|
||||
entities=GOOD)
|
||||
NOTES += setup_checks(dict(OPTS, price_provider="off",
|
||||
static_profile='{"periods":[]}'), entities=GOOD)
|
||||
NOTES += setup_checks(dict(OPTS, batt_entity="sensor.batt2"),
|
||||
entities=dict(GOOD, **{"sensor.batt2": ent("unavailable")}))
|
||||
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.soc2"),
|
||||
entities=dict(GOOD, **{"sensor.soc2": ent("unavailable")}))
|
||||
NOTES += setup_checks(dict(OPTS, meter_entity="sensor.m2"),
|
||||
entities=dict(GOOD, **{"sensor.m2": ent("unavailable")}))
|
||||
_TWO = dict(OPTS, inverters=[
|
||||
{"name": "a", "setpoint_entity": "number.sp", "soc_entity": "sensor.soc",
|
||||
"batt_entity": "sensor.batt", "max_w": 2000, "bms_soc_floor": 10},
|
||||
{"name": "b", "setpoint_entity": "number.b", "max_w": 2000,
|
||||
"bms_soc_floor": 10}])
|
||||
NOTES += setup_checks(_TWO, entities=dict(GOOD, **{"sensor.batt": ent("-0.5", "kW")}))
|
||||
NOTES += setup_checks(dict(OPTS, meter_source="ha_signed",
|
||||
p1_net_entity="sensor.net"),
|
||||
entities=dict(GOOD, **{"sensor.net": ent("-3.1", "kW")}))
|
||||
NOTES += config_notes(dict(OPTS, integrator_max_w=9000, max_w=2000,
|
||||
peak_cap_w=2000), entities=GOOD, month_peak_w=3000)
|
||||
NOTES += config_notes(dict(OPTS, pv_forecast_entity="sensor.f1",
|
||||
pv_forecast_tomorrow_entity="sensor.f2",
|
||||
peak_forecast_entity="sensor.fc2"),
|
||||
entities=dict(GOOD, **{"sensor.f1": ent("5", "W"),
|
||||
"sensor.f2": ent("6", "kWh"),
|
||||
"sensor.fc2": ent("5")}))
|
||||
NOTES += config_notes(dict(OPTS, meter_entity="sensor.gone"), entities=GOOD)
|
||||
NOTES += config_notes(dict(OPTS, pv_forecast_tomorrow_entity="sensor.f2"),
|
||||
entities=dict(GOOD, **{"sensor.f2": ent("6", "kWh")}))
|
||||
NOTES += config_notes(dict(OPTS, batt_entity="sensor.kw"),
|
||||
entities=dict(GOOD, **{"sensor.kw": ent("1", "kW")}))
|
||||
print(f" {len(NOTES)} notes built")
|
||||
_nonl = [n.text for n in NOTES if not n.nl]
|
||||
check(f"every note the validator builds has a Dutch twin ({_nonl})",
|
||||
NOTES and not _nonl)
|
||||
_same = [n.text for n in NOTES if n.nl == n.text]
|
||||
check(f"...which is not the English ({_same})", not _same)
|
||||
_bad = []
|
||||
for n in NOTES:
|
||||
nw, ns = len(words(n.nl)), sentences(n.nl)
|
||||
p = text_problems(n.nl, keys=True, entities=True) + dutch_problems(n.nl)
|
||||
if nw > 25 and not n.text.startswith(("The 15-minute", "Home Assistant")):
|
||||
p.append(f"over 25 words ({nw})")
|
||||
if ns > 2:
|
||||
p.append(f"over 2 sentences ({ns})")
|
||||
if ";" in n.nl:
|
||||
p.append("semicolon")
|
||||
if len(words(n.nl)) > len(words(n.text)) + 6:
|
||||
p.append("much longer than the English")
|
||||
_bad += [(n.nl, x) for x in p]
|
||||
for x in _bad:
|
||||
print(f" STYLE {x}")
|
||||
check(f"every Dutch note follows STYLE.md ({len(_bad)} problems)", not _bad)
|
||||
_l = {t: [n for n in NOTES if n.text.startswith(t)] for t in (
|
||||
"Prices for today are in", "The 15-minute demand forecast")}
|
||||
check("the wizard's price line, and the peak forecast unit note, are among "
|
||||
"them", all(_l.values()))
|
||||
check("note_text() picks: Dutch for nl, English for en, the old twin table "
|
||||
"for a note with no `nl`",
|
||||
note_text(_l["Prices for today are in"][0], "nl")
|
||||
== "De prijzen van vandaag zijn binnen, voor 18 van 24 uur."
|
||||
and note_text(_l["Prices for today are in"][0], "en")
|
||||
== "Prices for today are in, for 18 of 24 hours."
|
||||
and note_text(Note("info", (), messages.FIXED_TARIFF_NOTE), "nl")
|
||||
== messages.FIXED_TARIFF_NOTE_NL
|
||||
and note_text(Note("info", (), "Other"), "nl") == "Other")
|
||||
|
||||
# --- over HTTP: the settings page and the wizard, in both languages --------- #
|
||||
SCHEMA = D.schema_ui(D.CONFIG)
|
||||
TRANSLATIONS = {l: TL.parse_translation_file(HERE / "translations" / f"{l}.yaml")
|
||||
for l in ("en", "nl")}
|
||||
STATES = [{"entity_id": e, "state": v["state"],
|
||||
"attributes": ({"unit_of_measurement": v["unit"]} if v["unit"]
|
||||
else {})} for e, v in GOOD.items()]
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def states(self):
|
||||
return copy.deepcopy(STATES)
|
||||
|
||||
async def state(self, entity):
|
||||
return next((copy.deepcopy(s) for s in STATES
|
||||
if s["entity_id"] == entity), None)
|
||||
|
||||
|
||||
class Supervisor:
|
||||
def __init__(self, options):
|
||||
self.options = copy.deepcopy(options)
|
||||
|
||||
async def info(self):
|
||||
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
|
||||
"translations": TRANSLATIONS}
|
||||
|
||||
async def write_options(self, options):
|
||||
pass
|
||||
|
||||
async def restart(self, confirm):
|
||||
pass
|
||||
|
||||
|
||||
class Ctl:
|
||||
def __init__(self, opts):
|
||||
self.o, self.hass, self.inverters = dict(opts), Hass(), []
|
||||
self.store, self.auto, self.grid = None, True, 402.0
|
||||
|
||||
def log_event(self, text, hint=None):
|
||||
pass
|
||||
|
||||
def status_payload(self, lang="en"):
|
||||
return {"banner": "", "level": "ok", "rows": [], "checks": [],
|
||||
"plan": {"prices": [{"label": f"{h:02d}:00", "eur_kwh": 0.2}
|
||||
for h in range(20)]
|
||||
+ [{"label": f"{h:02d}:00", "eur_kwh": None}
|
||||
for h in range(20, 24)]}}
|
||||
|
||||
|
||||
async def serve(opts, steps):
|
||||
runner = aioweb.AppRunner(web.build_app(Ctl(opts), Supervisor(opts)))
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
return await steps(s, root)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
async def post(s, url, body):
|
||||
async with s.post(url, json=body) as r:
|
||||
return await r.json()
|
||||
|
||||
|
||||
RUN = dict(OPTS, meter_source="homewizard_local", p1_host="192.0.2.50",
|
||||
price_provider="generic_dayahead")
|
||||
|
||||
|
||||
async def pages(s, root):
|
||||
out = {}
|
||||
# The settings notes read the entity list the picker has cached, so the
|
||||
# first call only warms it.
|
||||
await post(s, root + "config/validate",
|
||||
{"model": U.options_to_ui(RUN), "setup": True})
|
||||
for lang in ("en", "nl"):
|
||||
async with s.get(root + f"config/model?lang={lang}") as r:
|
||||
m = await r.json()
|
||||
model = U.options_to_ui(RUN)
|
||||
out[lang] = await post(s, root + "config/validate",
|
||||
{"model": model, "setup": True, "lang": lang})
|
||||
out["none"] = await post(s, root + "config/validate", {
|
||||
"model": U.options_to_ui(RUN), "setup": True})
|
||||
return out
|
||||
|
||||
|
||||
R = asyncio.run(serve(RUN, pages))
|
||||
_texts = {l: [m["text"] for m in R[l].get("messages", [])] for l in ("en", "nl")}
|
||||
_setup = {l: [m["text"] for m in R[l].get("setup", [])] for l in ("en", "nl")}
|
||||
FC_EN = messages.peak_forecast_unit("kWh") + " Pick a sensor in W or kW."
|
||||
FC_NL = messages.peak_forecast_unit("kWh", "nl") + " Kies een sensor in W of kW."
|
||||
print(f" settings notes en: {_texts['en']}")
|
||||
print(f" settings notes nl: {_texts['nl']}")
|
||||
print(f" wizard lines nl: {_setup['nl']}")
|
||||
check("settings page, English: the peak forecast unit note is English",
|
||||
FC_EN in _texts["en"])
|
||||
check("settings page, Dutch: it is the Dutch sentence, and no English "
|
||||
"sentence of it is left", FC_NL in _texts["nl"]
|
||||
and FC_EN not in _texts["nl"])
|
||||
check("wizard, English: the price line and the checks are English",
|
||||
"Prices for today are in, for 20 of 24 hours." in _setup["en"])
|
||||
check("wizard, Dutch: the price line is Dutch",
|
||||
"De prijzen van vandaag zijn binnen, voor 20 van 24 uur."
|
||||
in _setup["nl"] and "Prices for today are in, for 20 of 24 hours."
|
||||
not in _setup["nl"])
|
||||
_left = [t for t in _setup["nl"] + _texts["nl"]
|
||||
if ENGLISH.search(QUOTED.sub("X", t))
|
||||
and not t.startswith("Home Assistant has no entity")]
|
||||
check(f"no wizard line or settings note on the Dutch page has English words "
|
||||
f"left ({_left})", not _left)
|
||||
check("a request with no language gets English (an old page keeps working)",
|
||||
[m["text"] for m in R["none"].get("setup", [])]
|
||||
== [m["text"] for m in R["en"].get("setup", [])])
|
||||
check("the raw `notes` list keeps the English `text` and adds `nl`",
|
||||
R["nl"]["notes"] and all(n["text"] != n["nl"] for n in R["nl"]["notes"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. labels")
|
||||
# --------------------------------------------------------------------------- #
|
||||
for lang in ("en", "nl"):
|
||||
name = TRANSLATIONS[lang]["configuration"]["peak_forecast_entity"]["name"]
|
||||
check(f"[{lang}] the peak forecast label has no unit: {name!r}",
|
||||
"(W)" not in name and "(kW)" not in name
|
||||
and name.split("·")[-1].strip() == (
|
||||
"15-minute demand forecast entity" if lang == "en"
|
||||
else "Entiteit kwartierprognose"))
|
||||
check("DOCS.md shows the same label, so the table cannot drift",
|
||||
"| Limits | 15-minute demand forecast entity | `peak_forecast_entity` |"
|
||||
in (HERE / "DOCS.md").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def stems(key):
|
||||
tr = TRANSLATIONS["nl"]["configuration"]
|
||||
out = []
|
||||
for c in (tr, tr["inverters"]["fields"]):
|
||||
if isinstance(c.get(key), dict):
|
||||
n = c[key]["name"].split("·")[-1].strip()
|
||||
while re.search(r"\s*\([^()]*\)$", n):
|
||||
n = re.sub(r"\s*\([^()]*\)$", "", n)
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
_drift = sorted(k for k, v in messages.LABELS_NL.items() if v not in stems(k))
|
||||
check(f"every Dutch label a note quotes is the setting's name in nl.yaml "
|
||||
f"(drifted: {_drift})", not _drift
|
||||
and set(messages.LABELS_NL) == set(messages.LABELS))
|
||||
_unit = sorted(k for k, v in messages.UNIT_LABELS_NL.items() if v not in stems(k))
|
||||
check(f"...and so are the two the inverter list names differently ({_unit})",
|
||||
not _unit)
|
||||
check("setting() in Dutch keeps the key, so the config panel can find it",
|
||||
messages.setting("max_w", "", "nl") == "“Vermogen van de omvormer” (max_w)"
|
||||
and messages.setting("max_w", "inverter-b", "nl")
|
||||
== "“Vermogen van deze omvormer” voor inverter-b (inverters.max_w)"
|
||||
and messages.setting("max_w") == "“Inverter rating” (max_w)")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. the rules catch spoiled input")
|
||||
# --------------------------------------------------------------------------- #
|
||||
check("a Dutch row left in English is caught",
|
||||
row_problems_nl(messages.AMBER, "Control: last ran 2 seconds ago"))
|
||||
check("an over-long Dutch row is caught",
|
||||
row_problems_nl(messages.AMBER, " ".join(["woord"] * 16) + "."))
|
||||
check("Dutch jargon is caught", dutch_problems("Het commando is geweigerd"))
|
||||
check("a row with no Dutch is caught",
|
||||
not isinstance(messages.pick("x", "nl"), messages.Bi)
|
||||
and not getattr(messages.Bi("x"), "nl"))
|
||||
check("the untranslated-call scan sees a bare _advise",
|
||||
any(isinstance(n, ast.Call) and not any(k.arg == "nl" for k in n.keywords)
|
||||
for n in ast.walk(ast.parse('_advise(notes, "warn", (), "x")'))))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -164,7 +164,7 @@ configuration:
|
||||
name: "Limits · Margin below the peak limit (W)"
|
||||
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
|
||||
peak_forecast_entity:
|
||||
name: "Limits · 15-minute demand forecast entity (W)"
|
||||
name: "Limits · 15-minute demand forecast entity"
|
||||
description: "Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit."
|
||||
peak_soc_floor:
|
||||
name: "Limits · Peak reserve (%)"
|
||||
|
||||
@@ -145,7 +145,7 @@ configuration:
|
||||
name: "Grenzen · Marge onder de piekgrens (W)"
|
||||
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
|
||||
peak_forecast_entity:
|
||||
name: "Grenzen · Entiteit kwartierprognose (W)"
|
||||
name: "Grenzen · Entiteit kwartierprognose"
|
||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier, in W of kW. Zonder vermogenseenheid wordt ze niet gebruikt. Laat u dit leeg, dan is er geen piekgrens."
|
||||
peak_soc_floor:
|
||||
name: "Grenzen · Piekreserve (%)"
|
||||
|
||||
Reference in New Issue
Block a user