A new price source, `price_provider: price_service`: the owner's price server,
one fixed HTTPS host, a bearer token (new password option
`price_service_token`), wholesale energy only so markup and taxes are added as
for generic_dayahead.
The chain is server, then energy-charts, then the prices the history store
kept. A step counts only if it has a price for now. Each step down and back up
is one log line. With every source down a site plans from what it saved.
No price for now: one status row, one Home plan line and one notification, in
English and Dutch, cleared when a price returns. With this provider the
maintenance charge gate reads the provider's prices and keeps the Home
Assistant rule: the current 15-minute price is at or below today's average.
The token is hidden on the settings page, refused when missing, and taken out
of any error text. Other providers, `off` and the Home Assistant pair take the
road they always took (scripted-day probe against 0ce7d35: one line differs,
the refusal that lists the known providers). See
DECISIONS/PRICE-01-own-price-server.md.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
1368 lines
58 KiB
Python
1368 lines
58 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
|
|
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"
|
|
T_NO_PRICES = "No prices"
|
|
T_NO_PRICES_NL = "Geen prijzen"
|
|
|
|
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"
|
|
|
|
# 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}")
|
|
|
|
|
|
def since_text(since, now) -> str:
|
|
"""PRICE-01: `00:00` for a moment today, `13 Oct at 21:00` for an earlier
|
|
day. Both arguments are already in the site's zone. Empty for no moment."""
|
|
if since is None:
|
|
return ""
|
|
return f"{since:%H:%M}" if since.date() == now.date() else when(since)
|
|
|
|
|
|
def no_prices(since_text: str = "") -> Notice:
|
|
"""PRICE-01: no source has a price for now. Said once, gone with the gap."""
|
|
if since_text:
|
|
en = (f"No prices since {since_text}, so the monthly battery cycle "
|
|
f"waits until they return.")
|
|
nl = (f"Geen prijzen sinds {since_text}, dus de maandelijkse "
|
|
f"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)
|
|
|
|
|
|
def row_no_prices(since_text: str = "") -> str:
|
|
"""PRICE-01: the status row while no price covers now."""
|
|
if since_text:
|
|
return f"No prices since {since_text}, maintenance waits."
|
|
return "No prices yet, maintenance waits."
|
|
|
|
|
|
def row_maint_waits(until_text: str) -> str:
|
|
"""MAINT-13: the status row while a failed cycle waits."""
|
|
return f"Waits until {until_text} after a cycle that did not finish."
|
|
|
|
|
|
# --- 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."
|
|
|
|
|
|
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 "Waiting for Home Assistant to finish starting"
|
|
|
|
|
|
def row_ha_waiting() -> str:
|
|
return ("Home Assistant is not answering, perhaps it is still starting. "
|
|
"Control resumes by itself when it answers.")
|
|
|
|
|
|
# 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=(), 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 = "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")
|
|
if others:
|
|
return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak "
|
|
f"limit alone. Home use still draws it lower, to the cut-out "
|
|
f"level.")
|
|
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_forecast_gap(day, missing, count) -> str:
|
|
"""OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based
|
|
positions of the forecast entities with no usable reading."""
|
|
which = names([str(n) for n in missing])
|
|
many = len(missing) > 1
|
|
return (f"Solar forecast for {day}: not used, source{'s' if many else ''} "
|
|
f"{which} of {count} {'have' if many else 'has'} no 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_unmeasured() -> str:
|
|
"""SAFETY-90. Before the first reading; that part counts as high use."""
|
|
return ("Peak guard: part of this 15-minute period was not measured, "
|
|
"counted as high use")
|
|
|
|
|
|
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_gap": "Meter is back, the peak guard counts the missed time as high use",
|
|
"peak_saturated": T_PEAK_SATURATED,
|
|
"peak_reserve": T_PEAK_RESERVE,
|
|
"meter_frozen": T_METER_FROZEN,
|
|
"power_too_high": T_OUTPUT_CLAMP,
|
|
"not_keeping_up": T_LOOP_BOUND,
|
|
"trade_charge": "Trading: charging while the price is low",
|
|
"trade_give": "Trading: giving power while the price is high",
|
|
"trade_off": "Trading: waiting for a better price",
|
|
"forecast_read": "Solar forecast read, the day plan uses it",
|
|
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
|
|
"forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading",
|
|
"split": "The power split between inverters changed",
|
|
"clash": "Two limits clash, inverters set to 0 W",
|
|
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
|
|
"unit_dropped": "{unit} left out of control, the others take its share",
|
|
"units_back": "Every inverter is back in control",
|
|
"unmet": "The inverters cannot give {w} of the power asked",
|
|
"unmet_gone": "The inverters can give all the power asked again",
|
|
"shadow_on": "Shadow mode on, inverters stay at 0 W",
|
|
"shadow_off": "Shadow mode off, the add-on controls the battery",
|
|
"shadow_decision": "Shadow mode: would ask the battery for {sw}",
|
|
"refused": "Settings refused, the add-on log shows why",
|
|
"inputs_missing": "A reading is missing, inverters set to 0 W",
|
|
"inputs_back": "All readings are back",
|
|
"stop_cleared": "{unit}: safety stop cleared by starting control",
|
|
"site_stop_cleared": "Safety stop cleared by starting control",
|
|
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
|
"maintenance": "Battery maintenance: {phase}",
|
|
"maint_paused": "Maintenance charging paused to stay under the peak limit",
|
|
"maint_failed": "The battery cycle did not finish, so it waits before trying again",
|
|
"pack_on": "{unit}: battery switched on",
|
|
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
|
|
"pack_mode": "{unit}: battery mode changed",
|
|
"pack_sell": "{unit}: selling switched",
|
|
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
|
|
"maint_waits": "{unit} cannot give power now, so its monthly cycle waits",
|
|
"mode_fault": "{unit}: battery mode ignored because of a software fault",
|
|
}
|
|
|
|
|
|
# OPT-11: why a battery switch or mode change was refused (the page shows it).
|
|
PACK_REFUSED = {
|
|
"off": "Battery modes are off. Turn on “Battery modes” first.",
|
|
"unit": "There is no battery with that name.",
|
|
"value": "That choice is not possible for this battery.",
|
|
# OPT-11 P3, owner decision 2026-09-29: selling is off for now.
|
|
"sell_off": "Selling is off for now.",
|
|
# OPT-11 P2: a Home Assistant command dropped before it reaches a battery.
|
|
"retained": "An old saved command is not used.",
|
|
"size": "That command is too long.",
|
|
"encoding": "That command is not plain text.",
|
|
"topic": "That is not a battery command.",
|
|
}
|
|
|
|
|
|
def pack_refused(why: str) -> str:
|
|
return PACK_REFUSED[why]
|
|
|
|
|
|
# MAINT-11: why a start or stop of ONE battery's cycle was refused. `{unit}` is
|
|
# the battery the sentence is about (for "busy", the one that is cycling). The
|
|
# page shows the same sentences in Dutch from app/ui_text, keyed by the code.
|
|
MAINT_REFUSED = {
|
|
"unit": "There is no battery with that name.",
|
|
"busy": "Only one battery can cycle at a time, and {unit} is cycling now.",
|
|
"off": "{unit} is switched off, so it cannot start a cycle.",
|
|
"idle": "{unit} is not running a cycle.",
|
|
}
|
|
|
|
|
|
def maint_refused(why: str, unit: str = "") -> str:
|
|
return MAINT_REFUSED[why].format(unit=unit)
|
|
|
|
|
|
def hint(key: str, **kw) -> str:
|
|
"""The plain words for one event. `w` is formatted as watts, `sw` as
|
|
watts with their sign (positive gives power, negative charges)."""
|
|
if "w" in kw:
|
|
kw["w"] = watts(kw["w"])
|
|
if "sw" in kw:
|
|
kw["sw"] = signed_watts(kw["sw"])
|
|
return HINTS[key].format(**kw)
|
|
|
|
|
|
def hint_samples() -> list:
|
|
"""`(key, text)` for every hint, rendered, for the test."""
|
|
kw = {"n": 2, "unit": "inverter-b", "w": 1200, "sw": -1200,
|
|
"phase": "drain"}
|
|
return [(k, hint(k, **{f: kw[f] for f in kw if "{" + f + "}" in v}))
|
|
for k, v in HINTS.items()]
|
|
|
|
|
|
RED, AMBER, GREEN = "bad", "warn", "ok"
|
|
|
|
|
|
def status_samples() -> list:
|
|
"""`(function name, colour, text)` for every row and banner, for the test.
|
|
|
|
The colour is the one main.py gives the row; a red row may use three
|
|
sentences. Banners are listed with the colour None.
|
|
"""
|
|
clamp = [Clamp(None, 7412, 5000, "rating")]
|
|
return [
|
|
("banner_not_ready", None, banner_not_ready(
|
|
{"text": row_meter_frozen()})),
|
|
("banner_not_ready", None, banner_not_ready(
|
|
{"text": "x", "banner": banner_peak_reserve()})),
|
|
("banner_circulating", None, banner_circulating()),
|
|
("banner_site_direction", None, banner_site_direction()),
|
|
("banner_direction", None, banner_direction(False)),
|
|
("banner_direction", None, banner_direction(True)),
|
|
("banner_not_delivering", None, banner_not_delivering(["inverter-b"])),
|
|
("banner_peak_reserve", None, banner_peak_reserve()),
|
|
("banner_power_too_high", None, banner_power_too_high()),
|
|
("banner_shadow", None, banner_shadow(False)),
|
|
("banner_setup_only", None, banner_setup_only()),
|
|
("row_setup_only", AMBER, row_setup_only()),
|
|
("banner_shadow", None, banner_shadow(True)),
|
|
("banner_not_ready", None, banner_not_ready(
|
|
{"text": row_ha_waiting(), "banner": banner_ha_waiting()})),
|
|
("banner_ha_waiting", None, banner_ha_waiting()),
|
|
("row_ha_waiting", RED, row_ha_waiting()),
|
|
("row_controller_starting", GREEN, row_controller_starting()),
|
|
("row_controller_ran", GREEN, row_controller_ran(2)),
|
|
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
|
("row_meter_stale", RED, row_meter_stale(40, 10)),
|
|
("row_meter_waiting", AMBER, row_meter_waiting(30)),
|
|
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
|
|
("row_meter_frozen", RED, row_meter_frozen()),
|
|
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
|
|
("row_freeze_on", GREEN, row_freeze_on(600, 4465, True)),
|
|
("row_peak_saturated", RED, row_peak_saturated(5000)),
|
|
("row_peak_guard_off", AMBER, row_peak_guard_off()),
|
|
("row_peak_reserve", RED, row_peak_reserve(
|
|
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
|
|
("row_peak_reserve", RED, row_peak_reserve(
|
|
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"],
|
|
others=True)),
|
|
("row_peak_reserve", RED, row_peak_reserve(
|
|
5000, soc_unread=True, floor_unknown=False)),
|
|
("row_peak_reserve", RED, row_peak_reserve(
|
|
5000, soc_unread=False, floor_unknown=True)),
|
|
("row_sensor", GREEN, row_sensor(LABEL_SOC, 54, "inverter-b")),
|
|
("row_sensor", GREEN, row_sensor(LABEL_BATTERY, -1200.0, "inverter-b")),
|
|
("row_sensor_unset", RED, row_sensor_unset(LABEL_GRID)),
|
|
("row_sensor_unusable", RED, row_sensor_unusable(LABEL_BATTERY,
|
|
"inverter-b")),
|
|
("row_control_unset", RED, row_control_unset("inverter-b")),
|
|
("row_control_missing", RED, row_control_missing("inverter-b")),
|
|
("row_control", GREEN, row_control(-5000, 5000, "inverter-b")),
|
|
("row_control_unset", RED, row_control_unset()),
|
|
("row_no_setpoint", AMBER, row_no_setpoint("inverter-b")),
|
|
("row_not_controllable", RED, row_not_controllable("inverter-b", 45,
|
|
30)),
|
|
("row_commands_refused", AMBER, row_commands_refused("inverter-b", 12,
|
|
30)),
|
|
("row_power_too_high", RED, row_power_too_high(clamp, 3)),
|
|
("row_power_too_high", RED, row_power_too_high(
|
|
[Clamp("inverter-b", 6000, 4600, "max_w+device")], 12)),
|
|
("row_power_too_high", RED, row_power_too_high((), 1)),
|
|
("row_power_too_high", RED, row_power_too_high(
|
|
[Clamp("inverter-b", 6000, 4600, "max_w+device")], 12,
|
|
shadow=True)),
|
|
("row_not_keeping_up", AMBER, row_not_keeping_up(48)),
|
|
("row_before", AMBER, row_before(T_OUTPUT_CLAMP, 2)),
|
|
("row_before", AMBER, row_before(T_LOOP_BOUND, 1)),
|
|
("row_write_refused", RED, row_write_refused("inverter-b")),
|
|
("row_direction_trip", RED, row_direction_trip()),
|
|
("row_direction_trip", RED, row_direction_trip("inverter-b")),
|
|
("row_direction_check", GREEN, row_direction_check(0, 60)),
|
|
("row_direction_check", GREEN, row_direction_check(12, 60,
|
|
"inverter-b")),
|
|
("row_site_direction_trip", RED, row_site_direction_trip()),
|
|
("row_site_direction_check", GREEN, row_site_direction_check(0, 60)),
|
|
("row_site_direction_check", GREEN, row_site_direction_check(5, 60)),
|
|
("row_circulating_trip", RED, row_circulating_trip()),
|
|
("row_circulating_check", GREEN, row_circulating_check(1200, 20, 60)),
|
|
("row_one_inverter", GREEN, row_one_inverter("Delivery check")),
|
|
("row_not_delivering", AMBER, row_not_delivering("inverter-b")),
|
|
("row_delivery_check", GREEN, row_delivery_check("inverter-b", 30, 120)),
|
|
("row_delivery_check", GREEN, row_delivery_check("inverter-b", 0, 120)),
|
|
("row_no_estop", AMBER, row_no_estop()),
|
|
("row_estop_fitted", GREEN, row_estop_fitted()),
|
|
("row_no_peak_forecast", AMBER, row_no_peak_forecast()),
|
|
("row_peak_guard_waiting", AMBER, row_peak_guard_waiting()),
|
|
("row_pv", GREEN, row_pv(3200, 2)),
|
|
("row_pv", GREEN, row_pv(1800, 1, True)),
|
|
("row_pv_none", AMBER, row_pv_none("no unit_of_measurement on the "
|
|
"entity - it cannot be read in watts")),
|
|
("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
|
|
("row_pv_partial", AMBER, row_pv_partial([2], 2)),
|
|
("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)),
|
|
("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)),
|
|
("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
|
|
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
|
|
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
|
("row_pv_stray", AMBER, row_pv_stray(2)),
|
|
("banner_stopped", None, banner_stopped()),
|
|
("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_unmeasured", AMBER, row_peak_guard_unmeasured()),
|
|
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
|
|
for k in BLIND_WORDS),
|
|
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
|
|
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
|
|
shadow=True)),
|
|
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
|
|
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
|
|
False)),
|
|
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", -1,
|
|
False)),
|
|
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
|
|
True)),
|
|
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
|
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
|
|
("row_no_prices", AMBER, row_no_prices("00:00")),
|
|
("row_no_prices", AMBER, row_no_prices("13 Oct at 21:00")),
|
|
("row_no_prices", AMBER, row_no_prices()),
|
|
]
|
|
|
|
|
|
def samples() -> list:
|
|
"""One rendering of every notice, with realistic values, for the test."""
|
|
return [
|
|
loop_bound(),
|
|
output_clamp([Clamp(None, 7412, 5000, "rating")]),
|
|
output_clamp(()),
|
|
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
|
|
Clamp("inverter-b", 6000, 4600, "max_w+device")]),
|
|
output_clamp([Clamp(None, 7412, 5000, "rating")], shadow=True),
|
|
output_clamp((), shadow=True),
|
|
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
|
|
Clamp("inverter-b", 6000, 4600, "max_w+device")],
|
|
shadow=True),
|
|
peak_saturated(5000),
|
|
peak_saturated(5000, shadow=True),
|
|
peak_reserve(5000, soc_unread=False, floor_unknown=False),
|
|
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
|
packs=["inverter-b"]),
|
|
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
|
packs=["inverter-b"], others=True),
|
|
peak_reserve(5000, soc_unread=True, floor_unknown=False),
|
|
peak_reserve(5000, soc_unread=False, floor_unknown=True),
|
|
circulating(["inverter-a"], ["inverter-b"]),
|
|
circulating(),
|
|
not_delivering("inverter-b", "inverter_b"),
|
|
meter_frozen(600),
|
|
meter_frozen(90),
|
|
maint_failed("charge", 7),
|
|
maint_failed("drain", 7, "inverter-b", "inverter_b"),
|
|
no_prices("00:00"),
|
|
no_prices("13 Oct at 21:00"),
|
|
no_prices(),
|
|
]
|
|
|
|
|
|
# --- refusals (UX-08 phase 2c) --------------------------------------------------
|
|
# A refusal names a setting by its label on the Configuration tab and then by its
|
|
# key, because an installer editing in YAML needs the key. The label is the
|
|
# en.yaml name without its group prefix and unit; test_messages.py checks that.
|
|
LABELS = {
|
|
"setpoint_entity": "Inverter setpoint entity",
|
|
"max_w": "Inverter rating",
|
|
"bms_soc_floor": "Battery cut-out level",
|
|
"capacity_kwh": "Usable battery capacity",
|
|
"inverters": "Inverter list",
|
|
"name": "Name",
|
|
"meter_source": "Where the meter reading comes from",
|
|
"meter_phases": "Number of phases",
|
|
"p1_host": "HomeWizard P1 address",
|
|
"meter_poll_s": "Time between meter reads",
|
|
"meter_max_age_s": "Reading too old after",
|
|
"meter_mqtt_topic": "MQTT topic",
|
|
"p1_import_entity": "Grid import entity",
|
|
"p1_export_entity": "Grid export entity",
|
|
"p1_net_entity": "Signed net power entity",
|
|
"p1_phase_net_entities": "Net entities per phase",
|
|
"peak_soc_floor": "Peak reserve",
|
|
"price_provider": "Dynamic price source",
|
|
"price_taxes_eur_kwh": "Distribution, levies and VAT",
|
|
"static_profile": "Fixed or day/night tariff",
|
|
"price_taxes_profile": "Day/night levies and VAT",
|
|
"price_formula": "Your own tariff formula",
|
|
"price_export_formula": "Your own injection formula",
|
|
"price_csv_path": "Price CSV file",
|
|
"price_service_token": "Price service token",
|
|
"price_now_entity": "Current price entity",
|
|
"price_avg_entity": "Average price entity",
|
|
"arbitrage_enabled": "Trade on the price spread",
|
|
"cycle_cost_threshold_eur": "Battery cycle cost",
|
|
"arbitrage_soc_floor": "Trading keeps at least",
|
|
"arbitrage_soc_ceiling": "Trading charges at most",
|
|
"maintenance_charge_w": "Recharge rate",
|
|
"sign_disagree_w": "Wrong-direction threshold",
|
|
"unit_sign_disagree_w": "Per-inverter wrong-direction threshold",
|
|
"slew_w": "Largest change per cycle",
|
|
"shadow_mode": "Shadow mode",
|
|
# UX-13: named by the settings page's warnings (validate.Note).
|
|
"integrator_max_w": "Catch-up limit",
|
|
"maintenance_discharge_w": "Draw-down rate",
|
|
"peak_cap_w": "Peak limit",
|
|
"peak_margin_w": "Margin below the peak limit",
|
|
"peak_forecast_entity": "15-minute demand forecast entity",
|
|
"meter_entity": "Grid power entity",
|
|
"soc_entity": "Battery level entity",
|
|
"batt_entity": "Battery power entity",
|
|
"pv_entity": "Solar production entity",
|
|
"pv_entities": "Solar production entities, per inverter",
|
|
"pv_forecast_entity": "Solar forecast, today",
|
|
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
|
|
"pv_forecast_entities": "Several solar forecasts, today",
|
|
"pv_forecast_tomorrow_entities": "Several solar forecasts, tomorrow",
|
|
"solar_trust_pct": "Trust in the forecast",
|
|
"p1_phase_import_entities": "Import entities per phase",
|
|
"p1_phase_export_entities": "Export entities per phase",
|
|
}
|
|
|
|
|
|
def shown(value) -> str:
|
|
"""A setting's value inside a refusal: `'abc'`, `5`, or `empty` for a value
|
|
that is missing or blank, never Python's `None` (UX-08 2d)."""
|
|
if value is None or (isinstance(value, str) and not value.strip()):
|
|
return "empty"
|
|
return repr(value)
|
|
|
|
|
|
def not_valid(option: str, err) -> str:
|
|
"""A setting whose text does not parse, without the parser's own words:
|
|
`static_profile is not valid near character 6` (UX-08 2d)."""
|
|
pos = getattr(err, "pos", None)
|
|
if isinstance(pos, int):
|
|
return f"{option} is not valid near character {pos + 1}"
|
|
return f"{option} is not valid"
|
|
|
|
|
|
# The units a formula's "units" field may name, as a reader writes them.
|
|
UNIT_WORDS = {"c_eur_per_kwh": "c/kWh", "eur_per_kwh": "EUR/kWh"}
|
|
|
|
|
|
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})"
|