Files
Claude (SAFETY-125 review) 6eebd4882b
checks / unit (pull_request) Successful in 56m45s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-125 review: guard row and Home line within UX-08 limits; N=1 maintenance named
- The over-the-limit rows ran over STYLE's 15 words (EN 16/19, NL 16/21)
  and two Dutch Home sentences over 12 words; they were not in the samples,
  so test_messages/test_ux46 never saw them. Now:
  "Peak guard: grid charge limited, at this rate 5 814 W, over 5 413 W" /
  "Piekbewaking: laden beperkt, aan dit tempo 5 814 W, boven 5 413 W",
  "...idle, at this rate ..., over ..." / "...in rust, aan dit tempo ...",
  NL Home "Laadt minder van het net om onder 5,4 kW te blijven." and
  "Zou minder van het net laden om onder 5,4 kW te blijven."
  The rows and the line are added to the samples. The under row is unchanged.
- CHANGELOG known issues and the DECISIONS note: at N=1 the maintenance
  charge is not sized to the room, so SAFETY-99 still applies to it.
- DOCS: the peak guard row's new words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:56:01 +01:00

1796 lines
79 KiB
Python

"""Every Home Assistant notification this add-on raises, in plain words (UX-08).
The rule is `STYLE.md` at the repository root; `test_messages.py` enforces it.
Titles, bodies and severities live here and nowhere else, so `main.py` only
decides WHEN to raise a notice and this file decides WHAT it says. The detail
that used to fill the bodies is in DOCS.md, under "Notifications", one section
per title; the body ends with a link to it.
"""
import os
import re
from dataclasses import dataclass
from datetime import datetime
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
# that no action a person must take is lost; everything else gets one.
ALARM, WARNING, INFO, RESOLVED = "alarm", "warning", "info", "resolved"
DOCS_SECTION = "Notifications"
def watts(value) -> str:
"""`7412` -> `7 412 W`: a magnitude, grouped for reading, no sign."""
return f"{abs(float(value)):,.0f} W".replace(",", " ")
def duration(seconds) -> str:
s = float(seconds)
if s < 120:
return f"{s:.0f} seconds"
return f"{s / 60:.0f} minutes"
class Bi(str):
"""UX-46. A status row in both languages. It is its English text, so every
caller and every log line sees English, and `.nl` is the Dutch. The Home
page picks one with `pick()` where it knows the page's language."""
def __new__(cls, en, nl=None):
out = super().__new__(cls, en)
out.nl = nl
return out
def __getnewargs__(self):
return (str(self), self.nl)
def pick(text, lang="en"):
"""`text` in `lang`. A `Bi` gives its Dutch for "nl"; any other text, and
any language but "nl", gives the text as it is."""
if lang == "nl" and isinstance(text, Bi) and text.nl:
return str(text.nl)
return str(text) if isinstance(text, Bi) else text
def seconden(value) -> str:
n = f"{float(value):.0f}"
return f"{n} seconde" if n == "1" else f"{n} seconden"
def duur(value) -> str:
s = float(value)
return f"{s:.0f} seconden" if s < 120 else f"{s / 60:.0f} minuten"
def keer(n) -> str:
return f"{int(n)} keer"
def voor(unit) -> str:
"""` voor inverter-b` on a site with several inverters, else nothing."""
return f" voor {unit}" if unit else ""
def namen(items) -> str:
items = list(items)
if len(items) <= 1:
return "".join(items)
return ", ".join(items[:-1]) + " en " + items[-1]
def names(items) -> str:
items = list(items)
if len(items) <= 1:
return "".join(items)
return ", ".join(items[:-1]) + " and " + items[-1]
# The Details link (UX-09). Home Assistant 2026.2 added the built-in `app`
# panel (core #157554, frontend #28245), whose add-on pages are
# /config/app/<slug>/<tab>. 2026.5 dropped the old `hassio` panel, so
# /hassio/addon/<slug>/<tab> is a 404 there; 2026.1 and older have only that
# one. No path reaches the Documentation tab on both, so the link follows the
# Home Assistant version, and an unknown version gets the current path.
DOCS_PATH = "/config/app/{slug}/documentation"
DOCS_PATH_OLD = "/hassio/addon/{slug}/documentation"
APP_PANEL_SINCE = (2026, 2)
_SLUG = re.compile(r"[a-z0-9]+_goodwe_controller")
_site = {"slug": None, "ha": None}
def set_site(slug=None, ha_version=None) -> None:
"""What main.py read from the Supervisor at start: our slug, HA's version."""
_site["slug"] = slug if isinstance(slug, str) and _SLUG.fullmatch(slug) \
else None
_site["ha"] = ha_version if isinstance(ha_version, str) else None
def ha_release(version) -> tuple | None:
"""`"2026.9.1"` -> `(2026, 9)`; None when it cannot be read."""
m = re.match(r"(\d{4})\.(\d{1,2})\b", str(version or ""))
return (int(m.group(1)), int(m.group(2))) if m else None
def addon_slug() -> str | None:
"""This add-on's Supervisor slug, or None outside an add-on.
The Supervisor's own answer (`GET /addons/self/info`) first. Failing that,
the container hostname, which the Supervisor names after the slug with `_`
spelled `-` (`local-goodwe-controller`). Anything else gets no link rather
than a wrong one.
"""
if _site["slug"]:
return _site["slug"]
host = os.environ.get("HOSTNAME", "")
if re.fullmatch(r"[a-z0-9]+-goodwe-controller", host):
return host.replace("-", "_")
return None
def docs_url(slug: str, ha_version=None) -> str:
release = ha_release(ha_version)
old = release is not None and release < APP_PANEL_SINCE
return (DOCS_PATH_OLD if old else DOCS_PATH).format(slug=slug)
def details(title: str) -> str:
where = f"section “{title}” under {DOCS_SECTION}"
slug = addon_slug()
if slug is None:
return f"Details: add-on documentation, {where}."
return (f"Details: [add-on documentation]({docs_url(slug, _site['ha'])}),"
f" {where}.")
@dataclass(frozen=True)
class Notice:
id: str
severity: str
title: str
body: str
section: str = "" # the DOCS.md heading, when it is not the title itself
body_nl: str = "" # MAINT-13: the same words in Dutch, shown under the English
@property
def docs(self) -> str:
return self.section or self.title
def data(self) -> dict:
"""The `persistent_notification.create` payload."""
return {"notification_id": self.id, "title": self.title,
"message": f"{self.body}\n\n"
+ (f"{self.body_nl}\n\n" if self.body_nl else "")
+ details(self.docs)}
# --- the catalogue ----------------------------------------------------------
# Each title below is also a DOCS.md heading (test_messages.py checks it).
T_LOOP_BOUND = "Battery is not keeping up"
T_OUTPUT_CLAMP = "Power request too high"
T_PEAK_SATURATED = "Load too high for battery"
T_PEAK_RESERVE = "Battery stopped limiting the peak"
T_CIRCULATING = "Inverters working against each other"
T_NOT_DELIVERING = "{unit} is not delivering"
S_NOT_DELIVERING = "An inverter is not delivering"
T_METER_FROZEN = "Meter reading may be stuck"
T_MAINT_FAILED = "Battery cycle did not finish"
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
# UX-46: the Dutch of the titles the rows repeat. The Home page's own titles
# (ui_text/nl.json, "attn") are the same words.
T_LOOP_BOUND_NL = "Batterij kan niet volgen"
T_OUTPUT_CLAMP_NL = "Vermogensvraag te hoog"
T_PEAK_SATURATED_NL = "Belasting te hoog voor de batterij"
T_METER_FROZEN_NL = "Meter staat mogelijk vast"
TITLE_NL = {T_LOOP_BOUND: T_LOOP_BOUND_NL, T_OUTPUT_CLAMP: T_OUTPUT_CLAMP_NL}
T_NO_PRICES = "No prices"
T_NO_PRICES_NL = "Prijzen ontbreken"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
ID_PEAK_SATURATED = "goodwe_peak_saturated"
ID_PEAK_RESERVE = "goodwe_peak_soc_floor"
ID_CIRCULATING = "goodwe_circulating_power"
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
ID_METER_FROZEN = "goodwe_meter_frozen"
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
ID_NO_PRICES = "goodwe_no_prices"
# PRICE-01: an earlier build raised a card with this id. Nothing raises it now;
# the add-on only dismisses it at start.
OLD_FIXED_CARD_ID = "goodwe_fixed_tariff_unused"
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
# The setting is named by its label, "Inverter rating (W)".
RATING = "the “Inverter rating” setting"
LIMIT_WORDS = {
"max_w": RATING,
"device": "the inverter's own limit",
"max_w+device": f"{RATING} and the inverter's own limit",
"rating": RATING,
}
def loop_bound() -> Notice:
"""SAFETY-40: the integrator bound. Normal in short bursts; not a fault."""
return Notice(ID_LOOP_BOUND, WARNING, T_LOOP_BOUND,
"The battery gives less than asked, which is normal for a "
"moment, but if it lasts, check it is on and not empty "
"or full.")
@dataclass(frozen=True)
class Clamp:
"""One command that was cut: what was asked, what was sent, and by what."""
unit: str | None
asked_w: float
sent_w: float
limit: str # a key of LIMIT_WORDS
def output_clamp(clamps, *, shadow: bool = False) -> Notice:
"""SAFETY-03: a computed command above the limit. A software fault.
Asked comes before sent, always - reading them the other way round turns
the alarm backwards (test_clamp.py part 6). In shadow mode the cut command
never reached the inverter, so the text says "would have" (UX-09).
"""
fault = "This is a software fault, so please report it to your installer."
if not clamps:
cut = ("would have been cut back" if shadow else "was cut back")
state = ("Shadow mode is on, so every inverter stays at 0 W." if shadow
else "Control is still running.")
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
f"The add-on asked for more power than {RATING} allows "
f"and {cut}. {state} {fault}")
c = clamps[0]
who = f"For {c.unit}, the" if c.unit else "The"
others = [x.unit for x in clamps[1:] if x.unit]
sent = "would have sent" if shadow else "sent"
if others:
verb = "was" if len(others) == 1 else "were"
state = (f"{names(others)} would have been cut too, and shadow mode "
f"keeps every inverter at 0 W." if shadow else
f"{names(others)} {verb} cut too, and control is still "
f"running.")
else:
state = ("Shadow mode is on, so the inverter stays at 0 W." if shadow
else "Control is still running.")
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
f"{who} add-on asked for {watts(c.asked_w)}, above "
f"{LIMIT_WORDS.get(c.limit, RATING)}, and {sent} "
f"{watts(c.sent_w)} instead. {state} {fault}")
def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice:
"""SAFETY-07: the battery gives all it can and the peak still goes up.
In shadow mode the battery gives nothing, so the text says what the add-on
would ask and that the peak may rise (UX-09).
"""
act = "Switch off large loads in the next few minutes."
if shadow:
return Notice(ID_PEAK_SATURATED, ALARM, T_PEAK_SATURATED,
f"The add-on would ask the battery for all it can give, "
f"and this 15-minute period is heading over your "
f"{watts(ceiling_w)} peak limit. Shadow mode is on, so "
f"the battery gives nothing and this month's peak may "
f"rise. {act}")
return Notice(ID_PEAK_SATURATED, ALARM, T_PEAK_SATURATED,
f"The battery is giving all it can, but this 15-minute period "
f"will still go over your {watts(ceiling_w)} peak limit and "
f"raise this month's peak. {act} This is not a fault: the "
f"load is more than the battery can supply.")
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=(), others: bool = False) -> Notice:
"""SAFETY-24/25/32: the peak guard may not draw the battery any lower.
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
guard only, and normal home use goes on drawing the battery below it.
`packs` names the batteries at their limit, only on a site with several.
`floor_unknown`: the floor the guard grades against is None, which at a
roster means some inverter's cut-out level is missing (the peak reserve
itself always has a value) - so the action names the cut-out level.
Both are named by their setting labels, "Peak reserve (%)" and "Battery
cut-out level (%)". `others`: the other batteries give all they can and
it is not enough (SAFETY-87), so the limit MAY be passed.
"""
if soc_unread:
why = "The battery level cannot be read"
act = ("Switch off large loads, and check the “Battery level entity” "
"setting.")
elif floor_unknown:
why = "A battery cut-out level is not known"
act = ("Switch off large loads, and set “Battery cut-out level” for "
"each inverter.")
else:
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
else "The battery is at its peak reserve")
act = "Switch off large loads, or lower the peak reserve."
if others:
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, and the other batteries cannot keep this "
f"15-minute period under your {watts(ceiling_w)} peak "
f"limit alone. {act} Home use can still draw a battery "
f"below its reserve, down to its cut-out level.")
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, so this 15-minute period will go over your "
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
f"{act} The reserve only applies to keeping the peak down: "
f"normal home use can still draw the battery below it, down "
f"to the battery cut-out level.")
def circulating(discharging=(), charging=()) -> Notice:
"""SAFETY-31: the inverters push power round each other. Control stopped."""
if discharging and charging:
what = (f"{names(discharging)} was giving power while "
f"{names(charging)} was charging, and your meter cannot see "
f"this.")
else:
what = ("One inverter was charging from another, and your meter "
"cannot see this.")
return Notice(ID_CIRCULATING, ALARM, T_CIRCULATING,
f"{what} Every inverter is set to 0 W: if the grid power does "
f"not change now, switch the inverters off at the isolator. "
f"Control stays stopped, even after a restart, until you "
f"start it on the add-on page once you know why.")
def not_delivering(unit: str, slug: str) -> Notice:
"""SAFETY-42: one inverter gives less than its share. Reports, never acts."""
return Notice(
ID_NOT_DELIVERING.format(slug=slug), ALARM,
T_NOT_DELIVERING.format(unit=unit),
f"{unit} gives less power than it is asked for, but nothing was "
f"changed and control keeps running. Check that {unit} is "
f"on and connected, that its battery is not empty or cold, and that "
f"nothing else controls it. A restart does not clear this: once you "
f"know why, stop and start control on the add-on page.",
section=S_NOT_DELIVERING)
def meter_frozen(unchanged_s) -> Notice:
"""SAFETY-18: the meter answers but its value does not move."""
return Notice(ID_METER_FROZEN, ALARM, T_METER_FROZEN,
f"The meter has shown the same value for "
f"{duration(unchanged_s)} while the add-on asks the "
f"battery for full power. Control is still running and still "
f"follows this reading. Check the meter, and if it is stuck, "
f"stop control on the add-on page.")
def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice:
"""MAINT-13: a monthly cycle stopped before it was done. Said once."""
full = kind == "charge"
who = unit or "The battery"
who_nl = unit or "De batterij"
en = f"{who} did not {'fill up' if full else 'empty'}, so its cycle " \
f"stopped and tries again in {days} days."
nl = f"{who_nl} raakte niet {'vol' if full else 'leeg'}, dus de cyclus is " \
f"gestopt en probeert het over {days} dagen opnieuw."
return Notice(ID_MAINT_FAILED.format(slug=slug or "battery"), WARNING,
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
NL_MONTHS = ("jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep",
"okt", "nov", "dec")
def since_text(since, now, lang="en") -> str:
"""`00:00` for a moment today, `13 Oct at 21:00` (`13 okt om 21:00` in
Dutch) for an earlier day. Both arguments are already in the site's zone.
Empty for no moment."""
if since is None:
return ""
if since.date() == now.date():
return f"{since:%H:%M}"
if lang == "nl":
return f"{since.day} {NL_MONTHS[since.month - 1]} om {since:%H:%M}"
return when(since)
def no_prices(since=None, now=None) -> Notice:
"""PRICE-01: no source has a price for now. Said once, gone with the gap.
`since` is the moment the last price ran out, or None."""
if since is not None:
en = (f"No prices since {since_text(since, now)}, so the monthly "
f"battery cycle waits until they return.")
nl = (f"Sinds {since_text(since, now, 'nl')} zijn er geen prijzen, dus "
f"de maandelijkse batterijcyclus wacht tot ze terug zijn.")
else:
en = ("No prices have come in yet, so the monthly battery cycle waits "
"until they do.")
nl = ("Er zijn nog geen prijzen binnen, dus de maandelijkse "
"batterijcyclus wacht tot ze er zijn.")
return Notice(ID_NO_PRICES, WARNING, T_NO_PRICES, en,
body_nl=f"{T_NO_PRICES_NL}. {nl}")
@dataclass(frozen=True)
class Dismiss:
"""The payload that takes one notification down again."""
id: str
def data(self) -> dict:
return {"notification_id": self.id}
def prices_back() -> Dismiss:
"""PRICE-01: the 'No prices' notification, taken down when a price is back."""
return Dismiss(ID_NO_PRICES)
# PRICE-01: a fixed tariff beside the price service. One true sentence, for the
# start-up log (English only, as every log line) and the settings page (EN, NL).
FIXED_TARIFF_LOG = "Fixed tariff is only used if no prices are available."
FIXED_TARIFF_NOTE = ("With the price service, the fixed tariff is only used if "
"no prices are available.")
FIXED_TARIFF_NOTE_NL = ("Met de prijsdienst wordt het vaste tarief alleen "
"gebruikt als er geen prijzen zijn.")
# Settings-page notes that have a Dutch twin (uipage.anchors).
NOTE_NL = {FIXED_TARIFF_NOTE: FIXED_TARIFF_NOTE_NL}
def fixed_tariff_card_gone() -> Dismiss:
"""PRICE-01: takes down the card an earlier build raised."""
return Dismiss(OLD_FIXED_CARD_ID)
def row_no_prices(since=None, now=None) -> str:
"""PRICE-01: the status row while no price covers now."""
if since is not None:
return Bi(f"No prices since {since_text(since, now)}, maintenance "
"waits.",
f"Geen prijzen sinds {since_text(since, now, 'nl')}, "
"onderhoud wacht.")
return Bi("No prices yet, maintenance waits.",
"Nog geen prijzen, onderhoud wacht.")
def row_maint_waits(until_text: str) -> str:
"""MAINT-13: the status row while a failed cycle waits."""
return Bi(f"Waits until {until_text} after a cycle that did not finish.",
f"Wacht tot {until_text} na een cyclus die niet af was.")
# --- the status page (UX-08 phase 2b) ----------------------------------------
# The rows of the Commissioning list and the banner at the top of the add-on
# page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad);
# these decide WHAT it says. A row is one sentence of at most 15 words; a red
# row may use up to 3 sentences and 30 words. A banner is at most 12 words.
# Rows never name a Home Assistant entity: they name the setting in words.
# DOCS.md, "Status page", explains every row.
NOT_READY = "Not ready"
BANNER_RUNNING = ("Running — holding grid at zero",
"Actief — houdt het net op nul")
DOCS_STATUS = "Status page"
def signed_watts(value) -> str:
"""`-1200` -> `-1 200 W`: like `watts()`, but the sign is kept."""
return f"{float(value):,.0f} W".replace(",", " ")
def seconds(value) -> str:
"""Always in seconds, for a live count set against its limit."""
n = f"{float(value):.0f}"
return f"{n} second" if n == "1" else f"{n} seconds"
# One token each, like a code, so the line keeps its length and stays greppable.
NO_REASON_SAVED, NO_REASON_CODE = "REASON_NOT_SAVED", "REASON_WITHOUT_CODE"
_REASON_CODE = re.compile(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b")
def reason_code(reason) -> str:
"""UX-11: the code in a safety reason (`STALE_READING`), for the one plain
WARNING or ERROR line that reports it. The reason with its figures goes on
a DEBUG line beside it and into the action log. Saved reasons have always
opened with their code, so an old state file reads the same way."""
if not reason:
return NO_REASON_SAVED
m = _REASON_CODE.search(str(reason))
return m.group(0) if m else NO_REASON_CODE
def times(n) -> str:
return "1 time" if int(n) == 1 else f"{int(n)} times"
def count(n, noun, plural=None) -> str:
"""`count(1, "price")` -> `1 price`, `count(3, "price")` -> `3 prices`."""
return f"{int(n)} {noun if int(n) == 1 else plural or noun + 's'}"
def when(at) -> str:
"""`27 Sep at 09:15`, as the page writes a moment. `at` is already in the
time zone the reader wants (the caller passes `price.brussels(t)`)."""
return f"{at.day} {at:%b} at {at:%H:%M}"
def for_unit(unit) -> str:
"""` for inverter-b` on a site with several inverters, else nothing."""
return f" for {unit}" if unit else ""
def headline(text: str) -> str:
"""The first sentence of a row, for the banner when the row has no own."""
first = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text.strip(), maxsplit=1)[0]
return first.rstrip(".")
# Banners (the headline at the top of the page).
def headline_bi(text):
"""`headline()` of a row, in both languages."""
return Bi(headline(text), headline(getattr(text, "nl", None) or text))
def banner_not_ready(row: dict) -> str:
part = row.get("banner") or headline_bi(row["text"])
return Bi(f"{NOT_READY} — {part}",
f"Niet klaar — {getattr(part, 'nl', None) or part}")
def banner_circulating() -> str:
return Bi("Inverters worked against each other. Every inverter is set to "
"0 W.",
"Omvormers werkten elkaar tegen. Elke omvormer staat op 0 W.")
def banner_site_direction() -> str:
return Bi("Total power went the wrong way. All inverters at 0 W.",
"Totaal vermogen ging de verkeerde kant op. Alle omvormers op "
"0 W.")
def banner_direction(several: bool) -> str:
if several:
return Bi("An inverter's own power went the wrong way. Set to 0 W.",
"Eigen vermogen van een omvormer ging verkeerd. Op 0 W "
"gezet.")
return Bi("The meter saw power go the wrong way. Inverter at 0 W.",
"De meter zag het vermogen verkeerd gaan. Omvormer op 0 W.")
def banner_not_delivering(units) -> str:
units = list(units)
verb = "are" if len(units) > 1 else "is"
werkwoord = "leveren" if len(units) > 1 else "levert"
return Bi(f"{names(units)} {verb} not delivering. Control keeps running.",
f"{namen(units)} {werkwoord} niet. De regeling blijft draaien.")
def banner_maintenance(phase: str, unit: str = "") -> str:
where = f" ({unit})" if unit else ""
fase = {"drain": "leegmaken", "charge": "opladen", "hold": "vol houden"}
return Bi(f"Maintenance: {phase}{where}",
f"Onderhoud: {fase.get(phase, phase)}{where}")
def banner_running() -> str:
return Bi(*BANNER_RUNNING)
def banner_stopped() -> str:
return Bi("Stopped — the inverters are set to 0 W",
"Gestopt — de omvormers staan op 0 W")
def banner_setup_only() -> str:
"""UX-15: setup-only mode, where the add-on serves the page and commands
nothing (app/setuponly.py)."""
return Bi("Setup: nothing is controlled yet",
"Installatie: nog niets wordt geregeld")
def row_setup_only() -> str:
return Bi("No inverter is named yet, so finish Setup and restart the "
"add-on.",
"Er is nog geen omvormer opgegeven, dus rond de installatie af "
"en herstart de add-on.")
def banner_shadow(latched: bool) -> str:
"""SAFETY-10: first in the cascade, never displaced by another banner.
With a safety stop standing it goes red and says the stop outlasts shadow
mode. Each stop has its own red row below, which names it and says how it
clears.
"""
if latched:
return Bi("Shadow mode: battery not controlled. A safety stop stays "
"on, see below.",
"Schaduwmodus: batterij niet geregeld. Een veiligheidsstop "
"blijft aan, zie hieronder.")
return Bi("Shadow mode: the battery is not controlled. Inverters stay at "
"0 W.",
"Schaduwmodus: de batterij wordt niet geregeld. Omvormers "
"blijven op 0 W.")
def banner_ha_waiting() -> str:
"""SAFETY-112: the Supervisor passes nothing on until Home Assistant has
finished starting, so the add-on can neither read nor write."""
return Bi("Waiting for Home Assistant to finish starting",
"Wachten tot Home Assistant is opgestart")
def row_ha_waiting() -> str:
return Bi("Home Assistant is not answering, perhaps it is still "
"starting. Control resumes by itself when it answers.",
"Home Assistant antwoordt niet, misschien start het nog op. "
"De regeling gaat vanzelf verder zodra het antwoordt.")
# Rows: control and the meter. "Control" is what the add-on does, as in
# "start control" (STYLE.md section 4).
def row_controller_starting() -> str:
return Bi("Control: starting", "Regeling: start op")
def row_controller_ran(age_s) -> str:
return Bi(f"Control: last ran {seconds(age_s)} ago",
f"Regeling: laatst uitgevoerd {seconden(age_s)} geleden")
def row_controller_stalled(age_s, veto_s) -> str:
return Bi(f"Control has not run for {seconds(age_s)}. The control board "
f"sets the inverter to 0 W after {seconds(veto_s)}.",
f"De regeling is al {seconden(age_s)} niet uitgevoerd. De "
f"stuurmodule zet de omvormer na {seconden(veto_s)} op 0 W.")
def row_meter_waiting(limit_s) -> str:
"""UX-10: no reading yet since the start, and still inside the limit."""
return Bi(f"Meter: waiting for the first reading, limit "
f"{seconds(limit_s)}",
f"Meter: wacht op de eerste meting, limiet {seconden(limit_s)}")
def row_meter_stale(age_s, limit_s) -> str:
return Bi(f"Meter: no reading for {seconds(age_s)}, limit "
f"{seconds(limit_s)}. The add-on log shows why.",
f"Meter: al {seconden(age_s)} geen meting, limiet "
f"{seconden(limit_s)}. De log van de add-on toont waarom.")
def row_meter(net_w, age_s, rejected=0, unchanged_s=None) -> str:
return Bi(f"Meter: {signed_watts(net_w)}, {seconds(age_s)} old"
+ (f", {rejected} readings refused" if rejected else "")
+ ("" if unchanged_s is None
else f", unchanged for {duration(unchanged_s)}"),
f"Meter: {signed_watts(net_w)}, {seconden(age_s)} oud"
+ (f", {rejected} metingen geweigerd" if rejected else "")
+ ("" if unchanged_s is None
else f", ongewijzigd gedurende {duur(unchanged_s)}"))
def row_meter_frozen() -> str:
return Bi(f"{T_METER_FROZEN}. Control still follows it. Check the meter, "
f"and stop control if it is stuck.",
f"{T_METER_FROZEN_NL}. De regeling volgt hem nog. Controleer "
f"de meter en stop de regeling als hij vastzit.")
# Settings are named by their labels in translations/en.yaml.
METER_SOURCE = "“Where the meter reading comes from”"
METER_SOURCE_NL = "“Waar de meterstand vandaan komt”"
FREEZE_OFF = {
"source": f"it needs {METER_SOURCE} set to HomeWizard local",
"rating": "“Inverter rating” is not set",
"range": "the inverter reports no usable power range",
}
FREEZE_OFF_NL = {
"source": f"vereist {METER_SOURCE_NL} op HomeWizard local",
"rating": "“Vermogen van de omvormer” is niet ingesteld",
"range": "de omvormer meldt geen bruikbaar vermogensbereik",
}
def row_freeze_off(why: str) -> str:
"""`why` is a key of FREEZE_OFF: why the stuck-meter check cannot run."""
return Bi(f"Stuck-meter check: off, {FREEZE_OFF[why]}",
f"Controle op vastgelopen meter: uit, {FREEZE_OFF_NL[why]}")
def row_freeze_on(hold_s, rail_w, own_limit: bool) -> str:
return Bi(f"Stuck-meter check: alerts after {duration(hold_s)} unchanged "
f"above {watts(rail_w)}"
+ (", the inverter's own limit" if own_limit else ""),
f"Controle op vastgelopen meter: alarm na {duur(hold_s)} "
f"ongewijzigd boven {watts(rail_w)}"
+ (", omvormerlimiet" if own_limit else ""))
# Rows: the peak guard.
def row_peak_saturated(ceiling_w) -> str:
return Bi(f"{T_PEAK_SATURATED}. Switch off large loads to stay under the "
f"{watts(ceiling_w)} peak limit.",
f"{T_PEAK_SATURATED_NL}. Schakel grote verbruikers uit om onder "
f"de piekgrens van {watts(ceiling_w)} te blijven.")
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=(), others: bool = False) -> str:
"""SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay.
`others`: as in `peak_reserve()` (SAFETY-87)."""
if soc_unread:
why, why_nl = ("Battery level cannot be read",
"Het laadniveau is niet leesbaar")
elif floor_unknown:
why, why_nl = ("A battery cut-out level is not set",
"Een uitschakelniveau van de batterij is niet ingesteld")
else:
why, why_nl = ((f"{names(packs)} battery at its peak reserve",
f"Batterij van {namen(packs)} op de piekreserve")
if packs else
("Battery at its peak reserve",
"Batterij op de piekreserve"))
if others:
return Bi(f"{why}. The others cannot hold the {watts(ceiling_w)} peak "
f"limit alone. Home use still draws it lower, to the "
f"cut-out level.",
f"{why_nl}. De andere kunnen de piekgrens van "
f"{watts(ceiling_w)} niet alleen houden. Het verbruik in "
f"huis trekt de batterij verder leeg, tot het "
f"uitschakelniveau.")
return Bi(f"{why}, so the {watts(ceiling_w)} peak limit will be passed. "
f"Home use still draws the battery below it, down to the "
f"cut-out level.",
f"{why_nl}, dus de piekgrens van {watts(ceiling_w)} wordt "
f"overschreden. Het verbruik in huis trekt de batterij verder "
f"leeg, tot het uitschakelniveau.")
def row_peak_guard_off() -> str:
"""`meter_source` is off: the 15-minute average cannot be measured."""
return Bi(f"Peak guard: off, set {METER_SOURCE} to anything but off",
f"Piekbewaking: uit, zet {METER_SOURCE_NL} op iets anders dan "
f"uit")
def banner_peak_reserve() -> str:
return Bi(T_PEAK_RESERVE, "Batterij beperkt de piek niet meer")
# Rows: the sensors and the inverter control.
LABEL_SOC = "Battery level"
LABEL_BATTERY = "Battery power"
LABEL_GRID = "Grid power"
# UX-46: the same three in Dutch, and the settings that name them.
LABEL_NL = {LABEL_SOC: "Laadniveau", LABEL_BATTERY: "Batterijvermogen",
LABEL_GRID: "Netvermogen"}
LABEL_ENTITY_NL = {LABEL_SOC: "Entiteit laadniveau",
LABEL_BATTERY: "Entiteit batterijvermogen",
LABEL_GRID: "Entiteit netvermogen"}
def row_sensor(label, value, unit=None) -> str:
shown = f"{float(value):g} %" if label.startswith(LABEL_SOC) \
else signed_watts(value)
return Bi(f"{label}{for_unit(unit)}: {shown}",
f"{LABEL_NL.get(label, label)}{voor(unit)}: {shown}")
def row_sensor_unset(label, unit=None) -> str:
"""The setting is "<label> entity" (e.g. "Grid power entity (W)")."""
return Bi(f"{label}{for_unit(unit)}: “{label} entity” is not set",
f"{LABEL_NL.get(label, label)}{voor(unit)}: “"
f"{LABEL_ENTITY_NL.get(label, label)}” is niet ingesteld")
def row_sensor_unusable(label, unit=None) -> str:
"""SAFETY-56: it may be missing OR held back as stale; say both."""
return Bi(f"{label}{for_unit(unit)}: no usable reading. The entity may be "
f"missing, or its reading is held back as too old. Check it in "
f"Developer Tools before replacing it.",
f"{LABEL_NL.get(label, label)}{voor(unit)}: geen bruikbare "
f"meting. De entiteit ontbreekt misschien, of de meting is te "
f"oud. Controleer ze in Ontwikkelhulpmiddelen voor u ze vervangt.")
def setpoint_label(unit=None, lang="en") -> str:
"""The label: "Setpoint entity" in the inverter list, else its long form."""
if lang == "nl":
return "“Setpoint-entiteit”" if unit \
else "“Setpoint-entiteit van de omvormer”"
return "“Setpoint entity”" if unit else "“Inverter setpoint entity”"
def row_control_unset(unit=None) -> str:
return Bi(f"Inverter control{for_unit(unit)}: {setpoint_label(unit)} is "
f"not set",
f"Regeling van de omvormer{voor(unit)}: "
f"{setpoint_label(unit, 'nl')} is niet ingesteld")
def row_control_missing(unit=None) -> str:
return Bi(f"Inverter control{for_unit(unit)}: not found in Home "
f"Assistant",
f"Regeling van de omvormer{voor(unit)}: niet gevonden in Home "
f"Assistant")
# Rows: whether each inverter can still be controlled (DEV-02). `name` is
# always given: these rows came with the inverter list.
def row_no_setpoint(name) -> str:
"""Amber. The site refuses to run at part strength, so all stay at 0 W."""
return Bi(f"{name}: “Setpoint entity” is not set, so every inverter "
f"stays at 0 W",
f"{name}: “Setpoint-entiteit” is niet ingesteld, dus elke "
f"omvormer blijft op 0 W")
def row_not_controllable(name, held_s, limit_s) -> str:
"""Red. The inverter is dropped and the others carry its share."""
return Bi(f"{name} cannot be controlled. It refused every command for "
f"{seconds(held_s)}, so control leaves it out.",
f"{name} kan niet worden geregeld. Hij weigerde elke opdracht "
f"gedurende {seconden(held_s)}, dus de regeling laat hem "
f"weg.")
def row_commands_refused(name, held_s, limit_s) -> str:
"""Amber. Refused, but not for long enough to be dropped yet."""
return Bi(f"{name}: commands refused for {seconds(held_s)}, left out of "
f"control after {seconds(limit_s)}",
f"{name}: opdrachten geweigerd gedurende {seconden(held_s)}, "
f"de regeling laat hem weg na {seconden(limit_s)}")
def row_control(lo_w, hi_w, unit=None) -> str:
"""The inverter's own range: charging below 0 W, giving power above it."""
head = f"Inverter control{for_unit(unit)}: ready"
kop = f"Regeling van de omvormer{voor(unit)}: klaar"
if lo_w is None or hi_w is None or lo_w > 0 or hi_w < 0:
def w(v):
return "?" if v is None else signed_watts(v)
return Bi(f"{head}, from {w(lo_w)} to {w(hi_w)}",
f"{kop}, van {w(lo_w)} tot {w(hi_w)}")
if abs(float(lo_w)) == float(hi_w):
return Bi(f"{head}, up to {watts(hi_w)} each way",
f"{kop}, tot {watts(hi_w)} in beide richtingen")
return Bi(f"{head}, {watts(lo_w)} charging, {watts(hi_w)} giving",
f"{kop}, {watts(lo_w)} laden, {watts(hi_w)} leveren")
# Rows: the two loop alarms. The names are the notification titles, so the
# row and the card always call the same thing by the same name (UX-07 item 1).
def row_power_too_high(clamps, count, *, shadow: bool = False) -> str:
"""In shadow mode nothing was sent, so it says "would send" (UX-09)."""
c = clamps[0] if clamps else None
head = f"{T_OUTPUT_CLAMP}{' in shadow mode' if shadow else ''}"
what = (f"asked {watts(c.asked_w)}{for_unit(c.unit)}, "
f"{'would send' if shadow else 'sent'} {watts(c.sent_w)}. Cut back"
if c else "cut back")
kop = f"{T_OUTPUT_CLAMP_NL}{' in schaduwmodus' if shadow else ''}"
wat = (f"gevraagd {watts(c.asked_w)}{voor(c.unit)}, "
f"{'zou ' + watts(c.sent_w) + ' sturen' if shadow else watts(c.sent_w) + ' gestuurd'}"
f". {keer(count)} teruggebracht" if c
else f"{keer(count)} teruggebracht")
return Bi(f"{head}: {what} {times(count)} since start. Software fault, "
f"please report it.",
f"{kop}: {wat} sinds de start. Softwarefout, meld dit "
f"alstublieft.")
def banner_power_too_high() -> str:
return Bi(T_OUTPUT_CLAMP, T_OUTPUT_CLAMP_NL)
def row_not_keeping_up(count) -> str:
return Bi(f"{T_LOOP_BOUND}: {times(count)} since start",
f"{T_LOOP_BOUND_NL}: {keer(count)} sinds de start")
def row_before(title, count) -> str:
"""After an episode clears: how often it happened, so the evidence stays."""
return Bi(f"{title}: {times(count)} since start, fine now",
f"{TITLE_NL.get(title, title)}: {keer(count)} sinds de start, "
f"nu weer in orde")
def row_write_refused(unit=None) -> str:
return Bi(f"{unit or 'The inverter'} refused the last command. Check the "
f"add-on log.",
f"{unit or 'De omvormer'} weigerde de laatste opdracht. Kijk in "
f"de log van de add-on.")
# Rows: the direction checks (SAFETY-02/30).
def row_direction_trip(unit=None) -> str:
"""Per inverter. `unit` only at several inverters, graded on its own power."""
what = (f"{unit}'s own power went against what the add-on asked, so it is"
if unit else
"Grid power went against what the add-on asked, so the inverter is")
wat = (f"Het eigen vermogen van {unit} ging tegen de vraag in, dus hij staat"
if unit else
"Het netvermogen ging tegen de vraag in, dus de omvormer staat")
return Bi(f"{what} set to 0 W. Find out why, then start control. A restart "
f"does not clear this.",
f"{wat} op 0 W. Zoek uit waarom, start dan de regeling. Een "
f"herstart heft dit niet op.")
def row_direction_check(held_s, hold_s, unit=None) -> str:
who = (f"Direction check for {unit}, against its own power" if unit
else "Direction check")
wie = (f"Richtingcontrole voor {unit}, op het eigen vermogen" if unit
else "Richtingcontrole")
if held_s:
return Bi(f"{who}: opposite for {held_s:.0f} of {seconds(hold_s)}",
f"{wie}: tegengesteld gedurende {held_s:.0f} van "
f"{seconden(hold_s)}")
return Bi(f"{who}: on", f"{wie}: aan")
def row_site_direction_trip() -> str:
return Bi("Grid power went against the add-on's total request, so every "
"inverter is set to 0 W. The cause is the total, not one "
"inverter. Only starting control clears this.",
"Het netvermogen ging tegen de totale vraag in, dus elke omvormer "
"staat op 0 W. De oorzaak is het totaal, niet één omvormer. "
"Alleen de regeling starten heft dit op.")
def row_site_direction_check(held_s, hold_s) -> str:
if held_s:
return Bi(f"Site direction check: opposite for {held_s:.0f} of "
f"{seconds(hold_s)}",
f"Richtingcontrole voor de installatie: tegengesteld "
f"gedurende {held_s:.0f} van {seconden(hold_s)}")
return Bi("Site direction check: on, against the meter",
"Richtingcontrole voor de installatie: aan, op de meter")
# Rows: circulating power (SAFETY-31) and delivery (SAFETY-42).
def row_circulating_trip() -> str:
return Bi("Inverters worked against each other, so every inverter is set "
"to 0 W. The meter cannot see this. Only starting control "
"clears this.",
"Omvormers werkten elkaar tegen, dus elke omvormer staat op 0 W. "
"De meter ziet dit niet. Alleen de regeling starten heft dit "
"op.")
CHECK_CIRCULATING = "Check for inverters working against each other"
CHECK_CIRCULATING_NL = "Controle op omvormers die elkaar tegenwerken"
CHECK_DELIVERY = "Delivery check"
CHECK_DELIVERY_NL = "Controle op levering"
CHECK_NL = {CHECK_CIRCULATING: CHECK_CIRCULATING_NL,
CHECK_DELIVERY: CHECK_DELIVERY_NL}
def row_circulating_check(now_w, held_s, hold_s) -> str:
if held_s:
return Bi(f"{CHECK_CIRCULATING}: {watts(now_w or 0)} for {held_s:.0f} "
f"of {seconds(hold_s)}",
f"{CHECK_CIRCULATING_NL}: {watts(now_w or 0)} gedurende "
f"{held_s:.0f} van {seconden(hold_s)}")
return Bi(f"{CHECK_CIRCULATING}: on"
+ (f", now {watts(now_w)}" if now_w else ""),
f"{CHECK_CIRCULATING_NL}: aan"
+ (f", nu {watts(now_w)}" if now_w else ""))
def row_one_inverter(check: str) -> str:
return Bi(f"{check}: not needed with one inverter",
f"{CHECK_NL.get(check, check)}: niet nodig met één omvormer")
def row_not_delivering(unit) -> str:
return Bi(f"{unit} is not delivering, and only stopping and starting "
f"control clears this",
f"{unit} levert niet, en alleen de regeling stoppen en starten "
f"heft dit op")
def row_delivery_check(unit, short_s, hold_s) -> str:
if short_s:
return Bi(f"Delivery check for {unit}: short for {short_s:.0f} of "
f"{seconds(hold_s)}",
f"{CHECK_DELIVERY_NL} voor {unit}: te weinig gedurende "
f"{short_s:.0f} van {seconden(hold_s)}")
return Bi(f"Delivery check for {unit}: on",
f"{CHECK_DELIVERY_NL} voor {unit}: aan")
def row_estop_fitted() -> str:
return Bi("Emergency stop: fitted", "Noodstop: geplaatst")
def row_no_peak_forecast() -> str:
return Bi("No peak forecast set, so maintenance charging is not kept "
"under the peak limit",
"Geen prognose voor de piekgrens ingesteld, dus onderhoudsladen "
"blijft niet onder de piekgrens")
def peak_forecast_unit(unit, lang="en") -> str:
"""SAFETY-118. The 15-minute demand forecast has a number but no power
unit, so it is not used. `unit` is "" for none. One sentence, for the log
line, the status row and the settings page. Log lines stay English."""
if lang == "nl":
return ("De kwartierprognose heeft geen eenheid, dus ze wordt niet "
"gebruikt." if not unit else
"De kwartierprognose heeft geen vermogenseenheid, dus ze "
"wordt niet gebruikt.")
return ("The 15-minute demand forecast has no unit, so it is not used."
if not unit else
"The 15-minute demand forecast is not in a power unit, so it is "
"not used.")
def row_peak_forecast_unit(unit) -> str:
return Bi(peak_forecast_unit(unit), peak_forecast_unit(unit, "nl"))
def row_peak_guard_waiting() -> str:
return Bi("Peak guard: no decision yet, control has not run",
"Piekbewaking: nog geen besluit, de regeling liep nog niet")
def row_no_estop() -> str:
return Bi("No emergency stop fitted: if this computer fails, the battery "
"keeps its last command",
"Geen noodstop geplaatst: als deze computer uitvalt, houdt de "
"batterij haar laatste opdracht")
# Rows: solar production (TEL-11/12/14). Sources are numbered in the order of
# "Solar production entities, per inverter". The settings are named by their
# labels in translations/en.yaml.
PV_ONE = "“Solar production entity”"
PV_REVERSED = "“Reversed-sign solar entities”"
PV_ONE_NL = "“Entiteit zonneproductie”"
PV_REVERSED_NL = "“Zonne-entiteiten met omgekeerd teken”"
def source(n, of, lang="en") -> str:
if lang == "nl":
return "de entiteit" if of == 1 else f"bron {n}"
return "the entity" if of == 1 else f"source {n}"
def pv_problem(why: str, lang="en") -> str:
"""`pv.watts()`'s reason, in plain words."""
w = str(why)
en, nl = ("the reading is not usable", "de meting is niet bruikbaar")
if w.startswith("no state"):
en, nl = "the entity is missing", "de entiteit ontbreekt"
elif w.startswith("entity is"):
en, nl = "the entity is unavailable", "de entiteit is niet beschikbaar"
elif w.endswith("is not numeric"):
en, nl = "the reading is not a number", "de meting is geen getal"
elif w.startswith("no unit"):
en, nl = "the entity has no unit", "de entiteit heeft geen eenheid"
elif w.startswith("unit "):
en, nl = ("the entity's unit is not a power unit",
"de eenheid van de entiteit is geen vermogenseenheid")
return nl if lang == "nl" else en
def row_pv(total_w, count, reversed_=False) -> str:
return Bi(f"Solar production: {signed_watts(total_w)}"
+ (f" from {count} sources" if count > 1 else "")
+ (", sign reversed" if reversed_ else ""),
f"Zonneproductie: {signed_watts(total_w)}"
+ (f" van {count} bronnen" if count > 1 else "")
+ (", teken omgekeerd" if reversed_ else ""))
def row_pv_none(why) -> str:
return Bi(f"Solar production: no reading, {pv_problem(why)}",
f"Zonneproductie: geen meting, {pv_problem(why, 'nl')}")
def row_pv_partial(missing, count) -> str:
"""`missing` are the 1-based positions of the sources that did not report."""
which = names([str(n) for n in missing])
welke = namen([str(n) for n in missing])
many = len(missing) > 1
return Bi(f"Solar production: no total, source{'s' if many else ''} "
f"{which} of {count} {'have' if many else 'has'} no "
f"reading",
f"Zonneproductie: geen totaal, {'bronnen' if many else 'bron'} "
f"{welke} van {count} {'hebben' if many else 'heeft'} geen "
f"meting")
def row_forecast_gap(day, missing, count) -> str:
"""OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based
positions of the forecast entities with no usable reading."""
which = names([str(n) for n in missing])
welke = namen([str(n) for n in missing])
many = len(missing) > 1
dag = {"today": "vandaag", "tomorrow": "morgen"}.get(day, day)
return Bi(f"Solar forecast for {day}: not used, source{'s' if many else ''} "
f"{which} of {count} {'have' if many else 'has'} no reading",
f"Zonneprognose voor {dag}: niet gebruikt, "
f"{'bronnen' if many else 'bron'} {welke} van {count} "
f"{'hebben' if many else 'heeft'} geen meting")
def row_pv_single_ignored() -> str:
return Bi(f"Solar production: clear {PV_ONE}, the per-inverter list "
f"replaces it",
f"Zonneproductie: wis {PV_ONE_NL}, de lijst per omvormer "
f"vervangt hem")
def row_pv_negative(n, count, value_w, flipped: bool) -> str:
fix = ("remove it from" if flipped else "add it to")
who = source(n, count)
herstel = "haal hem uit" if flipped else "voeg hem toe aan"
return Bi(f"Solar production: {who} reads {signed_watts(value_w)}, "
f"{fix} {PV_REVERSED}",
f"Zonneproductie: {source(n, count, 'nl')} meet "
f"{signed_watts(value_w)}, {herstel} {PV_REVERSED_NL}")
def row_pv_stray(n) -> str:
return Bi(f"Solar production: entry {n} of {PV_REVERSED} is not a solar "
f"entity in use",
f"Zonneproductie: item {n} van {PV_REVERSED_NL} is geen "
f"zonne-entiteit die in gebruik is")
# Rows: this month's peak and the peak guard (UX-08 phase 2d). The detail
# (the projection's reason, the store's state at start) is in the add-on log.
def row_month_peak(peak_w, at, frac) -> str:
"""`at` is the local start of the 15-minute period that set the peak."""
return Bi(f"Month peak: {watts(peak_w)} on {at.day} {at:%b} at {at:%H:%M}, "
f"{frac * 100:.0f} % of the peak limit",
f"Maandpiek: {watts(peak_w)} op {at.day} "
f"{NL_MONTHS[at.month - 1]} om {at:%H:%M}, {frac * 100:.0f} % "
f"van de piekgrens")
def row_month_peak_none() -> str:
return Bi("Month peak: none recorded yet this month",
"Maandpiek: deze maand nog niets gemeten")
def row_month_peak_unreadable() -> str:
"""The store did not answer. This is not a zero peak (TARIFF-01)."""
return Bi("Month peak: cannot be read, so only the peak limit is used",
"Maandpiek: niet leesbaar, dus alleen de piekgrens telt")
def row_periods_measured(n) -> str:
"""TARIFF-13's coverage figure. `n` is an int, or a text when unreadable."""
shown = n if isinstance(n, int) else "cannot be read"
getoond = n if isinstance(n, int) else "niet leesbaar"
return Bi(f"15-minute periods measured this month: {shown}",
f"Gemeten kwartieren deze maand: {getoond}")
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool,
cut: bool = False) -> str:
"""The projection first and the limit second (SAFETY-38's ordered pair).
SAFETY-125: over the limit at this rate it says so, and `cut` that a
grid charge is limited to the room left."""
if float(projected_w) > float(limit_w):
doing, doet = (("grid charge limited", "laden beperkt") if cut
else ("idle", "in rust"))
return Bi(f"Peak guard: {doing}, at this rate {watts(projected_w)}, "
f"over {watts(limit_w)}",
f"Piekbewaking: {doet}, aan dit tempo "
f"{watts(projected_w)}, boven {watts(limit_w)}")
limit = (f"this month's {watts(limit_w)} peak" if month_peak
else f"the {watts(limit_w)} peak limit")
grens = (f"de maandpiek van {watts(limit_w)}" if month_peak
else f"de piekgrens van {watts(limit_w)}")
return Bi(f"Peak guard: idle, heading for {watts(projected_w)}, under "
f"{limit}",
f"Piekbewaking: in rust, richting {watts(projected_w)}, onder "
f"{grens}")
def row_peak_guard_quiet() -> str:
return Bi("Peak guard: idle", "Piekbewaking: in rust")
BLIND_WORDS = {
"no_block": "waiting for meter readings",
"gap": "meter readings have a gap",
"frozen": "meter reading is stuck",
}
BLIND_WORDS_NL = {
"no_block": "wacht op metingen van de meter",
"gap": "de metingen van de meter hebben een gat",
"frozen": "de meterstand zit vast",
}
def row_peak_guard_blind(why: str) -> str:
"""`why` is a key of BLIND_WORDS. The battery may give power, not charge."""
return Bi(f"Peak guard: {BLIND_WORDS[why]}, so the battery does not "
f"charge meanwhile",
f"Piekbewaking: {BLIND_WORDS_NL[why]}, dus de batterij laadt "
f"intussen niet")
def row_peak_guard_unmeasured() -> str:
"""SAFETY-90. Before the first reading; that part counts as high use."""
return Bi("Peak guard: part of this 15-minute period was not measured, "
"counted as high use",
"Piekbewaking: een deel van dit kwartier is niet gemeten, "
"geteld als hoog verbruik")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return Bi(f"Peak guard: would ask for {watts(floor_w)} to stay under "
f"{watts(limit_w)}",
f"Piekbewaking: zou {watts(floor_w)} vragen om onder "
f"{watts(limit_w)} te blijven")
return Bi(f"Peak guard: asks the battery for {watts(floor_w)} to stay "
f"under {watts(limit_w)}",
f"Piekbewaking: vraagt de batterij {watts(floor_w)} om "
f"onder {watts(limit_w)} te blijven")
def row_battery_stuck(unit) -> str:
"""SAFETY-37/56. A fact, not a verdict: the reading may be stuck, or the
battery may be refusing, and the add-on cannot tell which."""
return Bi(f"{unit}: battery power reading stopped changing, so it is not "
f"used for now",
f"{unit}: het batterijvermogen verandert niet meer, dus het "
f"wordt nu niet gebruikt")
def row_battery_refuses(unit, sign, probing) -> str:
"""SAFETY-73. One inverter's battery refuses, the others take its share."""
way = "give" if sign > 0 else "take"
weg = "leveren" if sign > 0 else "opnemen"
weigert = "te leveren" if sign > 0 else "op te nemen"
if probing:
return Bi(f"{unit}: testing if the battery will {way} power again",
f"{unit}: test of de batterij weer stroom wil {weg}")
return Bi(f"{unit}: the battery refuses to {way} power, the others take "
f"over",
f"{unit}: de batterij weigert stroom {weigert}, de andere "
f"nemen over")
# The top of the page: why the battery does what it does (the "Why" line).
WHY_WINNER = {
"loop": "Keeping the grid near zero",
"peak": "Keeping under the peak limit",
"arbitrage": "Trading on the price",
"maintenance": "Battery maintenance",
"failsafe": "Safety stop, power went the wrong way",
"sign_site": "Safety stop, total power went the wrong way",
"circulation": "Safety stop, inverters worked against each other",
"stopped": "Control is stopped",
"inputs missing": "A reading is missing",
"no claim was made": "Nothing asked for power",
# UX-10: the claim reasons main.why_key() can return for a safety or
# failsafe winner. test_messages.py part 7 finds every one in the code.
"sign disagreement": "Safety stop, power went the wrong way",
"unsplit": "The power could not be split, all at 0 W",
# SAFETY-52: the loop's two words for a battery that gives nothing.
"battery refuses": "The battery refuses to give power",
"battery probe": "Testing if the battery gives power again",
# SAFETY-73: a refusing inverter's own claim, closed or probing.
"refused": "A battery refuses power, the others take over",
}
WHY_LIMIT = {
"peak(quarter peak)": "the peak limit",
"peak(blind)": "no charging while the meter is unclear",
"peak(SoC floor)": "the peak reserve",
"supervised": RATING,
"device": "the inverter's own limit",
"pack": "the battery level",
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
"charge": "battery maintenance",
"hold": "battery maintenance",
"refused": "a battery that refuses power",
}
def why(winner, target_w, bound_by=None, *, contradiction=False,
raised=False) -> str:
"""`winner` is the arbiter's winning source, or its reason for the two
that share a source ("stopped", "inputs missing", "no claim was made").
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)".
`raised` is True when that limit pushed the power UP (the peak limit
asking for more): then it did not hold anything back (OPT-11 P1 test F2).
"""
if contradiction:
return "Two limits clash, so every inverter is set to 0 W"
out = f"{WHY_WINNER.get(winner, 'Other')}: {signed_watts(target_w)}"
if bound_by:
limit = WHY_LIMIT.get(bound_by) or WHY_LIMIT.get(
str(bound_by).split("(")[0], "another limit")
out += f", raised for {limit}" if raised else \
f", held back by {limit}"
return out
def why_missing(what, limit_s) -> str:
"""UX-11: past the hold, which reading is missing and what it does."""
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
def why_waiting(missing_s) -> str:
"""A reading went missing a moment ago: control holds and waits."""
return f"A reading is missing for {seconds(missing_s)}, control waits"
# The last thing that happened, shown under the buttons. The add-on keeps its
# own technical record of each event (`Controller.events`); the page shows
# these words instead. Every `log_event()` call names one (test_messages.py).
HINTS = {
"control_started": "Control started",
"control_stopped": "Control stopped",
"settings_saved": "Settings saved, they apply after a restart",
"restart_requested": "Restart requested from the settings page",
"setup_only": "In setup, nothing is sent to any inverter",
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
"battery_stuck": "{unit}: battery power reading stopped changing",
"battery_back": "{unit}: battery power reading is used again",
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
"battery_gives": "The battery refusal ended, control asks for power again",
"unit_refuses": "{unit}: the battery refuses power, the others take over",
"unit_gives": "{unit}: the battery refusal ended, it is asked for power again",
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
"all_blocked": "A power request was blocked, inverters set to 0 W",
"write_cut": "{unit}: " + T_OUTPUT_CLAMP.lower(),
"write_slow": "{unit}: the inverter answered too slowly, trying again",
"direction": "{unit}: power went the wrong way, it is set to 0 W",
"site_direction": "Total power went the wrong way, inverters set to 0 W",
"circulating": T_CIRCULATING + ", all set to 0 W",
"not_delivering": T_NOT_DELIVERING,
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
"power_too_high": T_OUTPUT_CLAMP,
"not_keeping_up": T_LOOP_BOUND,
"trade_charge": "Trading: charging while the price is low",
"trade_give": "Trading: giving power while the price is high",
"trade_off": "Trading: waiting for a better price",
"forecast_read": "Solar forecast read, the day plan uses it",
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
"forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading",
"split": "The power split between inverters changed",
"clash": "Two limits clash, inverters set to 0 W",
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
"unit_dropped": "{unit} left out of control, the others take its share",
"units_back": "Every inverter is back in control",
"unmet": "The inverters cannot give {w} of the power asked",
"unmet_gone": "The inverters can give all the power asked again",
"shadow_on": "Shadow mode on, inverters stay at 0 W",
"shadow_off": "Shadow mode off, the add-on controls the battery",
"shadow_decision": "Shadow mode: would ask the battery for {sw}",
"refused": "Settings refused, the add-on log shows why",
"inputs_missing": "A reading is missing, inverters set to 0 W",
"inputs_back": "All readings are back",
"stop_cleared": "{unit}: safety stop cleared by starting control",
"site_stop_cleared": "Safety stop cleared by starting control",
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
"maintenance": "Battery maintenance: {phase}",
"maint_paused": "Maintenance charging paused to stay under the peak limit",
"maint_failed": "The battery cycle did not finish, so it waits before trying again",
"pack_on": "{unit}: battery switched on",
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
"pack_mode": "{unit}: battery mode changed",
"pack_sell": "{unit}: selling switched",
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
"maint_waits": "{unit} cannot give power now, so its monthly cycle waits",
"mode_fault": "{unit}: battery mode ignored because of a software fault",
}
# OPT-11: why a battery switch or mode change was refused (the page shows it).
PACK_REFUSED = {
"off": "Battery modes are off. Turn on “Battery modes” first.",
"unit": "There is no battery with that name.",
"value": "That choice is not possible for this battery.",
# OPT-11 P3, owner decision 2026-09-29: selling is off for now.
"sell_off": "Selling is off for now.",
# OPT-11 P2: a Home Assistant command dropped before it reaches a battery.
"retained": "An old saved command is not used.",
"size": "That command is too long.",
"encoding": "That command is not plain text.",
"topic": "That is not a battery command.",
}
def pack_refused(why: str) -> str:
return PACK_REFUSED[why]
# MAINT-11: why a start or stop of ONE battery's cycle was refused. `{unit}` is
# the battery the sentence is about (for "busy", the one that is cycling). The
# page shows the same sentences in Dutch from app/ui_text, keyed by the code.
MAINT_REFUSED = {
"unit": "There is no battery with that name.",
"busy": "Only one battery can cycle at a time, and {unit} is cycling now.",
"off": "{unit} is switched off, so it cannot start a cycle.",
"idle": "{unit} is not running a cycle.",
}
def maint_refused(why: str, unit: str = "") -> str:
return MAINT_REFUSED[why].format(unit=unit)
def hint(key: str, **kw) -> str:
"""The plain words for one event. `w` is formatted as watts, `sw` as
watts with their sign (positive gives power, negative charges)."""
if "w" in kw:
kw["w"] = watts(kw["w"])
if "sw" in kw:
kw["sw"] = signed_watts(kw["sw"])
return HINTS[key].format(**kw)
def hint_samples() -> list:
"""`(key, text)` for every hint, rendered, for the test."""
kw = {"n": 2, "unit": "inverter-b", "w": 1200, "sw": -1200,
"phase": "drain"}
return [(k, hint(k, **{f: kw[f] for f in kw if "{" + f + "}" in v}))
for k, v in HINTS.items()]
RED, AMBER, GREEN = "bad", "warn", "ok"
# PRICE-01 samples: a gap that began today, and one that began the day before.
_SINCE_SAMPLE = (datetime(2026, 10, 14, 0, 0), datetime(2026, 10, 14, 9, 0))
_SINCE_OLDER = (datetime(2026, 10, 13, 21, 0), datetime(2026, 10, 14, 9, 0))
def status_samples() -> list:
"""`(function name, colour, text)` for every row and banner, for the test.
The colour is the one main.py gives the row; a red row may use three
sentences. Banners are listed with the colour None.
"""
clamp = [Clamp(None, 7412, 5000, "rating")]
return [
("banner_not_ready", None, banner_not_ready(
{"text": row_meter_frozen()})),
("banner_not_ready", None, banner_not_ready(
{"text": "x", "banner": banner_peak_reserve()})),
("banner_circulating", None, banner_circulating()),
("banner_site_direction", None, banner_site_direction()),
("banner_direction", None, banner_direction(False)),
("banner_direction", None, banner_direction(True)),
("banner_not_delivering", None, banner_not_delivering(["inverter-b"])),
("banner_peak_reserve", None, banner_peak_reserve()),
("banner_power_too_high", None, banner_power_too_high()),
("banner_shadow", None, banner_shadow(False)),
("banner_setup_only", None, banner_setup_only()),
("row_setup_only", AMBER, row_setup_only()),
("banner_shadow", None, banner_shadow(True)),
("banner_not_ready", None, banner_not_ready(
{"text": row_ha_waiting(), "banner": banner_ha_waiting()})),
("banner_ha_waiting", None, banner_ha_waiting()),
("row_ha_waiting", RED, row_ha_waiting()),
("row_controller_starting", GREEN, row_controller_starting()),
("row_controller_ran", GREEN, row_controller_ran(2)),
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
("row_meter_stale", RED, row_meter_stale(40, 10)),
("row_meter_waiting", AMBER, row_meter_waiting(30)),
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
("row_meter_frozen", RED, row_meter_frozen()),
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
("row_freeze_on", GREEN, row_freeze_on(600, 4465, True)),
("row_peak_saturated", RED, row_peak_saturated(5000)),
("row_peak_guard_off", AMBER, row_peak_guard_off()),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"],
others=True)),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=True, floor_unknown=False)),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=True)),
("row_sensor", GREEN, row_sensor(LABEL_SOC, 54, "inverter-b")),
("row_sensor", GREEN, row_sensor(LABEL_BATTERY, -1200.0, "inverter-b")),
("row_sensor_unset", RED, row_sensor_unset(LABEL_GRID)),
("row_sensor_unusable", RED, row_sensor_unusable(LABEL_BATTERY,
"inverter-b")),
("row_control_unset", RED, row_control_unset("inverter-b")),
("row_control_missing", RED, row_control_missing("inverter-b")),
("row_control", GREEN, row_control(-5000, 5000, "inverter-b")),
("row_control_unset", RED, row_control_unset()),
("row_no_setpoint", AMBER, row_no_setpoint("inverter-b")),
("row_not_controllable", RED, row_not_controllable("inverter-b", 45,
30)),
("row_commands_refused", AMBER, row_commands_refused("inverter-b", 12,
30)),
("row_power_too_high", RED, row_power_too_high(clamp, 3)),
("row_power_too_high", RED, row_power_too_high(
[Clamp("inverter-b", 6000, 4600, "max_w+device")], 12)),
("row_power_too_high", RED, row_power_too_high((), 1)),
("row_power_too_high", RED, row_power_too_high(
[Clamp("inverter-b", 6000, 4600, "max_w+device")], 12,
shadow=True)),
("row_not_keeping_up", AMBER, row_not_keeping_up(48)),
("row_before", AMBER, row_before(T_OUTPUT_CLAMP, 2)),
("row_before", AMBER, row_before(T_LOOP_BOUND, 1)),
("row_write_refused", RED, row_write_refused("inverter-b")),
("row_direction_trip", RED, row_direction_trip()),
("row_direction_trip", RED, row_direction_trip("inverter-b")),
("row_direction_check", GREEN, row_direction_check(0, 60)),
("row_direction_check", GREEN, row_direction_check(12, 60,
"inverter-b")),
("row_site_direction_trip", RED, row_site_direction_trip()),
("row_site_direction_check", GREEN, row_site_direction_check(0, 60)),
("row_site_direction_check", GREEN, row_site_direction_check(5, 60)),
("row_circulating_trip", RED, row_circulating_trip()),
("row_circulating_check", GREEN, row_circulating_check(1200, 20, 60)),
("row_one_inverter", GREEN, row_one_inverter("Delivery check")),
("row_not_delivering", AMBER, row_not_delivering("inverter-b")),
("row_delivery_check", GREEN, row_delivery_check("inverter-b", 30, 120)),
("row_delivery_check", GREEN, row_delivery_check("inverter-b", 0, 120)),
("row_no_estop", AMBER, row_no_estop()),
("row_estop_fitted", GREEN, row_estop_fitted()),
("row_no_peak_forecast", AMBER, row_no_peak_forecast()),
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("")),
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("kWh")),
("row_peak_guard_waiting", AMBER, row_peak_guard_waiting()),
("row_pv", GREEN, row_pv(3200, 2)),
("row_pv", GREEN, row_pv(1800, 1, True)),
("row_pv_none", AMBER, row_pv_none("no unit_of_measurement on the "
"entity - it cannot be read in watts")),
("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
("row_pv_partial", AMBER, row_pv_partial([2], 2)),
("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)),
("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)),
("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
("row_pv_stray", AMBER, row_pv_stray(2)),
("banner_stopped", None, banner_stopped()),
("banner_running", None, banner_running()),
("banner_maintenance", None, banner_maintenance("drain")),
("banner_maintenance", None, banner_maintenance("drain", "inverter-b")),
("row_control", GREEN, row_control(-4000, 5000, "inverter-b")),
("row_month_peak", GREEN, row_month_peak(
4998, datetime(2026, 8, 12, 19, 15), 0.83)),
("row_month_peak_none", GREEN, row_month_peak_none()),
("row_month_peak_unreadable", AMBER, row_month_peak_unreadable()),
("row_periods_measured", GREEN, row_periods_measured(120)),
("row_periods_measured", GREEN, row_periods_measured("unreadable")),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(5814, 5413, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(5814, 5413, True,
cut=True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(3814, 3500, False,
cut=True)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
shadow=True)),
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
False)),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", -1,
False)),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
True)),
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
("row_no_prices", AMBER, row_no_prices(*_SINCE_SAMPLE)),
("row_no_prices", AMBER, row_no_prices(*_SINCE_OLDER)),
("row_no_prices", AMBER, row_no_prices()),
]
def samples() -> list:
"""One rendering of every notice, with realistic values, for the test."""
return [
loop_bound(),
output_clamp([Clamp(None, 7412, 5000, "rating")]),
output_clamp(()),
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
Clamp("inverter-b", 6000, 4600, "max_w+device")]),
output_clamp([Clamp(None, 7412, 5000, "rating")], shadow=True),
output_clamp((), shadow=True),
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
Clamp("inverter-b", 6000, 4600, "max_w+device")],
shadow=True),
peak_saturated(5000),
peak_saturated(5000, shadow=True),
peak_reserve(5000, soc_unread=False, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"]),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"], others=True),
peak_reserve(5000, soc_unread=True, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=True),
circulating(["inverter-a"], ["inverter-b"]),
circulating(),
not_delivering("inverter-b", "inverter_b"),
meter_frozen(600),
meter_frozen(90),
maint_failed("charge", 7),
maint_failed("drain", 7, "inverter-b", "inverter_b"),
no_prices(*_SINCE_SAMPLE),
no_prices(*_SINCE_OLDER),
no_prices(),
]
# --- refusals (UX-08 phase 2c) --------------------------------------------------
# A refusal names a setting by its label on the Configuration tab and then by its
# key, because an installer editing in YAML needs the key. The label is the
# en.yaml name without its group prefix and unit; test_messages.py checks that.
LABELS = {
"setpoint_entity": "Inverter setpoint entity",
"max_w": "Inverter rating",
"bms_soc_floor": "Battery cut-out level",
"capacity_kwh": "Usable battery capacity",
"inverters": "Inverter list",
"name": "Name",
"meter_source": "Where the meter reading comes from",
"meter_phases": "Number of phases",
"p1_host": "HomeWizard P1 address",
"meter_poll_s": "Time between meter reads",
"meter_max_age_s": "Reading too old after",
"meter_mqtt_topic": "MQTT topic",
"p1_import_entity": "Grid import entity",
"p1_export_entity": "Grid export entity",
"p1_net_entity": "Signed net power entity",
"p1_phase_net_entities": "Net entities per phase",
"peak_soc_floor": "Peak reserve",
"price_provider": "Dynamic price source",
"price_taxes_eur_kwh": "Distribution, levies and VAT",
"static_profile": "Fixed or day/night tariff",
"price_taxes_profile": "Day/night levies and VAT",
"price_formula": "Your own tariff formula",
"price_export_formula": "Your own injection formula",
"price_csv_path": "Price CSV file",
"price_service_token": "Price service token",
"price_now_entity": "Current price entity",
"price_avg_entity": "Average price entity",
"arbitrage_enabled": "Trade on the price spread",
"cycle_cost_threshold_eur": "Battery cycle cost",
"arbitrage_soc_floor": "Trading keeps at least",
"arbitrage_soc_ceiling": "Trading charges at most",
"maintenance_charge_w": "Recharge rate",
"sign_disagree_w": "Wrong-direction threshold",
"unit_sign_disagree_w": "Per-inverter wrong-direction threshold",
"slew_w": "Largest change per cycle",
"shadow_mode": "Shadow mode",
# UX-13: named by the settings page's warnings (validate.Note).
"integrator_max_w": "Catch-up limit",
"maintenance_discharge_w": "Draw-down rate",
"peak_cap_w": "Peak limit",
"peak_margin_w": "Margin below the peak limit",
"peak_forecast_entity": "15-minute demand forecast entity",
"meter_entity": "Grid power entity",
"soc_entity": "Battery level entity",
"batt_entity": "Battery power entity",
"pv_entity": "Solar production entity",
"pv_entities": "Solar production entities, per inverter",
"pv_forecast_entity": "Solar forecast, today",
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
"pv_forecast_entities": "Several solar forecasts, today",
"pv_forecast_tomorrow_entities": "Several solar forecasts, tomorrow",
"solar_trust_pct": "Trust in the forecast",
"p1_phase_import_entities": "Import entities per phase",
"p1_phase_export_entities": "Export entities per phase",
}
def shown(value) -> str:
"""A setting's value inside a refusal: `'abc'`, `5`, or `empty` for a value
that is missing or blank, never Python's `None` (UX-08 2d)."""
if value is None or (isinstance(value, str) and not value.strip()):
return "empty"
return repr(value)
def not_valid(option: str, err) -> str:
"""A setting whose text does not parse, without the parser's own words:
`static_profile is not valid near character 6` (UX-08 2d)."""
pos = getattr(err, "pos", None)
if isinstance(pos, int):
return f"{option} is not valid near character {pos + 1}"
return f"{option} is not valid"
# The units a formula's "units" field may name, as a reader writes them.
UNIT_WORDS = {"c_eur_per_kwh": "c/kWh", "eur_per_kwh": "EUR/kWh"}
# UX-46: the same labels in Dutch, as translations/nl.yaml names them
# (test_messages.py checks each one).
LABELS_NL = {
"setpoint_entity": "Setpoint-entiteit van de omvormer",
"max_w": "Vermogen van de omvormer",
"bms_soc_floor": "Uitschakelniveau van de batterij",
"capacity_kwh": "Bruikbare capaciteit",
"inverters": "Lijst met omvormers",
"name": "Naam",
"meter_source": "Waar de meterstand vandaan komt",
"meter_phases": "Aantal fasen",
"p1_host": "Adres van de HomeWizard P1",
"meter_poll_s": "Tijd tussen twee uitlezingen",
"meter_max_age_s": "Meting te oud na",
"meter_mqtt_topic": "MQTT-topic",
"p1_import_entity": "Entiteit afname",
"p1_export_entity": "Entiteit injectie",
"p1_net_entity": "Entiteit nettovermogen met teken",
"p1_phase_net_entities": "Netto-entiteiten per fase",
"peak_soc_floor": "Piekreserve",
"price_provider": "Bron van dynamische prijzen",
"price_taxes_eur_kwh": "Distributie, heffingen en btw",
"static_profile": "Vast of dag/nacht-tarief",
"price_taxes_profile": "Heffingen en btw dag/nacht",
"price_formula": "Uw eigen tariefformule",
"price_export_formula": "Uw eigen injectieformule",
"price_csv_path": "CSV-bestand met prijzen",
"price_service_token": "Token van de prijsdienst",
"price_now_entity": "Entiteit huidige prijs",
"price_avg_entity": "Entiteit gemiddelde prijs",
"arbitrage_enabled": "Handelen op het prijsverschil",
"cycle_cost_threshold_eur": "Kosten van een batterijcyclus",
"arbitrage_soc_floor": "Handel houdt minstens",
"arbitrage_soc_ceiling": "Handel laadt hoogstens",
"maintenance_charge_w": "Laadvermogen",
"sign_disagree_w": "Drempel verkeerde richting",
"unit_sign_disagree_w": "Drempel verkeerde richting per omvormer",
"slew_w": "Grootste stap per cyclus",
"shadow_mode": "Schaduwmodus",
"integrator_max_w": "Inhaalgrens",
"maintenance_discharge_w": "Ontlaadvermogen",
"peak_cap_w": "Piekgrens",
"peak_margin_w": "Marge onder de piekgrens",
"peak_forecast_entity": "Entiteit kwartierprognose",
"meter_entity": "Entiteit netvermogen",
"soc_entity": "Entiteit laadniveau",
"batt_entity": "Entiteit batterijvermogen",
"pv_entity": "Entiteit zonneproductie",
"pv_entities": "Entiteiten zonneproductie, per omvormer",
"pv_forecast_entity": "Zonneprognose, vandaag",
"pv_forecast_tomorrow_entity": "Zonneprognose, morgen",
"pv_forecast_entities": "Meerdere zonneprognoses, vandaag",
"pv_forecast_tomorrow_entities": "Meerdere zonneprognoses, morgen",
"solar_trust_pct": "Vertrouwen in de prognose",
"p1_phase_import_entities": "Afname-entiteiten per fase",
"p1_phase_export_entities": "Injectie-entiteiten per fase",
}
# The inverter list shows two of them under a shorter name.
UNIT_LABELS_NL = {"setpoint_entity": "Setpoint-entiteit",
"max_w": "Vermogen van deze omvormer"}
def setting(key: str, unit: str = "", lang: str = "en") -> str:
"""`max_w` -> `“Inverter rating” (max_w)`. For one inverter in a list it is
`“Inverter rating” for inverter-b (inverters.max_w)`, so the config panel
also shows the reason beside `inverters`, where that value is changed.
In Dutch: `“Vermogen van de omvormer” (max_w)`, and `voor inverter-b`."""
if lang == "nl":
if unit:
return (f"“{UNIT_LABELS_NL.get(key, LABELS_NL[key])}” voor {unit} "
f"(inverters.{key})")
return f"“{LABELS_NL[key]}” ({key})"
if unit:
return f"“{LABELS[key]}” for {unit} (inverters.{key})"
return f"“{LABELS[key]}” ({key})"