UX-08 2d: plain log lines, the rest of the status page, and DOCS
checks / unit (pull_request) Successful in 8m55s
infra-pin / fresh (pull_request) Successful in 1s

Every WARNING and ERROR line is one plain line of at most 25 words, with
no card ids, capitals or semicolons; the reasoning of the ten longest is
on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0).

The status page: the month-peak and peak-guard rows, the stale-reading
row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the
Stopped banner and the line under the buttons, which now shows the plain
hint of each event instead of the raw action log. The Supervisor question
at start logs one line with a real reason.

Refusals are counted as shown (labels expanded, long cases driven), seven
were shortened, the card and parse errors lost their internals, a blank
value reads "empty", and the panel no longer files inverters.max_w under
the flat max_w. Option names lose "(JSON)", the NL texts are checked too.

DOCS gets a glossary (control board, isolator, Developer Tools) and card
ids move from headings to a "For installers" line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
2026-09-27 08:01:16 +02:00
co-authored by Claude Opus 5.5
parent a02cd4b713
commit 6a95447248
38 changed files with 1294 additions and 742 deletions
+300 -214
View File
@@ -73,7 +73,7 @@ from . import packshare
from . import pv as pv_read
from . import shadow
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option, provider_configured
from .price import Uncertainty, brussels, price_option, provider_configured
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
@@ -267,14 +267,15 @@ def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
past `max_age_s`, is the exact state the soak measured; rendering it as
the bare static sentence is how it stayed invisible for 802 samples.
"""
# UX-08: plain words, every figure kept. The tick's sentence is "now";
# the plan carries its own age.
if plan is None:
if not provider:
return tick_words
return (f"{tick_words} (recomputed this tick); NO provider plan - "
f"none has been resolved yet, or the last one is older than "
f"{max_age_s:.0f} s")
return (f"{tick_words} (recomputed this tick); provider plan fetched "
f"{age_s:.0f} s ago for a {horizon_h:.0f} h horizon: {plan.words}")
return (f"{tick_words}. No price plan: none yet, or the last one is "
f"over {max_age_s:.0f} s old")
return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for "
f"{horizon_h:.0f} hours ahead: {plan.words}")
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
@@ -312,21 +313,21 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
render, from one that never reports it - and the bound TARIFF-10's
comment claimed did not exist is ~15 s of it.
"""
# UX-08: plain words; the setting is named by its label.
if not on:
return "off - `arbitrage_enabled` is false, so nothing trades"
return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not "
f"switched on")
if decided_at is None:
return (f"ON (`arbitrage_enabled`), but NO DECISION has been reached "
f"yet - no cycle has run as far as the arbitrage step. The "
f"loop's own reason: {why or 'none recorded'}")
return (f"On, but no decision yet, because control has not got that "
f"far. Control says: {why or 'no reason recorded'}")
age = (now - decided_at).total_seconds()
stale = cycle_at is not None and decided_at < cycle_at
head = (f"no decision this cycle ({why or 'no reason recorded'}); the "
f"last one was taken {age:.0f} s ago: " if stale else
f"decided {age:.0f} s ago: ")
head = (f"On, no decision this cycle ({why or 'no reason recorded'}), "
f"the last one {age:.0f} s ago: " if stale else
f"On, decided {age:.0f} s ago: ")
if decision is None:
return (f"ON (`arbitrage_enabled`), no decision this cycle "
f"({why or 'no reason recorded'}), and none stands from an "
f"earlier one")
return (f"On, no decision this cycle ({why or 'no reason recorded'}) "
f"and none before")
return head + decision.explain()
@@ -354,6 +355,8 @@ class Controller:
# their behalf, and a guard that raises AttributeError on the way to
# saying so is a guard that stops the add-on booting.
self.events: list[str] = []
# UX-08: the last event in plain words, for the page. See log_event().
self.hint = ""
# --- DEV-02: THE ROSTER, BUILT BEFORE ANYTHING THAT READS A RATING ---
# ⚠️ EVERY PER-DEVICE VALUE IN THIS CONTROLLER LIVES ON ONE OF THESE
@@ -467,7 +470,8 @@ class Controller:
"soc_entity / batt_entity / batt_invert / max_w keys are NOT "
"read in this shape; each entry carries its own."
% (len(self.inverters),
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)))
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)),
hint=messages.hint("roster", n=len(self.inverters)))
self.tuning = Tuning(
gain=float(opts.get("gain", 0.6)),
@@ -541,7 +545,8 @@ class Controller:
# recalibrated on schedule and one that silently is not, which is
# §12's opening sentence, so it is said where an operator looks.
self.log_event("maintenance history did NOT migrate cleanly: "
+ self.maint_migration.explain())
+ self.maint_migration.explain(),
hint=messages.hint("maint_history"))
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
# and the procedure is the same on every pack in the roster; what is per
@@ -590,11 +595,9 @@ class Controller:
self._grade_store_migration(store)
for inv in self.inverters:
if inv.sign_wd.tripped:
_LOG.error("SAFETY-02 was TRIPPED on %s before this restart and "
"stays tripped: %s. That inverter is held at 0 W. "
"Restarting the add-on does not clear this - start "
"control from the UI once you know why the command "
"opposed the meter.", inv.name,
_LOG.error("%s stays at 0 W after the restart because its "
"power went the wrong way: %s. Start control once "
"you know why.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
@@ -616,11 +619,9 @@ class Controller:
for inv in self.inverters:
if inv.delivery_wd.tripped:
_LOG.error(
"SAFETY-42 was TRIPPED on %s before this restart and stays "
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
"guard reports and does not act - so if that inverter is "
"still not delivering, the site is still short by its "
"share. Restarting does not clear this.",
"%s is still marked as not delivering after the restart: "
"%s. Control keeps running, and only stopping and starting "
"control clears this.",
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
@@ -650,10 +651,9 @@ class Controller:
reason=str(store.data.get("site_sign_reason", "") or ""))
if self.site_sign_wd.tripped:
_LOG.error(
"SAFETY-30 (site) was TRIPPED before this restart and stays "
"tripped: %s. Every inverter is held at 0 W. Restarting does "
"not clear this - start control from the UI once you know why "
"the site's total command opposed the meter.",
"Every inverter stays at 0 W after the restart because the "
"total power went the wrong way: %s. Start control once you "
"know why.",
self.site_sign_wd.reason or "(reason not recorded)")
# And the per-unit detectors are told which question they are asking,
# here as well as in `cycle()`. `checks()` and the status page read
@@ -704,10 +704,9 @@ class Controller:
self._announced_mono = {}
if self.circ_wd.tripped:
_LOG.error(
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
"%s. Every inverter is held at 0 W. Restarting does not clear "
"this - start control from the UI once you know why two "
"inverters were pushing against each other.",
"Every inverter stays at 0 W after the restart because the "
"inverters worked against each other: %s. Start control once "
"you know why.",
self.circ_wd.reason or "(reason not recorded)")
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
@@ -913,6 +912,7 @@ class Controller:
# than re-derived a cycle later. Starts False: nothing is on the wire.
self.following = False
self.reason = "starting"
self.why_words = "Starting"
# ALLOC-01. The allocation record from the last resolution, or None
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
@@ -959,7 +959,7 @@ class Controller:
# TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window:
# no" is an evidenced answer rather than a bare no.
self.tariff_words = "no static profile"
self.tariff_words = "No fixed tariff set"
# OPT-04. The tariff plan the BACKGROUND task last resolved, and when.
# cycle() reads these two attributes and never awaits a fetch - see
@@ -1005,10 +1005,15 @@ class Controller:
self.loop_pass_mono = None
# -- helpers -------------------------------------------------------------
def log_event(self, msg: str) -> None:
def log_event(self, msg: str, hint: str | None = None) -> None:
"""`msg` goes on the action log, the add-on's own technical record.
`hint` is `messages.hint()`'s plain words for the same event, and is
what the page shows under the buttons (UX-08)."""
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
if hint is not None:
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
def _grade_store_migration(self, store) -> None:
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
@@ -1066,9 +1071,9 @@ class Controller:
"""
rep = self.store_migration
if rep.ran and not rep.ok:
_LOG.error("SAFETY-02: the per-inverter store migration did not "
"verify (%s). The latch is being re-derived from the "
"legacy keys.", rep.explain())
_LOG.error("The saved safety stops did not copy over cleanly "
"(%s), so they are read again from the older saved "
"state.", rep.explain())
data = getattr(store, "data", {})
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
@@ -1194,10 +1199,8 @@ class Controller:
return
if stranded:
_LOG.error(
"SAFETY-44: this store carries a LATCHED sign-disagreement "
"failsafe under %s, and that unit is NOT on today's roster "
"%s - so no machine reads it and the site came up DRIVING on "
"a state file that says it is latched. Re-arming below.",
"A saved safety stop belongs to %s, which is not in the "
"inverter list %s, so it is set again on the first inverter.",
", ".join(sorted(stranded)), sorted(on_roster))
first = self.inverters[0]
# The one state this method exists for.
@@ -1209,17 +1212,18 @@ class Controller:
first.store_set("sign_reason", first.sign_wd.reason)
first.store_set("sign_tripped", True)
_LOG.error(
"SAFETY-02 LATCH GUARD: this store carries a LATCHED "
"sign-disagreement failsafe under the pre-DEV-02 flat key and NO "
"inverter came up latched. Either the per-inverter migration lost "
"it, or it was written by an older add-on while this install was "
"rolled back. Both are a disarmed safety guard on an install whose "
"state file says it is protected, so the latch has been re-armed "
"on %s from the legacy value: %s. %s is held at 0 W until control "
"is started from the UI.",
first.name, first.sign_wd.reason, first.name)
"%s is set to 0 W again, because an older saved safety stop was "
"found: %s. Start control once you know why.",
first.name, first.sign_wd.reason)
_LOG.debug(
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
"sign-disagreement failsafe is latched and no inverter came up "
"latched. Either the per-inverter migration lost it, or an older "
"add-on wrote it during a rollback. Both would be a disarmed "
"guard, so the latch is re-armed on %s.", first.name)
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
+ first.sign_wd.reason)
+ first.sign_wd.reason,
hint=messages.hint("stop_restored", unit=first.name))
# -- the roster, and the N=1 views of it ---------------------------------
@property
@@ -1805,7 +1809,7 @@ class Controller:
now_p = await self.hass.number(now_e)
avg_p = await self.hass.number(avg_e)
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self.tariff_words = "Home Assistant price entities"
self.tariff_words = "Prices from Home Assistant entities"
else:
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
@@ -1851,7 +1855,8 @@ class Controller:
if self.inputs_ok:
if self.inputs_bad_since is not None:
self.log_event("inputs recovered")
self.log_event("inputs recovered",
hint=messages.hint("inputs_back"))
self.inputs_bad_since = None
elif self.inputs_bad_since is None:
self.inputs_bad_since = datetime.now(timezone.utc)
@@ -1886,7 +1891,8 @@ class Controller:
# §11.1 rule 1 and is already what a missing reading does here.
inv.batt = inv.batt_fresh.reading()
if inv.batt_fresh.stale and not was_stale:
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
hint=messages.hint("battery_stuck", unit=inv.name))
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
# ELSE IN IT. The escape clause used to read "It clears itself the
# moment the register reports a different value", full stop - and
@@ -1913,26 +1919,31 @@ class Controller:
# this line already interpolates, is where the pair is spelled out.
# Saying it in both halves of one sentence is how an operator
# learns to skim it.
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
"ON THE REGISTER - %s. Treating it as MISSING, which "
"commands 0 W. It clears itself the moment the register "
"reports a different value, and failing that %.0f s "
"after the standing command falls below %.0f W "
"(SAFETY-57: a verdict whose own consequence is a 0 W "
"command cannot be tested again from inside it).",
inv.name, inv.batt_fresh.reason,
inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w)
# UX-08 2d: plain, with SAFETY-57's escape clause kept as an
# ordered pair (time, then power), and the reason still in `%s`.
_LOG.error("%s: battery power reading stopped changing. Inverters "
"go to 0 W until it changes, or %.0f s after the "
"command drops below %.0f W: %s",
inv.name, inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
elif inv.batt_fresh.releases > was_releases:
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
# timer and says nothing is a latch no operator can audit - and this
# one is withdrawing a SITE STOP. `release_note()` is written to say
# that nothing has been learned about the register, because nothing
# has.
self.log_event(f"STALE WITHDRAWN {inv.name}")
_LOG.warning("SAFETY-57: %s",
inv.batt_fresh.release_note(inv.name))
self.log_event(f"STALE WITHDRAWN {inv.name}",
hint=messages.hint("battery_back", unit=inv.name))
# Not "recovered": nothing is known about the reading yet, so the
# line gives the value it still holds and the window it is
# rechecked over (SAFETY-57).
_LOG.warning("%s: the command stayed below %.0f W for %.0f s, so "
"battery power reading %+.0f W is used again and "
"rechecked over %.0f s.", inv.name,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.release_s, inv.batt_fresh.held,
inv.batt_fresh.bound.unchanged_s)
_LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name))
@property
def shadow(self) -> bool:
@@ -2066,14 +2077,15 @@ class Controller:
self.log_event(
f"refusing to broadcast {float(value):+.0f} W to "
f"{len(targets)} inverters - that is the ARCH-01 fault; "
f"commanding 0 W")
_LOG.error("write_setpoint() was asked to put %+.0f W on every "
"one of %d inverters. A site target handed to each "
"unit is supplied by EACH of them: the gain at the "
"meter becomes N times the per-unit gain. Only 0 W "
"may be written site-wide; shares come from the "
"allocator. Commanding 0 W.", float(value),
len(targets))
f"commanding 0 W", hint=messages.hint("all_blocked"))
_LOG.error("Refused to send %+.0f W to all %d inverters at "
"once, so every inverter is set to 0 W. This is a "
"software fault.", float(value), len(targets))
_LOG.debug("ARCH-01: a site target handed to each unit is "
"supplied by each of them, so the gain at the meter "
"becomes N times the per-unit gain. Only 0 W may be "
"written site-wide, and shares come from the "
"allocator.")
value = 0.0
else:
targets = [unit]
@@ -2101,7 +2113,8 @@ class Controller:
# must not be counted as a clamp engagement, or a transport bug lands in
# the winding-loop counter.
if not is_commandable(value):
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W")
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W",
hint=messages.hint("bad_value"))
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
"0 W and continuing to command. This is a bug upstream, "
"not a limit.", value)
@@ -2139,11 +2152,11 @@ class Controller:
refusal = self.write_refusal(value, inv)
if refusal is not None:
self.log_event(
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W")
_LOG.error("SAFETY-28 REFUSED a write of %+.0f W to %s: %s. "
"Commanding 0 W instead. Every setpoint must be one the "
"allocator placed on THIS tick, for THIS unit - see "
"Controller.write_refusal().", value, inv.name, refusal)
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
hint=messages.hint("write_blocked", unit=inv.name))
_LOG.error("Refused to send %+.0f W to %s (%s), so it is set to "
"0 W instead. This is a software fault.",
value, inv.name, refusal)
value = 0.0
# ⚠️ THIS UNIT'S OWN THREE NUMBERS. `inv.max_w` is the supervised cap on
@@ -2158,7 +2171,8 @@ class Controller:
# (§15), and it is a different surface from the notification, which
# IS deduplicated. The count is what says how hard the loop pushed;
# the notification only says that it did.
self.log_event(f"{inv.name}: {result.describe()}")
self.log_event(f"{inv.name}: {result.describe()}",
hint=messages.hint("write_cut", unit=inv.name))
value = result.applied_w
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
@@ -2209,12 +2223,12 @@ class Controller:
timeout=WRITE_BUDGET_S)
except (asyncio.TimeoutError, TimeoutError):
ok = False
_LOG.error("the write of %+.0f W to %s (%s) did not complete in "
"%.0f s and was abandoned. It will be re-issued on the "
"next tick; no other inverter waited for it.",
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
"dropped, so it is sent again next cycle.",
value, inv.name, entity, WRITE_BUDGET_S)
self.log_event(f"{inv.name}: write timed out after "
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick")
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick",
hint=messages.hint("write_slow", unit=inv.name))
now = datetime.now(timezone.utc)
inv.note_write(bool(ok), now)
if ok:
@@ -2339,7 +2353,8 @@ class Controller:
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg)
self.log_event(msg, hint=messages.hint("maintenance",
phase=machine.phase))
maint_results.append(res)
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
# deferral is edge-logged (it lasts as long as the other pack's cycle),
@@ -2432,11 +2447,10 @@ class Controller:
# operator press that clears the namespaced ones.
self.store.set("sign_reason", inv.sign_wd.reason)
self.store.set("sign_tripped", True)
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason)
_LOG.error("SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on "
"that inverter and holding it until control is "
"started again. This survives an add-on restart; "
"restarting will not clear it.",
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
hint=messages.hint("direction", unit=inv.name))
_LOG.error("%s is set to 0 W because its power went the wrong "
"way: %s. Only starting control clears this.",
inv.name, inv.sign_wd.reason)
# ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'.
# SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour
@@ -2481,14 +2495,11 @@ class Controller:
# the same reason `circ_*` is.
self.store.set("site_sign_reason", self.site_sign_wd.reason)
self.store.set("site_sign_tripped", True)
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason)
_LOG.error("SAFETY-30 TRIPPED at the SITE - %s. That is the "
"SUM of every standing command opposing the meter, "
"which no single inverter is answerable for. "
"Commanding 0 W on every inverter and holding it "
"until control is started again. This survives an "
"add-on restart; restarting will not clear it.",
self.site_sign_wd.reason)
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason,
hint=messages.hint("site_direction"))
_LOG.error("Every inverter is set to 0 W because the total "
"power went the wrong way: %s. Only starting "
"control clears this.", self.site_sign_wd.reason)
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
@@ -2516,12 +2527,12 @@ class Controller:
self.store.set("circ_reason", self.circ_wd.reason)
self.store.set("circ_tripped", True)
self._circ_announced = False
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason)
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason,
hint=messages.hint("circulating"))
_LOG.error(
"SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and "
"holding it until control is started again. This survives an "
"add-on restart AND a Home Assistant restart; restarting will "
"not clear it.", self.circ_wd.reason)
"Every inverter is set to 0 W because the inverters worked "
"against each other: %s. Only starting control clears this.",
self.circ_wd.reason)
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
@@ -2623,16 +2634,18 @@ class Controller:
inv.store_set("delivery_tripped", True)
self._delivery_announced.discard(inv.name)
self.log_event(f"NOT DELIVERING {inv.name}: "
+ inv.delivery_wd.reason)
+ inv.delivery_wd.reason,
hint=messages.hint("not_delivering",
unit=inv.name))
_LOG.error(
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
"DIFFERENTLY: this guard reports and does not act, because "
"commanding a unit that is not listening achieves nothing "
"(HW-01) and the only re-allocation this add-on can express "
"would throw away a partially-delivering unit's remaining "
"output. The site is short by that unit's share until "
"somebody looks at it. This survives an add-on AND a Home "
"Assistant restart.", inv.name, inv.delivery_wd.reason)
"%s is not delivering: %s. Nothing was changed and control "
"keeps running, and only stopping and starting control "
"clears this.", inv.name, inv.delivery_wd.reason)
_LOG.debug(
"SAFETY-42 reports and does not act: commanding a unit "
"that is not listening achieves nothing (HW-01), and the "
"only re-allocation the add-on can express would throw "
"away a partly delivering unit's output.")
stale_after = float(self.o.get("stale_input_s", 15))
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
@@ -2653,6 +2666,7 @@ class Controller:
# A single missed poll is not a fault. Hold, keep the heartbeat
# going, and give it a few seconds to come back.
self.reason = f"inputs missing {bad_for:.0f}s"
self.why_words = messages.why_waiting(bad_for)
return
claims = []
@@ -2828,7 +2842,8 @@ class Controller:
actuator=self.actuator_of("safety")))
elif not self.inputs_ok:
if self.target != 0.0:
self.log_event("inputs missing - commanding 0 W")
self.log_event("inputs missing - commanding 0 W",
hint=messages.hint("inputs_missing"))
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
actuator=self.actuator_of("safety")))
@@ -2949,7 +2964,12 @@ class Controller:
if engaged_now != self._peak_engaged:
self._peak_engaged = engaged_now
if self.peak_guard is not None:
self.log_event(self.peak_guard.explain())
_pg = self.peak_guard
self.log_event(_pg.explain(), hint=messages.hint(
"peak_off" if not _pg.engaged else
"peak_blind" if _pg.blind else
"peak_reserve" if _pg.floor_limited else
"peak_saturated" if _pg.saturated else "peak_on"))
# --- OPT-04: price arbitrage (§10.1 priority 3) ----------------------
# ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole
@@ -3061,7 +3081,10 @@ class Controller:
# operator needs - "it started buying at 03:00", not 240 copies.
if self.arb.action != self._arb_action:
self._arb_action = self.arb.action
self.log_event(self.arb.explain())
self.log_event(self.arb.explain(), hint=messages.hint(
"trade_charge" if self.arb.action == arbitrage.CHARGE else
"trade_give" if self.arb.action == arbitrage.DISCHARGE
else "trade_off"))
# --- maintenance -----------------------------------------------------
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
@@ -3145,7 +3168,8 @@ class Controller:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
"contradicted",
hint=messages.hint("maint_paused"))
else:
self._maint_shape_suspended = False
hi = 0.0
@@ -3229,7 +3253,8 @@ class Controller:
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
f"{site_batt:.0f} W)")
f"{site_batt:.0f} W)",
hint=messages.hint("not_keeping_up"))
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
@@ -3358,7 +3383,8 @@ class Controller:
# made by commanding one pack to charge while another discharges.
# `allocator.plan()` carries the argument for each.
if not resolution.allocated:
self.log_event("ARBITER: " + resolution.allocation.explain())
self.log_event("ARBITER: " + resolution.allocation.explain(),
hint=messages.hint("split"))
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
@@ -3394,7 +3420,7 @@ class Controller:
f"ARBITER: the site target was allocated across "
f"{len(resolution.shares)} inverters and this controller has no "
f"setpoint entity for {undeliverable} - refusing to command a "
f"partial site")
f"partial site", hint=messages.hint("no_setpoint"))
# ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
# DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below
# commands each inverter `shares.get(name, 0.0)`, so carrying the
@@ -3422,9 +3448,11 @@ class Controller:
for _u, _why in (self.allocation.dropped if self.allocation else ()):
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
f"({_why}) - the remaining inverter(s) now carry "
f"its share of the site target")
f"its share of the site target",
hint=messages.hint("unit_dropped", unit=_u))
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split")
self.log_event("ALLOCATOR: every inverter is back in the split",
hint=messages.hint("units_back"))
self._dropped_last = _dropped
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
# `unmet_w` is the site target the envelopes could not absorb. It is
@@ -3441,9 +3469,11 @@ class Controller:
if _unmet:
self.log_event(
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
f"placed on any inverter - {self.allocation.explain()}")
f"placed on any inverter - {self.allocation.explain()}",
hint=messages.hint("unmet", w=_unmet))
else:
self.log_event("ALLOCATOR: the whole site target fits again")
self.log_event("ALLOCATOR: the whole site target fits again",
hint=messages.hint("unmet_gone"))
self._unmet_last = _unmet
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope does
@@ -3452,9 +3482,11 @@ class Controller:
# card removes, and a site constraint on one unit is a bound the
# other unit never gets. Loud, every cycle, and it does not change
# the resolution - the arbiter already routed by `actuator`.
_LOG.error("ARBITER SCOPE: %s", "; ".join(resolution.scope_errors))
_LOG.error("A limit was filed on the wrong inverter, which is a "
"software fault: %s", " / ".join(resolution.scope_errors))
if resolution.contradiction:
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
hint=messages.hint("clash"))
# --- SAFETY-02's arming gate ------------------------------------------
# Was the number about to go on the wire chosen by the grid-following
# loop, or by a strategy that commands against the meter on purpose?
@@ -3471,6 +3503,17 @@ class Controller:
self.following = loop_is_following(resolution, self.loop_w)
self.target = resolution.target_w
self.reason = resolution.explain()
# UX-08: the same answer in plain words, for the page's "Why" line.
# `reason` stays the technical record the log and the checks read.
_why = resolution.winner
if _why in ("safety", "failsafe"):
_why = next((c.reason for c in claims
if c.source == _why and c.kind == "set"),
"no claim was made" if not any(
c.kind == "set" for c in claims) else "unsplit")
self.why_words = messages.why(_why, resolution.target_w,
resolution.bound_by,
contradiction=resolution.contradiction)
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
@@ -3647,20 +3690,20 @@ class Controller:
self.log_event(
"SHADOW MODE ON - " + shadow.why(
self.o, control_started=self.auto)
+ f"; inverter held at {shadow.WIRE_W:.0f} W")
+ f"; inverter held at {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_on"))
_LOG.warning(
"SAFETY-10 SHADOW MODE IS ON. The full decision path runs "
"and NOTHING is commanded: every inverter is held at "
"%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W,
"Shadow mode is on, so every inverter stays at %.0f W and "
"nothing the add-on decides is sent. %s.", shadow.WIRE_W,
shadow.why(self.o, control_started=self.auto))
elif not first:
self.log_event(
"SHADOW MODE OFF - the battery is being controlled again, "
f"resuming from {shadow.WIRE_W:.0f} W")
f"resuming from {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_off"))
_LOG.warning(
"SAFETY-10 shadow mode is OFF. This controller is "
"commanding the battery again, starting from the "
"%+.0f W that was standing on the wire.", shadow.WIRE_W)
"Shadow mode is off, so the add-on controls the battery "
"again, starting from %.0f W.", shadow.WIRE_W)
if not engaged:
self.shadow_would_w = None
self.shadow_sized_from_meter = False
@@ -3693,7 +3736,9 @@ class Controller:
self._shadow_winner = edge
self.log_event(shadow.log_line(
would_w=resolution.target_w, reason=resolution.explain(),
sized_from_meter=self.shadow_sized_from_meter))
sized_from_meter=self.shadow_sized_from_meter),
hint=messages.hint("shadow_decision",
sw=resolution.target_w))
self.target = shadow.WIRE_W
for inv in self.inverters:
inv.target = shadow.WIRE_W
@@ -3771,25 +3816,29 @@ class Controller:
stamp = self.events[0].split(" ", 1)[0]
self.events[0] = f"{stamp} {row}"
else:
self.log_event(row)
self.log_event(row, hint=messages.hint(
"power_too_high" if bug else "not_keeping_up"))
if not fired:
return
if not self.clamp_wd.bug:
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
# it can never replace a BUG SIGNAL notice still on screen.
_LOG.warning("SAFETY-03 - %s Engagements so far: %d.",
self.clamp_wd.reason, self.clamp_wd.engagements)
_LOG.warning("The battery gives less than asked: %s Held back %d "
"times so far.", self.clamp_wd.reason,
self.clamp_wd.engagements)
await self.hass.call("persistent_notification", "create",
messages.loop_bound().data())
return
_LOG.error(
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
"reached 14 768 W on this project before: the clamp makes every "
"dashboard read a legal number while the thing producing it goes on "
"climbing. The command IS clamped and the loop is STILL RUNNING - "
"this is an alarm, not a trip. Engagements so far: %d.",
"Power request too high, which is a software fault: %s Control is "
"still running. Cut back %d times so far.",
self.clamp_wd.reason, self.clamp_wd.engagements)
_LOG.debug(
"SAFETY-03: silently clipping this is how a winding integrator "
"reached 14 768 W on this project before. The clamp makes every "
"dashboard read a legal number while the thing producing it goes "
"on climbing. This is an alarm, not a trip.")
# A fixed notification_id, for the reason check_meter_frozen() gives -
# and a DIFFERENT one from every other alarm this add-on raises, because
# a clamp is not a frozen meter and neither is a saturation freeze.
@@ -3829,13 +3878,12 @@ class Controller:
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
self.log_event("PEAK GUARD SATURATED - " + g.reason)
self.log_event("PEAK GUARD SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
_LOG.error(
"SAFETY-07 - %s. The guard is asking for everything the inverter "
"has and the quarter is still projected over the ceiling: this "
"quarter will set or raise the month's billed capacity peak. The "
"controller is STILL RUNNING and still shaving; there is nothing "
"further it can do. Shed load.", g.reason)
"The battery is asked for all it can give, but this 15-minute "
"period still goes over the peak limit: %s. Switch off large "
"loads.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
@@ -3889,12 +3937,10 @@ class Controller:
# a second copy is the kind of stutter that makes a 40-line action log
# read like boilerplate. The saturated path needs its prefix because its
# reason carries the word in the middle of a sentence, not at the front.
self.log_event(g.reason)
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
_LOG.error(
"SAFETY-24 - %s. The controller is STILL RUNNING and charging is "
"still blocked, but it is not discharging to hold this quarter, so "
"the quarter will set or raise the month's billed capacity peak. "
"Shed load, or lower peak_soc_floor if this pack can afford it.",
"The peak guard may not draw the battery lower, so this 15-minute "
"period will go over the peak limit: %s. Switch off large loads.",
g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A battery at its
@@ -4051,15 +4097,17 @@ class Controller:
dev_max=self.site_dev_bound("dev_max"))
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason)
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
hint=messages.hint("meter_frozen"))
_LOG.error(
"SAFETY-18 - %s. The grid-following loop is regulating against a "
"number that has stopped moving, and nothing in this stack stops "
"it: the meter is still answering, so the age is fresh, the "
"firmware watchdog is fed and the arbiter only bounds the "
"magnitude. CHECK THE METER. Sustained for a quarter of an hour "
"this sets the month's billed capacity-tariff peak. This is an "
"ALARM ONLY - the loop is still running.", self.freeze_wd.reason)
"The meter shows the same value while the battery is asked for "
"full power: %s. Control still follows it, so check the meter.",
self.freeze_wd.reason)
_LOG.debug(
"SAFETY-18: nothing else stops this. The meter still answers, so "
"its age is fresh, the firmware watchdog is fed and the arbiter "
"only bounds the magnitude. Kept up for a quarter of an hour, it "
"sets the month's billed capacity peak.")
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
# rather than stacking. An operator who has to dismiss eleven identical
# notifications learns to dismiss the twelfth without reading it.
@@ -4168,12 +4216,9 @@ class Controller:
and standing is not None and standing.from_feed)
if keep:
_LOG.warning(
"tariff refresh degraded (%s); KEEPING the plan "
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
"so it retires %.0f s after that fetch; arbitrage, "
"where it is armed, stops with it. A kept plan is "
"never presented as a current one.", plan.words,
self.tariff_at,
"The price update was incomplete (%s), so the plan "
"from %s, %.0f s ago, is kept until it is %.0f s old.",
plan.words, self.tariff_at,
(now - self.tariff_at).total_seconds(),
TARIFF_MAX_AGE_S)
else:
@@ -4185,9 +4230,9 @@ class Controller:
# here is something nobody expected, and the plan is kept for
# the same reason and on the same terms.
_LOG.warning(
"tariff refresh raised (%s); keeping the plan resolved at "
"%s. It retires on its own after %.0f s and arbitrage "
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
"The price update failed (%s), so the plan from %s is kept "
"until it is %.0f s old, and trading stops then.",
err, self.tariff_at, TARIFF_MAX_AGE_S)
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.stop_event.wait(),
timeout=TARIFF_REFRESH_S)
@@ -4239,7 +4284,8 @@ class Controller:
if problems:
refuse(problems)
self.refused = problems
self.log_event("REFUSED: " + problems[0])
self.log_event("REFUSED: " + problems[0],
hint=messages.hint("refused"))
# Signal, then return WITHOUT entering the loop. amain() wakes
# on this, runs the normal wind-down (which commands 0 W) and
# re-raises ConfigRefused afterwards, so the exit code is 78 and
@@ -4646,21 +4692,28 @@ class Controller:
# a normal state on the 1st; "unreadable" is a broken instrument, and
# a status page that spelled them the same way would let a 0 W read
# look like a quiet month right up until SAFETY-07 shaved nothing.
# UX-08: in plain words. The store's state at start is on the INFO
# line `capacity-tariff peak store: ...` and `_pk.explain()` on the
# log; the coverage figure (TARIFF-13) is a row of its own.
_pk = self.peak.peak(when)
if _pk.is_unreadable:
out.append({"ok": False, "warn": True,
"text": "capacity-tariff peak: " + _pk.explain()})
out.append({"ok": False, "warn": True, "id": "month_peak",
"text": messages.row_month_peak_unreadable()})
else:
# §9.1's "warning entity at 80 %", as a status row rather than
# a second entity - see the constructor on not doubling up.
_frac = self.peak.warning_frac(when) or 0.0
_ok = _frac < 0.8
out.append({"ok": _ok, "warn": not _ok,
"text": _pk.explain()
+ f" - {_frac * 100:.0f} % of the "
f"{self.peak.cap_w:.0f} W soft limit"
+ f", {self.peak.recorded} quarter(s) banked"
+ f", on start: {self.peak.restore_state}"})
out.append({"ok": _ok, "warn": not _ok, "id": "month_peak",
"text": (messages.row_month_peak_none()
if not _pk.is_observed else
messages.row_month_peak(
_pk.peak_w,
brussels(_pk.quarter_start),
_frac))})
out.append({"ok": True, "warn": False, "id": "periods_measured",
"text": messages.row_periods_measured(
self.peak.recorded)})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way
@@ -4715,10 +4768,16 @@ class Controller:
else ()))})
elif _pg.blind:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": _pg.explain()})
"text": messages.row_peak_guard_blind(
peakguard.blind_kind(_pg.blind))})
elif _pg.engaged:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_on(
_pg.floor_w or 0.0, _pg.ceiling_w,
shadow=self.shadow)})
else:
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
"id": "peak_guard", "text": _pg.explain()})
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
@@ -4913,7 +4972,7 @@ class Controller:
# watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_one_inverter(
"Circulating power check")})
messages.CHECK_CIRCULATING)})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "circulating",
@@ -5079,7 +5138,7 @@ class Controller:
# the row's first sentence, so it stays a headline.
level, banner = "bad", messages.banner_not_ready(bad[0])
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
level, banner = "warn", messages.banner_stopped()
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
@@ -5137,10 +5196,11 @@ class Controller:
# question it answers is the one those two cannot: at "Grid 0 W,
# Battery 0 W" the site is either becalmed or throwing away every
# watt it makes, and only this row separates them.
*(() if not self.pv_roster else ((("PV", w(self.pv_w)),))),
("State of charge", "—" if self.pack_floor_soc is None
*(() if not self.pv_roster else ((("Solar production",
w(self.pv_w)),))),
("Battery level", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
("Power request", w(self.target)),
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
# "Commanded 0 W" above and a "Why" below that explains a trade is
# the exact pair an operator would otherwise have to guess at; this
@@ -5155,8 +5215,8 @@ class Controller:
# - the page runs on its own task and `self.peak_guard` has
# moved on. See `Controller.__init__`.
sized_from_meter=self.shadow_sized_from_meter,
reason=self.reason)),
("Why", self.reason),
reason=self.why_words)),
("Why", self.why_words),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
@@ -5195,11 +5255,11 @@ class Controller:
("Arbitrage", arbitrage_row(
on=arbitrage.enabled(self.o), decision=self.arb,
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.reason)),
now=datetime.now(timezone.utc), why=self.why_words)),
]
return {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.events[0] if self.events else "",
"hint": self.hint,
}
async def handle_action(self, what: str):
@@ -5207,7 +5267,9 @@ class Controller:
if what == "auto_toggle":
self.auto = not self.auto
self.store.set("auto", self.auto)
self.log_event("control started" if self.auto else "control stopped")
self.log_event("control started" if self.auto else "control stopped",
hint=messages.hint("control_started" if self.auto
else "control_stopped"))
if self.auto:
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
# It clears on a deliberate human start, not on the condition
@@ -5230,7 +5292,9 @@ class Controller:
inv.store_set("sign_tripped", False)
inv.store_set("sign_reason", "")
self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}")
f"operator on {inv.name}",
hint=messages.hint("stop_cleared",
unit=inv.name))
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY.
# This is what makes `flat True + namespaced False` MEAN
# something, and it is the reason `_grade_store_migration()` can
@@ -5271,7 +5335,8 @@ class Controller:
# moment a second inverter was configured again.
if self.site_sign_wd.tripped:
self.log_event("site sign-disagreement failsafe cleared "
"by operator")
"by operator",
hint=messages.hint("site_stop_cleared"))
self.site_sign_wd.reset()
self.store.set("site_sign_tripped", False)
self.store.set("site_sign_reason", "")
@@ -5289,7 +5354,8 @@ class Controller:
self.circ_wd.reset()
self.store.set("circ_tripped", False)
self.store.set("circ_reason", "")
self.log_event("circulating-power trip cleared by operator")
self.log_event("circulating-power trip cleared by operator",
hint=messages.hint("site_stop_cleared"))
self._circ_announced = False
# SAFETY-42, cleared by the SAME press, and for a reason that is
# NOT the one above. The other three latches are cleared here
@@ -5307,7 +5373,10 @@ class Controller:
for inv in self.inverters:
if inv.delivery_wd.tripped:
self.log_event(f"not-delivering report on {inv.name} "
f"cleared by operator")
f"cleared by operator",
hint=messages.hint(
"not_delivering_cleared",
unit=inv.name))
inv.delivery_wd.reset()
inv.store_set("delivery_tripped", False)
inv.store_set("delivery_reason", "")
@@ -5333,14 +5402,18 @@ class Controller:
# bare `maint_start` with no unit, and inventing a unit parameter
# here would ship half a surface: the page would still have one
# button and the API would have two meanings.
for msg in self._maint_button(now).force_start(now):
self.log_event(msg)
_m = self._maint_button(now)
for msg in _m.force_start(now):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
return {"phase": self.maint.phase}
if what == "maint_abort":
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
for msg in self._maint_button(now).abort(now, "operator"):
self.log_event(msg)
_m = self._maint_button(now)
for msg in _m.abort(now, "operator"):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
await self.command_site_zero()
return {"phase": self.maint.phase}
return None
@@ -5362,8 +5435,9 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
if stop.is_set() or controller.stopping:
return # the normal wind-down, or run_control()'s refusal path
exc = None if task.cancelled() else task.exception()
_LOG.critical("CONTROL LOOP DIED (%s) - commanding 0 W and exiting with "
"status %d", "cancelled" if task.cancelled() else
_LOG.critical("Control stopped unexpectedly (%s), so every inverter is "
"set to 0 W and the add-on exits with code %d.",
"cancelled" if task.cancelled() else
(repr(exc) if exc else "returned"), EXIT_CONTROL_DIED,
exc_info=exc)
died.append(exc)
@@ -5381,21 +5455,33 @@ async def docs_link_facts(session, token: str) -> dict:
Asked together with a 3 s budget: a link may never hold up control start.
"""
async def ask(url, field):
"""`(value, why)`: the field, or None and a short reason (UX-09)."""
try:
async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={
"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
data = body.get("data", body) if isinstance(body, dict) else {}
return data.get(field) if isinstance(data, dict) else None
value = data.get(field) if isinstance(data, dict) else None
return value, (None if value else f"no {field} in its answer")
except Exception as err: # noqa: BLE001 - a link, never a stop
_LOG.info("docs link: %s unreadable (%s)", url, err)
return None
# A timeout's own text is empty, so the type is always named.
text = str(err).strip()
return None, (f"{type(err).__name__}: {text}" if text
else type(err).__name__)
slug, ha = await asyncio.gather(
(slug, why_slug), (ha, why_ha) = await asyncio.gather(
ask("http://supervisor/addons/self/info", "slug"),
ask("http://supervisor/info", "homeassistant"))
out = {"slug": slug, "ha_version": ha}
_LOG.info("docs link: slug %s, Home Assistant %s", slug, ha)
# ONE line, whatever happened: the link is a convenience, not a fault.
if slug and ha:
_LOG.info("Documentation link: add-on %s, Home Assistant %s", slug, ha)
else:
missed = [f"{what} ({why})" for what, why in
(("add-on name", why_slug), ("Home Assistant version", why_ha))
if why]
_LOG.info("Documentation link: the Supervisor gave no %s, so the "
"link is guessed or left out.", " and no ".join(missed))
return out