Compare commits
2
Commits
e077ae7f97
...
c1809b4ca9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1809b4ca9 | ||
|
|
7a392a5367 |
@@ -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 | 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 | 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 | 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. |
|
| 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 | 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. |
|
| 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. |
|
||||||
|
|||||||
@@ -7464,7 +7464,8 @@ class Controller:
|
|||||||
level, banner = "warn", messages.banner_stopped()
|
level, banner = "warn", messages.banner_stopped()
|
||||||
elif runs:
|
elif runs:
|
||||||
level, running_alarm = "bad", True
|
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):
|
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
# ⚠️ 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.
|
# 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
|
# character; at two, "Maintenance: drain" without a name is a
|
||||||
# banner an operator cannot act on, because the remedy is at one
|
# banner an operator cannot act on, because the remedy is at one
|
||||||
# device and the site has two.
|
# device and the site has two.
|
||||||
level, banner = "warn", f"Maintenance: {self.maint.phase}"
|
level, banner = "warn", messages.banner_maintenance(
|
||||||
if len(self.inverters) > 1:
|
self.maint.phase,
|
||||||
banner += f" ({self.maint_unit.name})"
|
self.maint_unit.name if len(self.inverters) > 1 else "")
|
||||||
else:
|
else:
|
||||||
level, banner = "ok", "Running — holding grid at zero"
|
level, banner = "ok", messages.banner_running()
|
||||||
|
|
||||||
def w(v):
|
def w(v):
|
||||||
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
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)),
|
now=datetime.now(timezone.utc), why=self.why_words)),
|
||||||
]
|
]
|
||||||
held = level == "bad" and not running_alarm
|
held = level == "bad" and not running_alarm
|
||||||
|
# UX-46. The rows were built in English with their Dutch beside them
|
||||||
|
# (`messages.Bi`); the page's language is chosen once, here, for the
|
||||||
|
# Home sentences, the checks list and the rows that carry one. The
|
||||||
|
# banner above stays English: it is the plain fallback.
|
||||||
|
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
|
||||||
|
rows = [(k, messages.pick(v, lang)) for k, v in rows]
|
||||||
|
banner = messages.pick(banner, lang)
|
||||||
out = {
|
out = {
|
||||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||||
"hint": self.hint,
|
"hint": self.hint,
|
||||||
|
|||||||
+501
-147
File diff suppressed because it is too large
Load Diff
@@ -54,10 +54,12 @@ class SetupOnly:
|
|||||||
|
|
||||||
def status_payload(self, lang: str = "en") -> dict:
|
def status_payload(self, lang: str = "en") -> dict:
|
||||||
return {"setup_only": True, "mode": "setup_only",
|
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"]],
|
"rows": [["Control", "not started, in setup"]],
|
||||||
"checks": [{"ok": False, "warn": True,
|
"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)}
|
"hint": self.hint, "events": list(self.events)}
|
||||||
|
|
||||||
async def handle_action(self, what):
|
async def handle_action(self, what):
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from . import uilayout as L
|
|||||||
from . import uitext
|
from . import uitext
|
||||||
from . import uimodel as U
|
from . import uimodel as U
|
||||||
from .configui import help_of, label_of, problems_by_field, split_group
|
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
|
# 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
|
# 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)
|
add(None, None, "bad", text)
|
||||||
for note in notes:
|
for note in notes:
|
||||||
tier = note.tier if note.tier in _TIERS else "info"
|
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 key in note.keys or (None,):
|
||||||
for field, card in (targets(key, note.text, model) if key
|
for field, card in (targets(key, note.text, model) if key
|
||||||
else [(None, None)]):
|
else [(None, None)]):
|
||||||
|
|||||||
@@ -386,12 +386,23 @@ class Note(NamedTuple):
|
|||||||
# also "ok" and "bad" (setup_checks)
|
# also "ok" and "bad" (setup_checks)
|
||||||
keys: tuple
|
keys: tuple
|
||||||
text: str
|
text: str
|
||||||
|
nl: str = "" # UX-46: the same sentence in Dutch
|
||||||
|
|
||||||
|
|
||||||
def _advise(notes, tier: str, keys, text: str) -> None:
|
def _advise(notes, tier: str, keys, text: str, nl: str = "") -> None:
|
||||||
"""Add a Note when the caller collects them (the settings page)."""
|
"""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:
|
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
|
# ⚠️ 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("()"))
|
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:
|
def _labels(keys, joiner: str = "and") -> str:
|
||||||
"""Several settings, each with its key: the config panel files a refusal
|
"""Several settings, each with its key: the config panel files a refusal
|
||||||
under every key it names (`configui.problems_by_field()`)."""
|
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)
|
integ_w, max_w)
|
||||||
_advise(notes, "warn", ("integrator_max_w",),
|
_advise(notes, "warn", ("integrator_max_w",),
|
||||||
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
|
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
|
# ⚠️ 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
|
# 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",),
|
_advise(notes, "warn", ("maintenance_discharge_w",),
|
||||||
f"{S('maintenance_discharge_w')} is above "
|
f"{S('maintenance_discharge_w')} is above "
|
||||||
f"{S('max_w', maint_where)}, so the battery empties at "
|
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(
|
_LOG.debug(
|
||||||
"Not refused: the drain phase sets the setpoint, the arbiter "
|
"Not refused: the drain phase sets the setpoint, the arbiter "
|
||||||
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
"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)
|
TAXES_PROFILE_OPTION, negative)
|
||||||
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
|
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
|
||||||
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
|
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
|
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||||
# STARTUP RATHER THAN AT 03:00.
|
# 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"),
|
_advise(notes, "warn", ("price_formula", "price_provider"),
|
||||||
f"{S('price_formula')} is not used, because "
|
f"{S('price_formula')} is not used, because "
|
||||||
f"{S('price_provider')} is not 'custom_formula', so clear "
|
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":
|
if spec is not None and pid == "custom_formula":
|
||||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||||
PRICE_OPTION_DEFAULTS["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.
|
# cannot start.
|
||||||
_LOG.warning("%s", aged)
|
_LOG.warning("%s", aged)
|
||||||
_advise(notes, "warn", ("price_formula",),
|
_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.
|
# ⚠️ 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)
|
xspec.offset_eur_kwh)
|
||||||
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
|
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
|
||||||
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
|
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(
|
_LOG.debug(
|
||||||
"Accepted because some suppliers (Eneco) print the "
|
"Accepted because some suppliers (Eneco) print the "
|
||||||
"injection formula with no price beside it. If your card "
|
"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:
|
if xaged:
|
||||||
_LOG.warning("%s", xaged)
|
_LOG.warning("%s", xaged)
|
||||||
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
|
_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
|
# ⚠️ 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
|
# 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)
|
"fallback.", pid)
|
||||||
_advise(notes, "warn", ("static_profile",),
|
_advise(notes, "warn", ("static_profile",),
|
||||||
f"{S('static_profile')} is empty, so the site has no tariff "
|
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
|
# 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
|
# 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.
|
# 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():
|
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
|
# ⚠️ 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
|
# 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
|
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 "
|
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
|
||||||
"the new figures if your contract changed.")
|
"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:
|
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||||
_advise(notes, "warn", ("peak_cap_w",),
|
_advise(notes, "warn", ("peak_cap_w",),
|
||||||
f"This month's peak is already {messages.watts(month_peak_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
|
return notes
|
||||||
|
|
||||||
|
|
||||||
@@ -1823,31 +1864,43 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
|||||||
if row is None:
|
if row is None:
|
||||||
_advise(notes, "bad" if key in bad else "warn", (key,),
|
_advise(notes, "bad" if key in bad else "warn", (key,),
|
||||||
f"Home Assistant has no entity {entity}, which "
|
f"Home Assistant has no entity {entity}, which "
|
||||||
f"{S(key, where)} names.")
|
f"{S(key, where)} names.",
|
||||||
|
nl=f"Home Assistant heeft geen entiteit {entity}, terwijl "
|
||||||
|
f"{SN(key, where)} die noemt.")
|
||||||
continue
|
continue
|
||||||
unit = str(row.get("unit") or "")
|
unit = str(row.get("unit") or "")
|
||||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||||
_advise(notes, "warn", (key,),
|
_advise(notes, "warn", (key,),
|
||||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
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:
|
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
|
||||||
_advise(notes, "warn", (key,),
|
_advise(notes, "warn", (key,),
|
||||||
messages.peak_forecast_unit(unit) + " Pick a sensor in "
|
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:
|
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
|
||||||
_advise(notes, "warn", (key,),
|
_advise(notes, "warn", (key,),
|
||||||
f"{entity} does not read in kWh, so pick a solar "
|
f"{entity} does not read in kWh, so pick a solar "
|
||||||
"forecast sensor in kWh.")
|
"forecast sensor in kWh.",
|
||||||
|
nl=f"{entity} meldt niet in kWh, dus kies een sensor voor "
|
||||||
|
"de zonneprognose in kWh.")
|
||||||
today = solarfc.entities(opts)
|
today = solarfc.entities(opts)
|
||||||
tomorrow = solarfc.entities(opts, tomorrow=True)
|
tomorrow = solarfc.entities(opts, tomorrow=True)
|
||||||
if (today or tomorrow) and \
|
if (today or tomorrow) and \
|
||||||
opts.get("battery_modes_enabled") is not True:
|
opts.get("battery_modes_enabled") is not True:
|
||||||
_advise(notes, "info", ("pv_forecast_entity",),
|
_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:
|
if tomorrow and not today:
|
||||||
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
||||||
"Tomorrow's forecast is used only with today's, so set "
|
"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:
|
if entities is None:
|
||||||
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
||||||
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
_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:
|
else:
|
||||||
# A missing meter entity is red too (UX-15): with the kettle test
|
# 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.
|
# 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)
|
solar.append(None)
|
||||||
_advise(notes, "warn", ("pv_invert_entities",),
|
_advise(notes, "warn", ("pv_invert_entities",),
|
||||||
f"{entity} reads below zero now, so turn on its "
|
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:
|
else:
|
||||||
w = -w if entity in flipped else w
|
w = -w if entity in flipped else w
|
||||||
solar.append(w)
|
solar.append(w)
|
||||||
_advise(notes, "ok", ("pv_entity",),
|
_advise(notes, "ok", ("pv_entity",),
|
||||||
f"{entity} reads {_signed(w)} of solar production "
|
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
|
# 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
|
# house use less than nothing. Only with every term known: a missing
|
||||||
# solar reading could be the difference.
|
# 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",),
|
_advise(notes, "warn", ("batt_invert",),
|
||||||
f"The readings add up to {_signed(sum(known))} of house "
|
f"The readings add up to {_signed(sum(known))} of house "
|
||||||
"use, so check each "
|
"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)
|
_price_check(notes, opts, running, prices_known)
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
@@ -1988,7 +2049,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
|||||||
return float(meter_now_w)
|
return float(meter_now_w)
|
||||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||||
"this meter itself, so Home shows its reading after "
|
"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
|
return None
|
||||||
if entities is None:
|
if entities is None:
|
||||||
return None
|
return None
|
||||||
@@ -2001,7 +2064,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
|||||||
if w is None:
|
if w is None:
|
||||||
_advise(notes, "warn", ("meter_source",),
|
_advise(notes, "warn", ("meter_source",),
|
||||||
f"{entity} reads {text} now, so check it in Home "
|
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
|
return None
|
||||||
total += sign * w
|
total += sign * w
|
||||||
if source == "off" and opts.get("meter_invert"):
|
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:
|
def _meter_line(notes, w) -> None:
|
||||||
if abs(w) < 1.0:
|
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:
|
elif w > 0:
|
||||||
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
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:
|
else:
|
||||||
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
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):
|
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."""
|
positive while giving power, or None when it is not known."""
|
||||||
if entities is None:
|
if entities is None:
|
||||||
return None
|
return None
|
||||||
|
wie = who.replace(" for ", " voor ", 1)
|
||||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||||
if setpoint and setpoint in entities:
|
if setpoint and setpoint in entities:
|
||||||
_advise(notes, "ok", ("setpoint_entity",),
|
_advise(notes, "ok", ("setpoint_entity",),
|
||||||
f"Home Assistant has {setpoint}, the power request "
|
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()
|
soc = str(entry.get("soc_entity") or "").strip()
|
||||||
if not soc:
|
if not soc:
|
||||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
||||||
f"level entity{who}, so pick one in step 2 or the add-on "
|
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:
|
elif soc in entities:
|
||||||
level, _unit = _reading(entities, soc)
|
level, _unit = _reading(entities, soc)
|
||||||
if level is None or not 0.0 <= level <= 100.0:
|
if level is None or not 0.0 <= level <= 100.0:
|
||||||
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
_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:
|
else:
|
||||||
_advise(notes, "ok", ("soc_entity",),
|
_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()
|
batt = str(entry.get("batt_entity") or "").strip()
|
||||||
if batt and batt in entities:
|
if batt and batt in entities:
|
||||||
w, _unit = _reading(entities, batt)
|
w, _unit = _reading(entities, batt)
|
||||||
if w is None:
|
if w is None:
|
||||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
_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
|
return None
|
||||||
w = -w if entry.get("batt_invert") else w
|
w = -w if entry.get("batt_invert") else w
|
||||||
if abs(w) < IDLE_W:
|
if abs(w) < IDLE_W:
|
||||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
_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:
|
else:
|
||||||
doing = "giving power" if w > 0 else "charging"
|
doing = "giving power" if w > 0 else "charging"
|
||||||
|
doet = "levert" if w > 0 else "laadt"
|
||||||
_advise(notes, "info", ("batt_invert",),
|
_advise(notes, "info", ("batt_invert",),
|
||||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
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 w
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -2070,20 +2152,28 @@ def _price_check(notes, opts, running, prices_known) -> None:
|
|||||||
if profile:
|
if profile:
|
||||||
_advise(notes, "ok", ("static_profile",),
|
_advise(notes, "ok", ("static_profile",),
|
||||||
"Your prices come from your contract, so they are "
|
"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:
|
else:
|
||||||
_advise(notes, "info", ("price_provider",),
|
_advise(notes, "info", ("price_provider",),
|
||||||
"No prices are set, so the batteries do not charge "
|
"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
|
return
|
||||||
same = (isinstance(running, dict)
|
same = (isinstance(running, dict)
|
||||||
and str(running.get("price_provider") or "") == provider)
|
and str(running.get("price_provider") or "") == provider)
|
||||||
if same and prices_known:
|
if same and prices_known:
|
||||||
_advise(notes, "ok", ("price_provider",),
|
_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:
|
else:
|
||||||
_advise(notes, "info", ("price_provider",),
|
_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]:
|
def _margin_problems(opts: dict) -> list[str]:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ from aiohttp import web
|
|||||||
from . import messages, price, selfconfig, uipage, uitext
|
from . import messages, price, selfconfig, uipage, uitext
|
||||||
from .haentities import EntityCache
|
from .haentities import EntityCache
|
||||||
from .homeview import priced_hours, region_of
|
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 .config_page import CONFIG_PAGE
|
||||||
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
||||||
problems_by_field, secret_keys)
|
problems_by_field, secret_keys)
|
||||||
@@ -105,6 +105,9 @@ def page() -> str:
|
|||||||
DEVICE_NOTE = (
|
DEVICE_NOTE = (
|
||||||
"%s did not report its power range, so two checks were skipped, here and "
|
"%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.")
|
"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 dan niet.")
|
||||||
|
|
||||||
|
|
||||||
def build_app(controller, selfcfg=None) -> web.Application:
|
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.
|
# 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.
|
# The entity checks use the picker's cached list and never wait on HA.
|
||||||
notes += config_notes(options, entities=entities.peek())
|
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,
|
payload = {"problems": problems, "by_key": by_key,
|
||||||
"unanchored": unanchored,
|
"unanchored": unanchored,
|
||||||
"notes": [n._asdict() for n in notes]}
|
"notes": [n._asdict() for n in notes]}
|
||||||
if unread:
|
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:
|
if model is not None:
|
||||||
payload["messages"] = (
|
payload["messages"] = (
|
||||||
uipage.derive_messages(errors, problems, notes, model,
|
uipage.derive_messages(errors, problems, notes, model,
|
||||||
_keys_of(options),
|
_keys_of(options), lang)
|
||||||
uitext.lang_of(_body.get("lang")))
|
|
||||||
if errors
|
if errors
|
||||||
else uipage.anchors(problems, notes, model, _keys_of(options),
|
else uipage.anchors(problems, notes, model, _keys_of(options),
|
||||||
uitext.lang_of(_body.get("lang"))))
|
lang))
|
||||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||||
if _body.get("setup"):
|
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)
|
return web.json_response(payload)
|
||||||
|
|
||||||
async def _setup_checks(options):
|
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)"
|
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."
|
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:
|
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."
|
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:
|
peak_soc_floor:
|
||||||
name: "Limits · Peak reserve (%)"
|
name: "Limits · Peak reserve (%)"
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ configuration:
|
|||||||
name: "Grenzen · Marge onder de piekgrens (W)"
|
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."
|
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
|
||||||
peak_forecast_entity:
|
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."
|
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:
|
peak_soc_floor:
|
||||||
name: "Grenzen · Piekreserve (%)"
|
name: "Grenzen · Piekreserve (%)"
|
||||||
|
|||||||
Reference in New Issue
Block a user