Files
goodwe-addon/goodwe_controller/app/messages.py
T
glenn schrooyenandClaude Opus 5.5 2c516a032c
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
UX-08 2c: short refusals, error texts and option texts
Validator refusals name the setting by its label and key, say what is
wrong and what to change, and keep the rejected value and limit. Option
names and descriptions are short in en and nl; citations moved to DOCS
"For installers" and the test_translations citation check now pins the
instruction on the tab and the record in DOCS. No rule, key, schema,
default or exit code changed. Allowlist 167 -> 43 (log lines only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 05:55:45 +02:00

849 lines
34 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
# 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"
def times(n) -> str:
return "1 time" if int(n) == 1 else f"{int(n)} times"
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 command and meter point opposite ways. All inverters at 0 W."
def banner_direction(several: bool) -> str:
if several:
return "An inverter's own power opposes its command. It is at 0 W."
return "Command and meter point opposite ways. Inverter set to 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_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_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:
def w(v):
return "?" if v is None else signed_watts(v)
return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}"
# 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}: command and its own power point opposite ways, so it is"
if unit else "Command and meter point opposite ways, 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 ("Total command and meter point opposite ways, 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.")
def row_circulating_check(now_w, held_s, hold_s) -> str:
return ("Circulating power check: on"
+ (f", now {watts(now_w)}" if now_w else "")
+ (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s 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")
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_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", 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)),
]
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",
}
def setting(key: str, unit: str = "") -> str:
"""`max_w` -> `“Inverter rating” (max_w)`, plus ` for inverter-b` in a list."""
return f"“{LABELS[key]}” ({key})" + (f" for {unit}" if unit else "")