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
+57 -72
View File
@@ -647,7 +647,7 @@ def _roster_problems(opts: dict) -> list[str]:
where = f"inverters[{i}]"
if not isinstance(entry, dict):
problems.append(
f"{where} in the “Inverter list” is {entry!r}. Each entry needs "
f"{where} in the “Inverter list” is {messages.shown(entry)}. Each entry needs "
"a name, a setpoint entity and an inverter rating.")
continue
name = str(entry.get("name") or "").strip()
@@ -670,7 +670,7 @@ def _roster_problems(opts: dict) -> list[str]:
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
problems.append(
f"{who} has no usable {S('max_w')}: it is "
f"{entry.get('max_w')!r}. Set this inverter's own rating in "
f"{messages.shown(entry.get('max_w'))}. Set this inverter's own rating in "
"watts.")
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
@@ -690,7 +690,7 @@ def _roster_problems(opts: dict) -> list[str]:
val = None
if val is None or not (val > 0.0):
problems.append(
f"{who} has {S('capacity_kwh')} {raw!r}, which is not a usable "
f"{who} has {S('capacity_kwh')} {messages.shown(raw)}, which is not a usable "
"number. Set it in kWh, or leave it empty on every inverter.")
else:
stated.append(who)
@@ -831,7 +831,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# has answered; it is not called from here because at startup no device has.
max_w = _number(opts, "max_w", 2000.0)
if max_w is None:
problems.append(f"{S('max_w')} is {opts.get('max_w')!r}, which is not "
problems.append(f"{S('max_w')} is {messages.shown(opts.get('max_w'))}, which is not "
"a number. Set it in watts.")
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
# the check is not for the Supervisor path - it is because
@@ -866,8 +866,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if phases is None or max_age_s is None:
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
f"both be numbers. They are "
f"{opts.get('meter_phases')!r} and "
f"{opts.get('meter_max_age_s')!r}.")
f"{messages.shown(opts.get('meter_phases'))} and "
f"{messages.shown(opts.get('meter_max_age_s'))}.")
else:
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
@@ -918,14 +918,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
weakest = min(caps) if caps else None
if sign_w is None:
problems.append(f"{S('sign_disagree_w')} is "
f"{opts.get('sign_disagree_w')!r}, which is not a "
f"{messages.shown(opts.get('sign_disagree_w'))}, which is not a "
"number. Set it back to 300 W.")
elif weakest is not None and weakest > 0 and sign_w >= weakest:
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
problems.append(
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the "
f"{S('max_w', where)} is {weakest:.0f} W, so this safety stop can "
f"never trip. Lower it below {weakest:.0f} W, or raise the rating.")
f"{S('sign_disagree_w')} is {sign_w:.0f} W, not below "
f"{S('max_w', where)} at {weakest:.0f} W, so this safety stop can "
f"never trip. Lower it or raise the rating.")
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
@@ -972,15 +972,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if caps_ix else "")
if unit_sign_w is None:
problems.append(f"{S('unit_sign_disagree_w')} is "
f"{opts.get('unit_sign_disagree_w')!r}, which is not a "
f"{messages.shown(opts.get('unit_sign_disagree_w'))}, which is not a "
"number. Set it back to "
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
problems.append(
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, but the "
f"{S('max_w', unit_where)} is {weakest:.0f} W, so this safety stop "
f"can never trip. Lower it below {weakest:.0f} W, or raise the "
"rating.")
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, not below "
f"{S('max_w', unit_where)} at {weakest:.0f} W, so this safety stop "
f"can never trip. Lower it or raise the rating.")
# --- 6. a maintenance charge the ceiling can actually accommodate --------
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
@@ -1054,7 +1053,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if charge_w is None:
problems.append(
f"{S('maintenance_charge_w')} is "
f"{opts.get('maintenance_charge_w')!r}, which is not a number. Set "
f"{messages.shown(opts.get('maintenance_charge_w'))}, which is not a number. Set "
"it in watts.")
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
problems.append(
@@ -1089,7 +1088,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
slew_w = _number(opts, "slew_w", 0.0)
rail = site_rail_w(opts)
if slew_w is None:
problems.append(f"{S('slew_w')} is {opts.get('slew_w')!r}, which is "
problems.append(f"{S('slew_w')} is {messages.shown(opts.get('slew_w'))}, which is "
"not a number. Set it to 0 to follow the inverter "
"rating.")
elif slew_w < 0:
@@ -1133,8 +1132,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if soc_floor is None or bms_floor is None:
problems.append(
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
f"both be numbers. They are {opts.get('peak_soc_floor')!r} and "
f"{_raw!r}.")
f"both be numbers. They are {messages.shown(opts.get('peak_soc_floor'))} and "
f"{messages.shown(_raw)}.")
elif soc_floor <= bms_floor:
problems.append(
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
@@ -1147,10 +1146,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
if integ_w is not None and max_w is not None and integ_w > max_w:
_LOG.warning(
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
"arbiter still bounds the wire to +-max_w - but the only thing the "
"extra headroom buys is more wrong-direction power to unwind after "
"saturation. 0 (follow max_w) is the recommended value.",
"integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
"but it only adds overshoot, so 0 is recommended.",
integ_w, max_w)
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
@@ -1173,15 +1170,13 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if (disch_w is not None and maint_cap is not None and maint_cap > 0
and disch_w > maint_cap):
_LOG.warning(
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
"refused - the drain phase SETS the setpoint and the arbiter clamps "
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
"so nothing is silent and the cycle still completes. But it drains "
"at %.0f W, not the %.0f W you configured, and the drain phase has "
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
"surprise.",
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
maint_cap)
"maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
_LOG.debug(
"Not refused: the drain phase sets the setpoint, the arbiter "
"clamps it to %.0f W and names the clamp, and the drain phase has "
"a 720 min timeout.", maint_cap)
return problems
@@ -1289,10 +1284,9 @@ def _price_problems(opts: dict) -> list[str]:
if str(opts.get(k, "") or "").strip()]
if pid != "off" and ha_price:
problems.append(
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} also set and would win. "
f"Clear {'it' if len(ha_price) == 1 else 'them'}, or set the price "
"source to 'off'.")
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} would "
f"win over it. Clear {'it' if len(ha_price) == 1 else 'them'}, or "
"set the source to 'off'.")
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
@@ -1302,7 +1296,8 @@ def _price_problems(opts: dict) -> list[str]:
if ha_price and str(opts.get("static_profile", "") or "").strip():
problems.append(
f"{S('static_profile')} is set, but {_labels(ha_price)} would win "
"over it. Clear the one you do not use.")
f"over it. Clear the fixed tariff or "
f"{'the entity' if len(ha_price) == 1 else 'the two entities'}.")
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
# that does not tile the day has hours with no price, and the only ways to
@@ -1374,9 +1369,8 @@ def _price_problems(opts: dict) -> list[str]:
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
problems.append(
f"{S(TAXES_PROFILE_OPTION)} is set, so "
f"{S('price_taxes_eur_kwh')} is not used, but it reads {scalar} "
f"instead of {shipped}. Set it back to {shipped}, or clear the "
"table.")
f"{S('price_taxes_eur_kwh')} is not used. Set it back to "
f"{shipped} from {scalar}, or clear the table.")
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
@@ -1403,10 +1397,9 @@ def _price_problems(opts: dict) -> list[str]:
# observe. §14's warning box governs CONTRADICTIONS, and this is
# not one.
_LOG.warning(
"%s carries negative levies %s EUR/kWh. That is accepted, but "
"a tax component below zero is usually a sign error: the "
"add-on will SUBTRACT it from the published price in those "
"hours.", TAXES_PROFILE_OPTION, negative)
"%s has negative levies %s EUR/kWh, which are subtracted from "
"the price. This is allowed, but it is usually a sign error.",
TAXES_PROFILE_OPTION, negative)
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00.
@@ -1459,11 +1452,9 @@ def _price_problems(opts: dict) -> list[str]:
# silent loser this module's case (a) is about, so it is said out
# loud rather than left to be discovered from a bill.
_LOG.warning(
"price_formula is filled in but price_provider is %r, so the "
"coefficients decide NOTHING - only 'custom_formula' reads "
"them. Set price_provider to 'custom_formula' to use them, or "
"clear price_formula so the configuration says what the "
"add-on is doing.", pid)
"price_formula is filled in, but price_provider is %r, so the "
"formula is not used. Set price_provider to 'custom_formula' "
"or clear price_formula.", pid)
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1552,20 +1543,18 @@ def _price_problems(opts: dict) -> list[str]:
# bounds in `implausible_injection()` are applied, and the
# operator is told which of the two checks did NOT run.
_LOG.warning(
"%s has no \"card\" block, so its coefficients are "
"UNGRADED: nothing recomputed a printed price from them. "
"That is accepted because it is the real market - Eneco "
"prints its injection formula with no price beside it, so "
"there is nothing to grade against on that card - and "
"refusing would make the published line inexpressible. "
"What IS checked is the shape of the coefficients: the "
"pass-through (%.4g x the wholesale price) and the offset "
"(%+.5f EUR/kWh). If YOUR card prints an injection "
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
"..., \"expect_incl_vat\": ...} and the add-on will "
"refuse to start unless your numbers reproduce it.",
"%s has no \"card\" block, so its numbers are not checked "
"against a printed price, only their shape (pass-through "
"%.4g, offset %+.5f EUR/kWh).",
EXPORT_FORMULA_OPTION, xspec.passthrough,
xspec.offset_eur_kwh)
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
"prints an injection estimate, add {\"month\": ..., "
"\"index_eur_per_mwh\": ..., \"expect_incl_vat\": ...} "
"and the add-on refuses to start unless your numbers "
"reproduce it.")
else:
xaged = stale_card(xspec)
if xaged:
@@ -1578,11 +1567,9 @@ def _price_problems(opts: dict) -> list[str]:
# every install TARIFF-02 shipped, none of which could have had one.
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
_LOG.warning(
"price_provider is %r but static_profile is empty. §11.4's "
"degradation row is 'no price data -> fall back to the static "
"profile'; with none configured, a feed outage leaves this site "
"with no tariff at all rather than with a coarse one. Not refused, "
"but filling it in is what makes that row work.", pid)
"price_provider is %r but static_profile is empty, so a price "
"feed outage leaves no tariff at all. Fill in static_profile as a "
"fallback.", pid)
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot
@@ -1598,7 +1585,7 @@ def _price_problems(opts: dict) -> list[str]:
if thresh is None or thresh < 0:
problems.append(
f"{S('cycle_cost_threshold_eur')} is "
f"{opts.get('cycle_cost_threshold_eur')!r}. Set it to 0 or more, "
f"{messages.shown(opts.get('cycle_cost_threshold_eur'))}. Set it to 0 or more, "
"in EUR/kWh.")
if arbitrage_on(opts):
@@ -1655,11 +1642,9 @@ def refuse(problems: list[str]) -> None:
at all and even the exit status distinguishes the two.
"""
_LOG.error("=" * 72)
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
_LOG.error("the add-on will start as soon as the settings below are fixed.")
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
_LOG.error("Configuration, then start the add-on again.")
_LOG.error("The add-on did not start, because its settings have problems.")
_LOG.error("Nothing is broken. Fix the settings below, then start it again.")
_LOG.error("They are on the add-on's Configuration tab.")
_LOG.error("-" * 72)
for i, problem in enumerate(problems, 1):
_LOG.error(" %d. %s", i, problem)