"""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//. 2026.5 dropped the old `hassio` panel, so # /hassio/addon// 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_readings_late(delay_s, zero=False) -> str: """SAFETY-128: readings show the power request late; `delay_s` the batteries' fitted delay (their own answer included), or None when only the meter has shown it; past the hold, 0 W.""" if delay_s is None: if zero: return Bi("Home Assistant readings show requests late, so every " "inverter is at 0 W.", "Metingen uit Home Assistant tonen vermogensvraag te " "laat, omvormers op 0 W.") return Bi("Home Assistant readings show requests late, so control " "holds the batteries.", "Metingen uit Home Assistant tonen vermogensvraag te laat, " "dus de batterijen wachten.") if zero: return Bi(f"Home Assistant readings show requests {seconds(delay_s)} " f"late, so every inverter is at 0 W.", f"Metingen uit Home Assistant tonen vermogensvraag " f"{seconden(delay_s)} te laat, omvormers op 0 W.") return Bi(f"Home Assistant readings show requests about {seconds(delay_s)} " f"late, so control holds the batteries.", f"Metingen uit Home Assistant tonen vermogensvraag zo'n " f"{seconden(delay_s)} te laat, dus de batterijen wachten.") 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 "