PRICE-01 round 3: replace the fixed-tariff notification with a log line and a settings note
checks / unit (pull_request) Successful in 39m6s
infra-pin / fresh (pull_request) Successful in 1s

G1: remove the false 'Fixed tariff not used' notification. One INFO line at
start and an info note beside the setting (EN/NL) when price_service has a
non-blank static_profile; the old notification id is dismissed at start.
DOCS, migration step 5 and DECISIONS rule 7 now say what the code does.
S05: whitespace-only static_profile counts as not set.
G2: maintenance_enabled read through MaintConfig.from_options().

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-14 20:27:15 +02:00
co-authored by Claude
parent 2f261f8ba4
commit c68fd275cc
12 changed files with 211 additions and 85 deletions
+18 -15
View File
@@ -90,7 +90,7 @@ from .p1 import P1Ingest, build_source, is_enabled
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_chain import (PriceWatch, fixed_tariff_unused, gate_for,
from .price_chain import (PriceWatch, fixed_tariff_beside_service, gate_for,
uses_chain)
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_gated, static_status
@@ -1214,7 +1214,7 @@ class Controller:
self.price_gap = False
self.price_gap_since = None
self._price_gap_announced = False
self._fixed_unused_said = False
self._fixed_note_said = False
self._gated_key = None # MAINT-13: (options, hour) of the cache
# 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:
@@ -4035,7 +4035,7 @@ class Controller:
# Owner, 2026-10-14: the words say 'maintenance waits', so they show
# only while the maintenance charge is on.
self.price_gap = ((not gate.covered) and self.tariff_at is not None
and self.o.get("maintenance_enabled") is True)
and MaintConfig.from_options(self.o).enabled)
last = self._last_published
if gate.covered or last is None:
self.price_gap_since = None
@@ -5591,7 +5591,7 @@ class Controller:
# Not a safety check, so it is one of the cheap ones: an attribute test
# and, on a change only, one Home Assistant call.
await self.check_price_gap()
await self.check_fixed_unused()
await self.check_fixed_note()
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
# Unconstrained where the two above are not: it grades
@@ -6229,18 +6229,21 @@ class Controller:
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
async def check_fixed_unused(self) -> None:
"""PRICE-01 F2. A `static_profile` beside `price_service` no longer
drives the cheap window or the maintenance gate. Not refused: said once,
at the first cycle, in the log and as one notification."""
if self._fixed_unused_said:
async def check_fixed_note(self) -> None:
"""PRICE-01 G1. At the first cycle only: one INFO line when a
`static_profile` is set beside `price_service` (it is the plan's last
fallback, not a fault, so no notification), and a dismiss of the card an
earlier build raised, which is harmless when absent."""
if self._fixed_note_said:
return
self._fixed_unused_said = True
if fixed_tariff_unused(self.o):
_LOG.warning("The fixed tariff is not used, because the price "
"service gives the prices.")
await self.hass.call("persistent_notification", "create",
messages.fixed_tariff_unused().data())
self._fixed_note_said = True
if fixed_tariff_beside_service(self.o):
_LOG.info(messages.FIXED_TARIFF_LOG)
try:
await self.hass.call("persistent_notification", "dismiss",
messages.fixed_tariff_card_gone().data())
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
_LOG.debug("Could not take down the old fixed-tariff card.")
def _gap_args(self) -> tuple:
"""`(since, now)` in Brussels time for the gap's words. `since` is None
+19 -15
View File
@@ -131,8 +131,6 @@ T_MAINT_FAILED = "Battery cycle did not finish"
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
T_NO_PRICES = "No prices"
T_NO_PRICES_NL = "Prijzen ontbreken"
T_FIXED_UNUSED = "Fixed tariff not used"
T_FIXED_UNUSED_NL = "Vast tarief niet gebruikt"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
@@ -143,7 +141,9 @@ ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
ID_METER_FROZEN = "goodwe_meter_frozen"
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
ID_NO_PRICES = "goodwe_no_prices"
ID_FIXED_UNUSED = "goodwe_fixed_tariff_unused"
# PRICE-01: an earlier build raised a card with this id. Nothing raises it now;
# the add-on only dismisses it at start.
OLD_FIXED_CARD_ID = "goodwe_fixed_tariff_unused"
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
# The setting is named by its label, "Inverter rating (W)".
@@ -354,17 +354,6 @@ def no_prices(since=None, now=None) -> Notice:
body_nl=f"{T_NO_PRICES_NL}. {nl}")
def fixed_tariff_unused() -> Notice:
"""PRICE-01: a fixed tariff is set beside the price service, which wins.
Said once, at start."""
return Notice(
ID_FIXED_UNUSED, WARNING, T_FIXED_UNUSED,
"The fixed tariff is not used, because the price service gives the "
"prices.",
body_nl=f"{T_FIXED_UNUSED_NL}. Het vaste tarief wordt niet gebruikt, "
"want de prijsdienst levert de prijzen.")
@dataclass(frozen=True)
class Dismiss:
"""The payload that takes one notification down again."""
@@ -379,6 +368,22 @@ def prices_back() -> Dismiss:
return Dismiss(ID_NO_PRICES)
# PRICE-01: a fixed tariff beside the price service. One true sentence, for the
# start-up log and the settings page (neither has a Dutch layer).
FIXED_TARIFF_LOG = "Fixed tariff is only used if no prices are available."
FIXED_TARIFF_NOTE = ("With the price service, the fixed tariff is only used if "
"no prices are available.")
FIXED_TARIFF_NOTE_NL = ("Met de prijsdienst wordt het vaste tarief alleen "
"gebruikt als er geen prijzen zijn.")
# Settings-page notes that have a Dutch twin (uipage.anchors).
NOTE_NL = {FIXED_TARIFF_NOTE: FIXED_TARIFF_NOTE_NL}
def fixed_tariff_card_gone() -> Dismiss:
"""PRICE-01: takes down the card an earlier build raised."""
return Dismiss(OLD_FIXED_CARD_ID)
def row_no_prices(since=None, now=None) -> str:
"""PRICE-01: the status row while no price covers now."""
if since is not None:
@@ -1301,7 +1306,6 @@ def samples() -> list:
no_prices(*_SINCE_SAMPLE),
no_prices(*_SINCE_OLDER),
no_prices(),
fixed_tariff_unused(),
]
+4 -3
View File
@@ -50,9 +50,10 @@ def uses_chain(opts: dict) -> bool:
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
def fixed_tariff_unused(opts: dict) -> bool:
"""PRICE-01 F2: a fixed tariff is set beside `price_service`, which then
decides the cheap window and the gate. Accepted, so it is said, not refused."""
def fixed_tariff_beside_service(opts: dict) -> bool:
"""PRICE-01: a fixed tariff is set beside `price_service`. Not a fault: it
stays the plan's last fallback (`resolve_tariff`), and only the cheap window
and the gate ignore it. A blank or whitespace-only value is not set."""
return (provider_id(opts) == "price_service"
and bool(str(price_option(opts, "static_profile")).strip()))
+7 -4
View File
@@ -12,6 +12,7 @@ import copy
import os
import re
from . import messages
from . import uilayout as L
from . import uitext
from . import uimodel as U
@@ -247,7 +248,7 @@ def derive_messages(errors, problems, notes, model, keys, lang="en"):
kept = [p for p in problems if p in kept]
notes = [n for n in notes if not set(n.keys or ()) & failed]
return ([derive_message(err, lang) for err in errors]
+ anchors(kept, notes, model, keys))
+ anchors(kept, notes, model, keys, lang))
# --------------------------------------------------------------------------- #
@@ -292,9 +293,10 @@ def targets(key: str, text: str, model: dict) -> list:
return [(None, None)]
def anchors(problems, notes, model, keys) -> list:
def anchors(problems, notes, model, keys, lang="en") -> list:
"""[{field, card, tier, text}] for the refusals and notes. `field` is
None for a sentence that names no setting on the page."""
None for a sentence that names no setting on the page. A note with a Dutch
twin in `messages.NOTE_NL` is shown in Dutch when `lang` is "nl"."""
out, seen = [], set()
def add(field, card, tier, text):
@@ -312,10 +314,11 @@ def anchors(problems, notes, model, keys) -> list:
add(None, None, "bad", text)
for note in notes:
tier = note.tier if note.tier in _TIERS else "info"
text = messages.NOTE_NL.get(note.text, note.text) if lang == "nl" else note.text
for key in note.keys or (None,):
for field, card in (targets(key, note.text, model) if key
else [(None, None)]):
add(field, card, tier, note.text)
add(field, card, tier, text)
# A sentence placed on one inverter's field is not also put on the list.
roster = _roster()
placed = {m["text"] for m in out
+6
View File
@@ -1659,6 +1659,12 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
f"{S('static_profile')} is empty, so the site has no tariff "
"at all while the price feed is down.")
# PRICE-01: not a fault, so an info note and no log line here (the add-on
# logs it once at start). With `price_service` the fixed tariff is only the
# plan's last fallback; the cheap window and the gate do not read it.
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE)
# ⚠️ 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
# fire is indistinguishable from one that fires and finds no opportunity -
+2 -1
View File
@@ -312,7 +312,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
_keys_of(options),
uitext.lang_of(_body.get("lang")))
if errors
else uipage.anchors(problems, notes, model, _keys_of(options)))
else uipage.anchors(problems, notes, model, _keys_of(options),
uitext.lang_of(_body.get("lang"))))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]