Files
goodwe-addon/goodwe_controller/app/messages.py
T
glenn schrooyenandClaude Opus 5.5 1c41a1e412
checks / unit (pull_request) Successful in 24m23s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-86 review: hold the site at the peak floor while a HOLD gives
At N>=2 the holding pack's pin follows the guard's floor while the loop
still asks for +5000 W, so the ALLOCATOR "could not be placed" row was
re-logged on every 1 W change and filled the 40-row action log. While the
pin is raised, cycle() now files a site `hold` ceiling at the floor
(P_MAINTENANCE), as SAFETY-84 does for a charge. Filed only when the
holding pack gives; hi == the floor, so it never clashes with it.

Tests: a noisy N=2 house logs no ALLOCATOR row and at most one clamp
row (20 and 20 without the ceiling); the ceiling is the floor itself;
a CHARGE is not refiled at N=1. The DECISIONS entry records the owner's
2026-10-01 decision: the HOLD ends on its clock, no top-up.

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

1216 lines
51 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"
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
@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{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"
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"
# 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=()) -> 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 (%)".
"""
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."
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.")
# --- 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"
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 banner_not_ready(row: dict) -> str:
return f"{NOT_READY} — {row.get('banner') or headline(row['text'])}"
def banner_circulating() -> str:
return "Inverters worked against each other. Every inverter is set to 0 W."
def banner_site_direction() -> str:
return "Total power went the wrong way. All inverters at 0 W."
def banner_direction(several: bool) -> str:
if several:
return "An inverter's own power went the wrong way. Set to 0 W."
return "The meter saw power go the wrong way. Inverter at 0 W."
def banner_not_delivering(units) -> str:
units = list(units)
verb = "are" if len(units) > 1 else "is"
return f"{names(units)} {verb} not delivering. Control keeps running."
def banner_stopped() -> str:
return "Stopped — the inverters are set to 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 "Setup: nothing is controlled yet"
def row_setup_only() -> str:
return "No inverter is named yet, so finish Setup and restart the 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 ("Shadow mode: battery not controlled. A safety stop stays on, "
"see below.")
return "Shadow mode: the battery is not controlled. Inverters stay at 0 W."
# 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 "Control: starting"
def row_controller_ran(age_s) -> str:
return f"Control: last ran {seconds(age_s)} ago"
def row_controller_stalled(age_s, veto_s) -> str:
return (f"Control has not run for {seconds(age_s)}. The control board "
f"sets the inverter to 0 W after {seconds(veto_s)}.")
def row_meter_waiting(limit_s) -> str:
"""UX-10: no reading yet since the start, and still inside the limit."""
return f"Meter: waiting for the first reading, limit {seconds(limit_s)}"
def row_meter_stale(age_s, limit_s) -> str:
return (f"Meter: no reading for {seconds(age_s)}, limit "
f"{seconds(limit_s)}. The add-on log shows why.")
def row_meter(net_w, age_s, rejected=0, unchanged_s=None) -> str:
return (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)}"))
def row_meter_frozen() -> str:
return (f"{T_METER_FROZEN}. Control still follows it. Check the meter, "
f"and stop control if it is stuck.")
# Settings are named by their labels in translations/en.yaml.
METER_SOURCE = "“Where the meter reading comes from”"
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",
}
def row_freeze_off(why: str) -> str:
"""`why` is a key of FREEZE_OFF: why the stuck-meter check cannot run."""
return f"Stuck-meter check: off, {FREEZE_OFF[why]}"
def row_freeze_on(hold_s, rail_w, own_limit: bool) -> str:
return (f"Stuck-meter check: alerts after {duration(hold_s)} unchanged "
f"above {watts(rail_w)}"
+ (", the inverter's own limit" if own_limit else ""))
# Rows: the peak guard.
def row_peak_saturated(ceiling_w) -> str:
return (f"{T_PEAK_SATURATED}. Switch off large loads to stay under the "
f"{watts(ceiling_w)} peak limit.")
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> str:
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
if soc_unread:
why = "Battery level cannot be read"
elif floor_unknown:
why = "A battery cut-out level is not set"
else:
why = (f"{names(packs)} battery at its peak reserve" if packs
else "Battery at its peak reserve")
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
f"use still draws the battery below it, down to the cut-out level.")
def row_peak_guard_off() -> str:
"""`meter_source` is off: the 15-minute average cannot be measured."""
return f"Peak guard: off, set {METER_SOURCE} to anything but off"
def banner_peak_reserve() -> str:
return T_PEAK_RESERVE
# Rows: the sensors and the inverter control.
LABEL_SOC = "Battery level"
LABEL_BATTERY = "Battery power"
LABEL_GRID = "Grid power"
def row_sensor(label, value, unit=None) -> str:
shown = f"{float(value):g} %" if label.startswith(LABEL_SOC) \
else signed_watts(value)
return f"{label}{for_unit(unit)}: {shown}"
def row_sensor_unset(label, unit=None) -> str:
"""The setting is "<label> entity" (e.g. "Grid power entity (W)")."""
return f"{label}{for_unit(unit)}: “{label} entity” is not set"
def row_sensor_unusable(label, unit=None) -> str:
"""SAFETY-56: it may be missing OR held back as stale; say both."""
return (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.")
def setpoint_label(unit=None) -> str:
"""The label: "Setpoint entity" in the inverter list, else its long form."""
return "“Setpoint entity”" if unit else "“Inverter setpoint entity”"
def row_control_unset(unit=None) -> str:
return f"Inverter control{for_unit(unit)}: {setpoint_label(unit)} is not set"
def row_control_missing(unit=None) -> str:
return f"Inverter control{for_unit(unit)}: not found in Home 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 f"{name}: “Setpoint entity” is not set, so every inverter stays at 0 W"
def row_not_controllable(name, held_s, limit_s) -> str:
"""Red. The inverter is dropped and the others carry its share."""
return (f"{name} cannot be controlled. It refused every command for "
f"{seconds(held_s)}, so control leaves it out.")
def row_commands_refused(name, held_s, limit_s) -> str:
"""Amber. Refused, but not for long enough to be dropped yet."""
return (f"{name}: commands refused for {seconds(held_s)}, left out of "
f"control after {seconds(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"
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 f"{head}, from {w(lo_w)} to {w(hi_w)}"
if abs(float(lo_w)) == float(hi_w):
return f"{head}, up to {watts(hi_w)} each way"
return f"{head}, {watts(lo_w)} charging, {watts(hi_w)} giving"
# 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")
return (f"{head}: {what} {times(count)} since start. Software fault, "
f"please report it.")
def banner_power_too_high() -> str:
return T_OUTPUT_CLAMP
def row_not_keeping_up(count) -> str:
return f"{T_LOOP_BOUND}: {times(count)} since start"
def row_before(title, count) -> str:
"""After an episode clears: how often it happened, so the evidence stays."""
return f"{title}: {times(count)} since start, fine now"
def row_write_refused(unit=None) -> str:
return (f"{unit or 'The inverter'} refused the last command. Check the "
f"add-on log.")
# 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")
return (f"{what} set to 0 W. Find out why, then start control. A restart "
f"does not clear this.")
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")
if held_s:
return f"{who}: opposite for {held_s:.0f} of {seconds(hold_s)}"
return f"{who}: on"
def row_site_direction_trip() -> str:
return ("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.")
def row_site_direction_check(held_s, hold_s) -> str:
if held_s:
return (f"Site direction check: opposite for {held_s:.0f} of "
f"{seconds(hold_s)}")
return "Site direction check: on, against the meter"
# Rows: circulating power (SAFETY-31) and delivery (SAFETY-42).
def row_circulating_trip() -> str:
return ("Inverters worked against each other, so every inverter is set to "
"0 W. The meter cannot see this. Only starting control clears this.")
CHECK_CIRCULATING = "Check for inverters working against each other"
def row_circulating_check(now_w, held_s, hold_s) -> str:
if held_s:
return (f"{CHECK_CIRCULATING}: {watts(now_w or 0)} for {held_s:.0f} of "
f"{seconds(hold_s)}")
return (f"{CHECK_CIRCULATING}: on"
+ (f", now {watts(now_w)}" if now_w else ""))
def row_one_inverter(check: str) -> str:
return f"{check}: not needed with one inverter"
def row_not_delivering(unit) -> str:
return (f"{unit} is not delivering, and only stopping and starting control "
f"clears this")
def row_delivery_check(unit, short_s, hold_s) -> str:
if short_s:
return (f"Delivery check for {unit}: short for {short_s:.0f} of "
f"{seconds(hold_s)}")
return f"Delivery check for {unit}: on"
def row_estop_fitted() -> str:
return "Emergency stop: fitted"
def row_no_peak_forecast() -> str:
return ("No peak forecast set, so maintenance charging is not kept under "
"the peak limit")
def row_peak_guard_waiting() -> str:
return "Peak guard: no decision yet, control has not run"
def row_no_estop() -> str:
return ("No emergency stop fitted: if this computer fails, the battery "
"keeps its last command")
# 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”"
def source(n, of) -> str:
return "the entity" if of == 1 else f"source {n}"
def pv_problem(why: str) -> str:
"""`pv.watts()`'s reason, in plain words."""
w = str(why)
if w.startswith("no state"):
return "the entity is missing"
if w.startswith("entity is"):
return "the entity is unavailable"
if w.endswith("is not numeric"):
return "the reading is not a number"
if w.startswith("no unit"):
return "the entity has no unit"
if w.startswith("unit "):
return "the entity's unit is not a power unit"
return "the reading is not usable"
def row_pv(total_w, count, reversed_=False) -> str:
return (f"Solar production: {signed_watts(total_w)}"
+ (f" from {count} sources" if count > 1 else "")
+ (", sign reversed" if reversed_ else ""))
def row_pv_none(why) -> str:
return f"Solar production: no reading, {pv_problem(why)}"
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])
return (f"Solar production: no total, source{'s' if len(missing) > 1 else ''} "
f"{which} of {count} {'have' if len(missing) > 1 else 'has'} no "
f"reading")
def row_pv_single_ignored() -> str:
return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it"
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)
return (f"Solar production: {who} reads {signed_watts(value_w)}, "
f"{fix} {PV_REVERSED}")
def row_pv_stray(n) -> str:
return (f"Solar production: entry {n} of {PV_REVERSED} is not a solar "
f"entity in use")
# 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 (f"Month peak: {watts(peak_w)} on {at.day} {at:%b} at {at:%H:%M}, "
f"{frac * 100:.0f} % of the peak limit")
def row_month_peak_none() -> str:
return "Month peak: none recorded yet this month"
def row_month_peak_unreadable() -> str:
"""The store did not answer. This is not a zero peak (TARIFF-01)."""
return "Month peak: cannot be read, so only the peak limit is used"
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"
return f"15-minute periods measured this month: {shown}"
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool) -> str:
"""The projection first and the limit second (SAFETY-38's ordered pair)."""
limit = (f"this month's {watts(limit_w)} peak" if month_peak
else f"the {watts(limit_w)} peak limit")
return f"Peak guard: idle, heading for {watts(projected_w)}, under {limit}"
def row_peak_guard_quiet() -> str:
return "Peak guard: idle"
BLIND_WORDS = {
"no_block": "waiting for meter readings",
"gap": "meter readings have a gap",
"frozen": "meter reading is stuck",
}
def row_peak_guard_blind(why: str) -> str:
"""`why` is a key of BLIND_WORDS. The battery may give power, not charge."""
return (f"Peak guard: {BLIND_WORDS[why]}, so the battery does not charge "
f"meanwhile")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
f"{watts(limit_w)}")
return (f"Peak guard: asks the battery for {watts(floor_w)} to stay under "
f"{watts(limit_w)}")
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 (f"{unit}: battery power reading stopped changing, so it is not used "
f"for now")
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"
if probing:
return f"{unit}: testing if the battery will {way} power again"
return f"{unit}: the battery refuses to {way} power, the others take 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_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",
"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",
"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]
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"
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)),
("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=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_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_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()),
("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_quiet", GREEN, row_peak_guard_quiet()),
*(("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)),
]
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=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),
]
# --- 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_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",
"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"}
def setting(key: str, unit: str = "") -> 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."""
if unit:
return f"“{LABELS[key]}” for {unit} (inverters.{key})"
return f"“{LABELS[key]}” ({key})"