UX-08 2c: short refusals, error texts and option texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s

Validator refusals name the setting by its label and key, say what is
wrong and what to change, and keep the rejected value and limit. Option
names and descriptions are short in en and nl; citations moved to DOCS
"For installers" and the test_translations citation check now pins the
instruction on the tab and the record in DOCS. No rule, key, schema,
default or exit code changed. Allowlist 167 -> 43 (log lines only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
glenn schrooyen
2026-09-27 05:55:45 +02:00
co-authored by Claude Opus 5.5
parent e1d0970b23
commit 2c516a032c
26 changed files with 1026 additions and 1236 deletions
+140 -297
View File
@@ -322,6 +322,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
import logging
from . import messages
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
@@ -571,6 +572,17 @@ def _unit_label(opts: dict, index: int) -> str:
return f" ({name or f'inverters[{index}]'})"
def S(key: str, where: str = "") -> str:
"""A setting as a refusal names it; `where` is `_unit_label()`'s " (name)"."""
return messages.setting(key, where.strip().strip("()"))
def _labels(keys, joiner: str = "and") -> str:
"""Several settings, each with its key: the config panel files a refusal
under every key it names (`configui.problems_by_field()`)."""
return f" {joiner} ".join(S(k) for k in keys)
def _roster_problems(opts: dict) -> list[str]:
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
what it says.
@@ -625,11 +637,9 @@ def _roster_problems(opts: dict) -> list[str]:
if raw is None or raw == "" or raw == []:
return problems
if not isinstance(raw, (list, tuple)):
return [f"inverters is {type(raw).__name__} {raw!r}, and it must be a "
"LIST of inverter objects - one per inverter, each naming its "
"own name, setpoint_entity and max_w. A single inverter needs "
"no list at all: leave `inverters` empty and the flat "
"setpoint_entity/soc_entity/batt_entity/max_w keys describe it."]
return [f"{S('inverters')} is {type(raw).__name__} {raw!r}, not a list. "
"Add one entry per inverter, or leave it empty for a single "
"inverter."]
entries = roster_entries(opts)
seen: dict = {}
@@ -637,45 +647,31 @@ def _roster_problems(opts: dict) -> list[str]:
where = f"inverters[{i}]"
if not isinstance(entry, dict):
problems.append(
f"{where} is {type(entry).__name__} {entry!r}, and every entry "
"must be an object with at least name, setpoint_entity and "
"max_w.")
f"{where} in the “Inverter list” is {entry!r}. Each entry needs "
"a name, a setpoint entity and an inverter rating.")
continue
name = str(entry.get("name") or "").strip()
if not name:
problems.append(
f"{where} has no name. Every inverter needs one: the Store "
"namespaces this unit's SAFETY-02 latch, its heartbeat and its "
"write bookkeeping under it, so two unnamed units would share "
"one namespace and a trip caused by one would latch the other. "
"Any short identifier works - inverter-a, inverter-b, garage.")
f"{where} in the “Inverter list” has no name. Give every "
"inverter a short name, for example inverter-a.")
elif name in seen:
problems.append(
f"{where} and {seen[name]} are both called {name!r}. The Store "
"keys every per-unit latch under the unit's name, so two units "
"with one name share one SAFETY-02 latch and one heartbeat "
"deadline - a trip caused by either would stop both, and an "
"operator clearing it would clear both. Give them different "
"names.")
f"{where} and {seen[name]} are both called {name!r}. Give every "
"inverter its own name.")
else:
seen[name] = where
who = f"{where} ({name})" if name else where
if not str(entry.get("setpoint_entity") or "").strip():
problems.append(
f"{who} has no setpoint_entity, so this add-on cannot command "
"it. It would sit in the roster, take a share of the site "
"target every cycle and deliver none of it, while the status "
"page showed a site running at full strength. Give it the "
"ESPHome number entity that carries ITS setpoint, or remove "
"the entry.")
f"{who} has no {S('setpoint_entity')}, so the add-on cannot "
"control it. Set its ESPHome number entity, or remove the "
"entry.")
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
problems.append(
f"{who} has no usable max_w (got {entry.get('max_w')!r}). It is "
"the supervised cap on what may be written to THIS inverter, it "
"is not shared with any other unit and it is deliberately not "
"inherited from the flat max_w - a rating borrowed from a "
"sibling is exactly what DEV-02 removed. State this unit's own "
"rating in watts.")
f"{who} has no usable {S('max_w')}: it is "
f"{entry.get('max_w')!r}. Set this inverter's own rating in "
"watts.")
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
stated, blank = [], []
@@ -694,35 +690,21 @@ def _roster_problems(opts: dict) -> list[str]:
val = None
if val is None or not (val > 0.0):
problems.append(
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
"read to weight the split of the site target by ENERGY headroom "
"rather than by power rating; a zero or an unparseable value is "
"a pack the split would quietly stop preferring while the box "
"still shows a value. State it in kWh, or leave it empty on "
"every entry.")
f"{who} has {S('capacity_kwh')} {raw!r}, which is not a usable "
"number. Set it in kWh, or leave it empty on every inverter.")
else:
stated.append(who)
if stated and blank:
problems.append(
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
"split weights one pack's energy headroom against another's, so a "
"pack measured in kWh beside a sibling that weighs a dimensionless "
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
"whole roster and the value you filled in changes nothing. State a "
"capacity for every inverter, or for none of them.")
f"{S('capacity_kwh')} is set on {', '.join(stated)} but empty on "
f"{', '.join(blank)}. Set it on every inverter, or on none.")
flat_sp = str(opts.get("setpoint_entity") or "").strip()
if entries and flat_sp:
problems.append(
f"setpoint_entity is set to {flat_sp} AND an inverters list of "
f"{len(entries)} is declared. The flat setpoint_entity/soc_entity/"
"batt_entity/max_w keys are the SINGLE-inverter shape and are not "
"read at all once a roster exists, so this config declares an "
"actuator that nothing will ever write - a site running quietly "
"smaller than its own configuration. Move that inverter into the "
"list as its own entry and clear setpoint_entity.")
f"{S('setpoint_entity')} is set to {flat_sp}, but the "
f"{S('inverters')} has {len(entries)} entries. Move that inverter "
"into the list and clear this setting.")
return problems
@@ -760,17 +742,14 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
name = getattr(unit, "name", entity)
if dev_max is not None and cap > float(dev_max):
problems.append(
f"{name}: max_w is {cap:.0f} W but {entity} accepts at most "
f"{float(dev_max):.0f} W - THE DEVICE WINS, so the {cap:.0f} W "
f"configured for this inverter would silently never happen. "
f"Lower this entry's max_w to {float(dev_max):.0f} W or less.")
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts at "
f"most {float(dev_max):.0f} W. Lower this inverter's rating to "
f"{float(dev_max):.0f} W or less.")
elif dev_min is not None and -cap < float(dev_min):
problems.append(
f"{name}: max_w is {cap:.0f} W, so this controller may command "
f"{-cap:.0f} W, but {entity} accepts no less than "
f"{float(dev_min):.0f} W - THE DEVICE WINS on the charge side. "
f"Lower this entry's max_w to {abs(float(dev_min)):.0f} W or "
"less.")
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts no "
f"less than {float(dev_min):.0f} W when charging. Lower this "
f"inverter's rating to {abs(float(dev_min)):.0f} W or less.")
return problems
@@ -803,11 +782,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
writers = actuator_writers(opts)
if not writers:
problems.append(
"setpoint_entity is not set, so this add-on has no actuator: every "
"write is a no-op, the device's own rating is never read, and the "
"ESP32 heartbeat that holds off the layer-1 failsafe never happens. "
"It would run, look healthy, and move no energy at all. Set it to "
"the ESPHome number entity that carries the setpoint.")
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
"inverter. Set it to the ESPHome number entity for the setpoint.")
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
# count of option keys. Read `actuator_writers()` for why the old check went
# green on the very config this one is here to refuse.
@@ -822,15 +798,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
for actuator, who in sorted(writers.items()):
if len(who) > 1:
problems.append(
f"{len(who)} configured writers can reach the one actuator "
f"{actuator}: {', '.join(who)}. specs §1.5 makes any "
"configuration in which two writers can reach one actuator a "
"non-goal - two independent setpoints on one inverter is the "
"arbitration problem this add-on refuses to have, and at "
"runtime it is a meter-emulation target being written twice a "
"second by two strategies that cannot see each other. Give "
"each actuator exactly one writer, or point the second one at "
"its own entity.")
f"{len(who)} settings point at the same inverter entity "
f"{actuator}: {', '.join(who)}. Give each inverter its own "
"setpoint entity.")
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
@@ -861,7 +831,8 @@ 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"max_w {opts.get('max_w')!r} is not a number")
problems.append(f"{S('max_w')} is {opts.get('max_w')!r}, 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
# validate_startup_config() is now also sim/inject.py's entry point and can
@@ -872,24 +843,19 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# nonsense config it is.
elif max_w <= 0:
problems.append(
f"max_w is {max_w:.0f} W and must be positive - it is a magnitude, "
"not a signed setpoint. At or below zero the arbiter's supervised "
f"limit becomes lo={-max_w:.0f} / hi={max_w:.0f}, which is a "
"contradiction it resolves to 0 W, so the controller would look "
"like it was tripping rather than misconfigured.")
f"{S('max_w')} is {max_w:.0f} W. Set it above 0 W.")
elif dev_max is not None and max_w > dev_max:
problems.append(
f"max_w is {max_w:.0f} W but {opts.get('setpoint_entity') or 'the setpoint device'} accepts at "
f"most {dev_max:.0f} W - THE DEVICE WINS, so the {max_w:.0f} W you "
f"configured would silently never happen. Lower max_w to "
f"{dev_max:.0f} W or less, or point setpoint_entity at a device that "
f"can take {max_w:.0f} W.")
f"{S('max_w')} is {max_w:.0f} W, but "
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts at "
f"most {dev_max:.0f} W. Lower the rating to {dev_max:.0f} W or "
"less.")
elif dev_min is not None and -max_w < dev_min:
problems.append(
f"max_w is {max_w:.0f} W, so this controller may command "
f"{-max_w:.0f} W, but {opts.get('setpoint_entity') or 'the setpoint device'} accepts no less "
f"than {dev_min:.0f} W - THE DEVICE WINS on the charge side. Lower "
f"max_w to {abs(dev_min):.0f} W or less.")
f"{S('max_w')} is {max_w:.0f} W, but "
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts no "
f"less than {dev_min:.0f} W when charging. Lower the rating to "
f"{abs(dev_min):.0f} W or less.")
# --- 3. a meter that can actually read -----------------------------------
# ⚠️ Not a second copy of these rules - p1.py owns them, because
@@ -898,9 +864,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
phases = _number(opts, "meter_phases", 1.0)
max_age_s = _number(opts, "meter_max_age_s", 30.0)
if phases is None or max_age_s is None:
problems.append("meter_phases and meter_max_age_s must both be numbers "
f"(got {opts.get('meter_phases')!r} / "
f"{opts.get('meter_max_age_s')!r})")
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}.")
else:
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
@@ -921,15 +888,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if (is_enabled(opts) and max_age_s is not None
and max_age_s > FIRMWARE_INGEST_VETO_S):
problems.append(
f"meter_max_age_s is {max_age_s:.0f} s but the ESP32 firmware vetoes "
f"any P1 sample at {FIRMWARE_INGEST_VETO_S:.0f} s and forces the "
"layer-1 failsafe - THE FIRMWARE WINS, and it wins silently. This "
f"add-on would treat a {max_age_s - 1:.0f} s reading as fresh and "
"keep commanding, while the inverter sat at 0 W with a healthy-"
"looking meter on the status page. Lower meter_max_age_s to "
f"{FIRMWARE_INGEST_VETO_S:.0f} s or less (30 s is the tested value), "
"or rebuild the firmware with a matching max_age_s and update "
"validate.FIRMWARE_INGEST_VETO_S.")
f"{S('meter_max_age_s')} is {max_age_s:.0f} s, but the control "
f"board stops the inverter after {FIRMWARE_INGEST_VETO_S:.0f} s. "
f"Set it to {FIRMWARE_INGEST_VETO_S:.0f} s or less.")
# --- 5. a sign watchdog that can actually fire ---------------------------
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
@@ -956,19 +917,15 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
caps = [cap for _, cap in caps_ix]
weakest = min(caps) if caps else None
if sign_w is None:
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
problems.append(f"{S('sign_disagree_w')} is "
f"{opts.get('sign_disagree_w')!r}, 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"sign_disagree_w is {sign_w:.0f} W but max_w{where} is "
f"{weakest:.0f} W, so "
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
"arbiter never lets a command exceed max_w, and the watchdog needs "
f"a command over {sign_w:.0f} W. It would sit there reporting "
"'armed' on the status page while being incapable of tripping - the "
f"one runaway failure mode this system has actually observed. Lower "
f"sign_disagree_w below {weakest:.0f} W (300 W is the measured "
"default), or raise max_w.")
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the "
f"{S('max_w', where)} is {weakest:.0f} W, so this check can never "
f"trip. Lower it below {weakest:.0f} W, 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
@@ -1014,20 +971,16 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
if caps_ix else "")
if unit_sign_w is None:
problems.append(f"unit_sign_disagree_w "
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
problems.append(f"{S('unit_sign_disagree_w')} is "
f"{opts.get('unit_sign_disagree_w')!r}, 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"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
"watchdog can never fire: no inverter is ever commanded past max_w, "
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
"inverters that is the guard which catches a unit doing the "
"OPPOSITE of what it was told while its sibling covers for it at "
"the meter - the case the site-level watchdog is structurally "
f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W "
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
"measured default), or raise max_w.")
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 check can "
f"never trip. Lower it below {weakest:.0f} W, or raise the "
"rating.")
# --- 6. a maintenance charge the ceiling can actually accommodate --------
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
@@ -1100,18 +1053,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
maint_where = _unit_label(opts, maint_index)
if charge_w is None:
problems.append(
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
f"{S('maintenance_charge_w')} is "
f"{opts.get('maintenance_charge_w')!r}, 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(
f"maintenance_charge_w is {charge_w:.0f} W but max_w{maint_where} is "
f"{maint_cap:.0f} W, so a maintenance charge in a cheap window can "
"never happen: the shaping asks the arbiter for at most "
f"{-charge_w:.0f} W while the supervised limit allows no less than "
f"{-maint_cap:.0f} W, which is a contradiction it resolves to 0 W - "
"THE CEILING WINS, and what you would see is an ARBITER "
"CONTRADICTION line that reads like a bug rather than like these "
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
f"less, or raise max_w to at least {charge_w:.0f} W.")
f"{S('maintenance_charge_w')} is {charge_w:.0f} W, but the "
f"{S('max_w', maint_where)} is {maint_cap:.0f} W, so the monthly "
f"recharge cannot run. Lower it to {maint_cap:.0f} W or less.")
# --- 10. a slew rate the plant can actually follow -----------------------
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
@@ -1140,34 +1089,19 @@ 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"slew_w {opts.get('slew_w')!r} is not a number")
problems.append(f"{S('slew_w')} is {opts.get('slew_w')!r}, which is "
"not a number. Set it to 0 to follow the inverter "
"rating.")
elif slew_w < 0:
problems.append(
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
f"{S('slew_w')} is {slew_w:.0f} W and cannot be negative. Set it to "
"0 to follow the inverter rating.")
elif slew_w and rail is not None and rail > 0:
bound = SLEW_RAIL_FRAC * rail
if slew_w > bound + 1.0:
ticks = rail / slew_w
problems.append(
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
f"so the loop crosses its whole output range in {ticks:.1f} "
f"cycles - {ticks * LOOP_TICK_S:.0f} s at the {LOOP_TICK_S:.0f} s "
f"tick this site was measured running - while the plant takes "
f"{PLANT_SETTLE_S:.0f} s to settle (goodwe/NOTES.md §5.10, a "
"1.78 kW step). The controller would command its entire range "
"before it could observe its answer to the first step, which is "
"not a slow loop, it is a loop that MANUFACTURES the error it "
"then chases: measured on this system 2026-09-05, a 3.4 kW "
"export drove the setpoint 380 -> 5000 W in five ticks, the "
"battery to -5154 W past a 5000 VA rating, and the grid from "
"3.4 kW of export to 2.8 kW of import. The stated limit - the "
"command moves at most slew_w per cycle, so it cannot jump "
"ahead of the inverter - is untrue at this rail; THE PLANT "
f"WINS, and it has no status row. Set slew_w to 0, which means "
f"'follow the rail' and resolves to {bound:.0f} W here and "
"moves on its own if the rail does, or write a value at or "
f"below {bound:.0f} W. Lower is always accepted.")
f"{S('slew_w')} is {slew_w:.0f} W, too fast for a {rail:.0f} W "
f"site. Set it to 0 or to at most {bound:.0f} W.")
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
@@ -1198,22 +1132,15 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
where = _unit_label(opts, _ix)
if soc_floor is None or bms_floor is None:
problems.append(
f"peak_soc_floor and bms_soc_floor{where} must both be numbers "
f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})")
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}.")
elif soc_floor <= bms_floor:
problems.append(
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced "
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
"the pack's protection rather than yours. The guard would report a "
"SoC floor on its status page while being incapable of ever being "
"the thing that stops the discharge, and the symptom of that is a "
"battery repeatedly sitting at the BMS limit with no record of why "
f"- which looks exactly like the SoC drift the maintenance cycle "
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
f"(the shipped default is "
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
f"bms_soc_floor{where} if it does not match this pack.")
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
f"{S('bms_soc_floor', where)} of {bms_floor:.0f} %. Raise the "
f"reserve above {bms_floor:.0f} %, or correct the cut-out "
"level.")
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
@@ -1306,43 +1233,19 @@ def _price_problems(opts: dict) -> list[str]:
# fall back to, the site has had no tariff at all - a worse state, and
# one this message must not paper over.
been_running = (
f"the static day/night tariff in static_profile"
f"the {S('static_profile')}"
if str(opts.get("static_profile", "") or "").strip()
else "NOTHING AT ALL - static_profile is empty too, so there was "
"no tariff to fall back to either")
else "no tariff at all")
problems.append(
f"price_provider is {pid!r}, which WAS a shipped plugin and has "
f"been WITHDRAWN (TARIFF-07). This is not a typo and you did not "
f"mistype it - the add-on offered this id, accepted it, and has "
f"said nothing at every startup since. "
f"⚠️ IT HAS NEVER FETCHED A PRICE, HERE OR ANYWHERE: "
f"{RETIRED_PROVIDERS[pid]}. Every fetch it ever made ended in "
f"SourceUnavailable, and §11.4 turns that into a SILENT fall back "
f"- so what this site has actually been running since the day the "
f"id was set is {been_running}. Nothing you could see would have "
f"differed from a working provider that simply never found a cheap "
f"hour, which is why you are being told now rather than left on "
f"it. WHAT TO DO: set price_provider to 'generic_dayahead' for the "
f"wholesale EPEX BE day-ahead curve, to which the add-on adds your "
f"price_markup_eur_kwh and price_taxes_eur_kwh - that APPROXIMATES "
f"a Belgian dynamic tariff and cannot reproduce your supplier's "
f"B x spot + A formula, so the level will be off even when the "
f"shape is right; or 'manual_csv' to enter all-in prices you "
f"compute yourself; or 'off' to run on static_profile "
f"deliberately. There is deliberately NO alias to your old "
f"supplier: aliasing would need your contract's own two "
f"coefficients, which live behind your klantenzone login and are "
f"in no public index, so the add-on would have to invent the "
f"numbers it prices your battery against.")
f"{S('price_provider')} is {pid!r}, which was withdrawn and never "
f"fetched a price. This site ran on {been_running}. Choose "
"'generic_dayahead', 'manual_csv' or 'off'.")
return problems # nothing below can be judged against a dead id
if pid != "off" and pid not in PROVIDERS:
problems.append(
f"price_provider is {pid!r}, which is not a shipped plugin. The "
f"ids that exist are: off, {', '.join(sorted(PROVIDERS))}. "
"Refused rather than defaulted, per the §14 warning box - a "
"silent fall back to 'off' would look exactly like a working "
"provider that happens never to find a cheap hour.")
f"{S('price_provider')} is {pid!r}, which does not exist. Choose "
f"one of: off, {', '.join(sorted(PROVIDERS))}.")
return problems # nothing below can be judged against an unknown id
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
@@ -1386,19 +1289,10 @@ def _price_problems(opts: dict) -> list[str]:
if str(opts.get(k, "") or "").strip()]
if pid != "off" and ha_price:
problems.append(
f"price_provider is {pid!r} AND {' and '.join(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
"what main.py reduces to the `cheap` flag the maintenance cycle "
"reads, and read_inputs() never looks at the other source once "
"they are set. The provider series is consumed - "
"app/static_tariff.py builds a tariff plan and an arbitrage "
"verdict, and app/arbitrage.py trades on it off a background "
"refresh - but the `cheap` flag those entities own is a separate "
"decision they would silently keep. Clear "
"price_now_entity and price_avg_entity to use the "
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
"Home Assistant sensors.")
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'.")
# ⚠️ 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`
@@ -1407,14 +1301,9 @@ def _price_problems(opts: dict) -> list[str]:
# day/night tariff has no way to see that it lost.
if ha_price and str(opts.get("static_profile", "") or "").strip():
problems.append(
f"static_profile is set AND {' and '.join(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
"ENTITIES WIN: main.py reads them first and never looks at the "
"static profile, so the day/night tariff you typed would decide "
"nothing and nothing would say so. Clear price_now_entity and "
"price_avg_entity to use static_profile, or clear static_profile "
"to keep the Home Assistant sensors.")
f"{S('static_profile')} and {_labels(ha_price)} are both set, and "
"only the Home Assistant price entities would count. Clear one of "
"the two.")
# ⚠️ 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
@@ -1427,7 +1316,7 @@ def _price_problems(opts: dict) -> list[str]:
profile = parse_static_profile(raw_profile)
except ProfileInvalid as e:
profile = None
problems.append(f"static_profile cannot be used: {e}")
problems.append(f"“{messages.LABELS['static_profile']}” cannot be used. {e}")
if profile is not None and profile.product is not None \
and len(set(profile.prices)) == 1:
_LOG.info(
@@ -1452,7 +1341,7 @@ def _price_problems(opts: dict) -> list[str]:
try:
taxes_table = parse_taxes_profile(raw_taxes_profile)
except ProfileInvalid as e:
problems.append(f"{TAXES_PROFILE_OPTION} cannot be used: {e}")
problems.append(f"“{messages.LABELS[TAXES_PROFILE_OPTION]}” cannot be used. {e}")
if taxes_table is not None:
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
@@ -1482,21 +1371,13 @@ def _price_problems(opts: dict) -> list[str]:
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
superseded = ", ".join(
str(u) for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
if scalar is not None \
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
problems.append(
f"{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is "
f"{scalar} rather than its shipped {shipped}. THE TABLE WINS: "
f"the add-on reads the per-period figures and never looks at "
f"the scalar, so the number you typed there would decide "
f"nothing and nothing would say so. Set price_taxes_eur_kwh "
f"back to {shipped}, or clear {TAXES_PROFILE_OPTION} to keep "
f"the flat figure. (A scalar still sitting on a SUPERSEDED "
f"default - {superseded} - is accepted and NOT refused: "
f"that is a number this add-on wrote into your options "
f"blob, not one you typed.)")
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.")
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
@@ -1511,13 +1392,8 @@ def _price_problems(opts: dict) -> list[str]:
if abs(p.eur_per_kwh) >= 1.0})
if steep:
problems.append(
f"{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian "
f"levies and distribution run about 0.05-0.25 EUR/kWh, so "
f"these are almost certainly the CENT figures off the tariff "
f"card (ORES prints `13,27`, which is 0.1327 EUR/kWh) typed "
f"without the conversion - a factor of 100 that would make "
f"every hour look like an emergency. Divide by 100, or clear "
f"the option.")
f"{S(TAXES_PROFILE_OPTION)} has {steep} EUR/kWh, which looks "
"like cents. Divide the figures by 100, or clear this setting.")
negative = sorted({p.eur_per_kwh for p in taxes_table.periods
if p.eur_per_kwh < 0})
if negative:
@@ -1567,25 +1443,15 @@ def _price_problems(opts: dict) -> list[str]:
or isinstance(raw_formula, dict)
if pid == "custom_formula" and not has_formula:
problems.append(
"price_provider is 'custom_formula' but price_formula is empty, "
"so the add-on has no coefficients to price anything with. Every "
"fetch would return no-price-data and §11.4 would fall back "
"silently - which is the shape of defect this provider exists to "
"remove, arrived at by leaving one field blank. Copy the "
"Tariefformule line off your supplier's tariff card, e.g. "
"{\"index\":\"epex_be\",\"resolution\":\"hourly\","
"\"factor\":0.102,\"offset\":1.00,"
"\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,"
"\"card\":{\"month\":\"2026-09\","
"\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}} "
"- see DOCS.md. Or set price_provider to 'generic_dayahead' for "
"the wholesale curve with a flat markup.")
f"{S('price_provider')} is 'custom_formula', but "
f"{S('price_formula')} is empty. Copy the formula from your "
"supplier's tariff card, or choose 'generic_dayahead'.")
elif has_formula:
try:
spec = parse_price_formula(raw_formula)
except FormulaInvalid as e:
spec = None
problems.append(f"price_formula cannot be used: {e}")
problems.append(f"“{messages.LABELS['price_formula']}” cannot be used. {e}")
if spec is not None and pid != "custom_formula":
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
# fighting over one flag - it is one that is simply not wired to
@@ -1639,7 +1505,7 @@ def _price_problems(opts: dict) -> list[str]:
xspec = parse_export_formula(raw_export)
except FormulaInvalid as e:
xspec = None
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
problems.append(f"“{messages.LABELS[EXPORT_FORMULA_OPTION]}” cannot be used. {e}")
if xspec is not None:
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
@@ -1732,62 +1598,39 @@ def _price_problems(opts: dict) -> list[str]:
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
if thresh is None or thresh < 0:
problems.append(
f"cycle_cost_threshold_eur is "
f"{opts.get('cycle_cost_threshold_eur')!r} and must be a "
f"non-negative number of EUR/kWh - it is what one round trip COSTS "
f"(§10.1). A negative threshold is one that every spread clears, "
f"including a spread of nothing, which is arbitrage with the brake "
f"removed rather than arbitrage tuned aggressively.")
f"{S('cycle_cost_threshold_eur')} is "
f"{opts.get('cycle_cost_threshold_eur')!r}. Set it to 0 or more, "
"in EUR/kWh.")
if arbitrage_on(opts):
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
problems.append(
"arbitrage_enabled is on but price_provider is 'off' and "
"static_profile is empty, so this site has NO PRICES AT ALL. "
"§10.1's priority 3 is 'charge cheap, discharge expensive' and "
"there is nothing here to tell cheap from expensive, so the "
"strategy would refuse on every cycle forever while the "
"configuration said it was running. Configure a price_provider "
"or a static_profile, or turn arbitrage_enabled off.")
f"{S('arbitrage_enabled')} is on, but the site has no prices. "
f"Set {_labels(['price_provider', 'static_profile'], 'or')}, "
"or turn trading off.")
if not is_enabled(opts):
problems.append(
"arbitrage_enabled is on but meter_source is 'off', so there "
"is no P1 telegram stream, the capacity-tariff peak guard "
"never runs, and nothing measures the 15-minute average this "
"strategy's charging would land in. §9.1 gives peak shaving "
"priority over arbitrage; an unmeasured peak cannot be shaved, "
"so arbitrage would be deliberately creating the billed "
"quantity with the one guard that limits it switched off. "
"app/arbitrage.py refuses to trade in that state anyway - this "
"refuses the configuration that asks it to. Set meter_source, "
"or turn arbitrage_enabled off.")
f"{S('arbitrage_enabled')} is on, but no meter is set in "
f"{S('meter_source')}. Choose a meter, or turn trading off.")
floor = _number(opts, "arbitrage_soc_floor",
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
ceiling = _number(opts, "arbitrage_soc_ceiling",
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
if floor is None or ceiling is None:
problems.append(
f"arbitrage_soc_floor {opts.get('arbitrage_soc_floor')!r} and "
f"arbitrage_soc_ceiling {opts.get('arbitrage_soc_ceiling')!r} "
f"must both be numbers - they are percentages of pack charge.")
f"{S('arbitrage_soc_floor')} and {S('arbitrage_soc_ceiling')} "
"must both be numbers, in %.")
elif floor >= ceiling:
problems.append(
f"arbitrage_soc_floor is {floor:g} % and arbitrage_soc_ceiling "
f"is {ceiling:g} %, so there is no band left between them: "
f"every state of charge is either at or under the floor (no "
f"discharge) or at or over the ceiling (no charge), and the "
f"strategy can never act. The floor is the reserve it may not "
f"spend and the ceiling is the headroom it may not overfill, "
f"so the floor must be the SMALLER number.")
f"{S('arbitrage_soc_floor')} is {floor:g} %, not below "
f"{S('arbitrage_soc_ceiling')} at {ceiling:g} %. Make the lower "
"limit the smaller number.")
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
problems.append(
"price_provider is 'manual_csv' but price_csv_path is empty, so "
"the offline fallback has no file to read. Every fetch would "
"return no-price-data with a filesystem error as its reason - "
"which is §11.4 behaviour arrived at by accident rather than by "
"configuration. Point it at the CSV, or set price_provider to "
"'off', which says the same thing on purpose.")
f"{S('price_provider')} is 'manual_csv', but "
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
"choose 'off'.")
return problems