Deploy 0.18.0 to master #193

Merged
admin merged 30 commits from deploy-0.18.0 into master 2026-10-16 00:42:55 +02:00
12 changed files with 211 additions and 85 deletions
Showing only changes of commit c68fd275cc - Show all commits
@@ -64,9 +64,18 @@ energy only; the framework adds markup and taxes, as for `generic_dayahead`.
classic page draws it as a password field; a save or a validate that sends
`********` or nothing keeps the stored value, any other value replaces it.
A token can therefore be changed but not cleared from the page.
7. **A fixed tariff beside `price_service` is said, not refused.** It no longer
drives the cheap window or the gate, so the add-on warns once at start
(notification *Fixed tariff not used* and a log line).
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
cheap window and the gate ignore it, because they read the provider's
prices. Keeping it set is the recommended setup, and clearing it triggers the
"static_profile is empty" warning, so no notification (round 2's *Fixed
tariff not used* was false and fired on every sound start; round 3 removed
it). Instead one INFO line at start (English, as every log line) and an info
note beside the setting (EN and NL), saying "Fixed tariff is only used if no
prices are available.", both
only with a non-blank `static_profile` (whitespace counts as blank). At
start the add-on dismisses the old notification id, in case a pre-release
build raised it.
8. **The no-price words show only with the maintenance charge on** (owner,
2026-10-14): the status row, the Home line and the notification say
'maintenance waits', so with `maintenance_enabled` off none is shown.
+14 -14
View File
@@ -2326,11 +2326,15 @@ Do this on the Configuration tab, in one save, and restart once:
the provider they are added to the wholesale price, so they set the prices
shown on the page and, if battery modes or trading are on, the prices those
decide on. The maintenance charge gate does not depend on them.
5. Keep **Fixed or day/night tariff** (`static_profile`) if you have one. It
is still the tariff used, marked as estimated and without trading, while no
source has a price. It no longer decides *Cheap window* or the maintenance
charge: the provider's prices do. The add-on says so once at start (*Fixed
tariff not used*) and does not refuse the setting.
5. Keep **Fixed or day/night tariff** (`static_profile`) if you have one; it
is fine to leave it set. It is the plan's last fallback: it is used,
marked as estimated and without trading, only when no source has a price.
*Cheap window* and the maintenance charge never read it: the provider's
prices decide them. The add-on logs one INFO line at start ("Fixed tariff
is only used if no prices are available.") and shows the same note beside
the setting. Nothing needs clearing, and clearing it only removes the
fallback (the settings page then warns that the site has no tariff while the
price feed is down).
6. Remove the `price_service` REST and template sensors from Home Assistant
when you are satisfied. They are no longer read.
@@ -3377,16 +3381,13 @@ battery goes on as before. Check that the add-on can reach the internet and that
the price service token is right. The notification goes by itself when a price
is back. It is shown only while the monthly battery cycle is switched on.
### Fixed tariff not used
A fixed or day/night tariff (`static_profile`) is set, and so is the price
service. The price service gives the prices, so the fixed tariff does not
decide *Cheap window* or the monthly battery cycle's charge. The setting is
kept and not refused. Clear it, or choose another price source, to remove the
message. It is shown once each time the add-on starts.
### For installers: notifications
A fixed tariff beside the price service raises no notification: it is the
plan's last fallback, not a fault. An earlier pre-release build raised a card
with the id `goodwe_fixed_tariff_unused`; the add-on takes that id down at every
start, which does nothing when it is absent.
This part is for installers. The texts live in `app/messages.py` and follow
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
replaces the card. The ERROR or WARNING line of the add-on log names the
@@ -3406,7 +3407,6 @@ default `info` hides it.
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
| No prices | warning | `goodwe_no_prices` | PRICE-01 |
| Fixed tariff not used | warning | `goodwe_fixed_tariff_unused` | PRICE-01 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
+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)]
+3 -1
View File
@@ -415,7 +415,9 @@ def put_in_charge(c, which, hours_ago, open_min):
def notes(h):
return [x for x in h.calls if x[0] == "persistent_notification"]
# Not the dismiss of PRICE-01's old fixed-tariff card, which every start sends.
return [x for x in h.calls if x[0] == "persistent_notification"
and x[2].get("notification_id") != "goodwe_fixed_tariff_unused"]
for cheap in (True, False):
+118 -26
View File
@@ -742,10 +742,16 @@ class Wired:
await self.ctl.read_inputs()
await self.ctl.cycle()
def notes(self):
def all_notes(self):
return [(s, d) for dom, s, d in self.ctl.hass.calls
if dom == "persistent_notification"]
def notes(self):
"""Without the dismiss of the old fixed-tariff card that every start
makes (section 14 looks at it through `all_notes`)."""
return [x for x in self.all_notes()
if x != ("dismiss", {"notification_id": "goodwe_fixed_tariff_unused"})]
async def _scenario_gap():
out = {}
@@ -1477,44 +1483,130 @@ check("battery modes with no price and maintenance off: no price run is "
print(" Home plan lines in that state:", _ml)
# =========================================================================== #
print("\n14. round 2: a fixed tariff beside price_service is said once (F2)")
print("\n14. round 3: a fixed tariff beside price_service is a log line and a "
"settings note, no notification (G1, S05)")
_PROFILE = json.dumps({"periods": [
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
"eur_per_kwh": 0.25}]})
_LINE = "Fixed tariff is only used if no prices are available."
_NOTE = ("With the price service, the fixed tariff is only used if no prices "
"are available.")
_OLD_ID = "goodwe_fixed_tariff_unused"
async def _scenario_fixed(**over):
with Wired(NOW, **over) as W:
before = mark()
await W.refresh()
for k in range(4):
await W.tick(NOW + timedelta(seconds=30 * k))
return [x for x in W.notes() if x[1].get("notification_id")
== "goodwe_fixed_tariff_unused"], W.ctl
said = [m for m in since_mark(before, logging.INFO) if "only used if no prices" in m]
return said, W.all_notes(), W.ctl
_before = mark()
_f, _fc = run(_scenario_fixed(static_profile=_PROFILE))
_logged = [m for m in since_mark(_before, logging.WARNING)
if "fixed tariff" in m]
check("price_service with a static_profile: ONE notification over four "
def _settings_notes(**over):
base = dict(OPTS_C)
base.update(price_provider="price_service", price_service_token=TOKEN)
base.update(over)
out = []
validate_startup_config(base, None, None, notes=out)
return [n.text for n in out if n.keys == ("static_profile",)
and "fixed tariff" in n.text.lower()]
_said, _fn, _fc = run(_scenario_fixed(static_profile=_PROFILE))
check("price_service + a set profile: the INFO line appears once over four "
"ticks, and the setting is not refused",
len(_f) == 1 and _f[0][0] == "create"
and problems(static_profile=_PROFILE) == [])
check("...in English and Dutch, plain and short",
_f[0][1]["title"] == "Fixed tariff not used"
and "The fixed tariff is not used, because the price service gives the "
"prices." in _f[0][1]["message"]
and "Vast tarief niet gebruikt. Het vaste tarief wordt niet gebruikt, "
"want de prijsdienst levert de prijzen." in _f[0][1]["message"])
check("...and one WARNING line in the log",
_logged == ["The fixed tariff is not used, because the price service "
"gives the prices."])
check("no static_profile: nothing is said",
run(_scenario_fixed())[0] == [])
check("another provider with a static_profile: nothing is said (there it is "
"the fallback)",
run(_scenario_fixed(price_provider="generic_dayahead",
static_profile=_PROFILE))[0] == [])
_said == [_LINE] and problems(static_profile=_PROFILE) == [])
check("...no notification is raised, only the old id is dismissed",
[x for x in _fn if x[1].get("notification_id") == _OLD_ID]
== [("dismiss", {"notification_id": _OLD_ID})]
and not [x for x in _fn if x[0] == "create"])
for _label, _kw in (("no profile", {}),
("an empty profile", {"static_profile": ""}),
("a spaces-only profile", {"static_profile": " "}),
("a line-break-only profile", {"static_profile": "\n \t"}),
("another provider", {"price_provider": "generic_dayahead",
"static_profile": _PROFILE})):
_s, _n, _c = run(_scenario_fixed(**_kw))
check(f"{_label}: no log line", _s == [])
check(f"{_label}: the old id is still dismissed at start (harmless)",
[x for x in _n if x[1].get("notification_id") == _OLD_ID]
== [("dismiss", {"notification_id": _OLD_ID})])
_off_s, _, _ = run(_scenario_fixed(price_provider="off",
static_profile=_PROFILE))
check("provider off with a profile: no log line", _off_s == [])
check("the log line is the same words as the note's second half",
_NOTE.endswith(_LINE[0].lower() + _LINE[1:]))
check("settings page: price_service + a set profile shows the one note, "
"beside static_profile", _settings_notes(static_profile=_PROFILE) == [_NOTE])
check("...not with no, empty, spaces-only or line-break-only profile",
all(_settings_notes(**kw) == [] for kw in (
{}, {"static_profile": ""}, {"static_profile": " "},
{"static_profile": "\n \t"})))
check("...not with another provider",
_settings_notes(price_provider="generic_dayahead",
static_profile=_PROFILE) == []
and _settings_notes(price_provider="off",
static_profile=_PROFILE) == [])
_nn = []
validate_startup_config(dict(OPTS_C, price_provider="price_service",
price_service_token=TOKEN,
static_profile=_PROFILE), None, None, notes=_nn)
_NOTE_NL = ("Met de prijsdienst wordt het vaste tarief alleen gebruikt als er "
"geen prijzen zijn.")
_en = _UP.anchors([], _nn, {"price_provider": "price_service"},
["price_provider", "static_profile"], "en")
_nl = _UP.anchors([], _nn, {"price_provider": "price_service"},
["price_provider", "static_profile"], "nl")
check("the page puts the note beside the fixed-tariff field, in English and "
f"Dutch ({_en})",
[m["text"] for m in _en] == [_NOTE] and [m["text"] for m in _nl]
== [_NOTE_NL] and _en[0]["tier"] == "info" and _en[0]["field"]
and _en[0]["field"] == _nl[0]["field"])
check("...and the note names no refusal: the options are still accepted",
problems(static_profile=_PROFILE) == [])
async def _scenario_profile_roles(profile):
"""Prices up: what the cheap window and the gate say. Then every source
down: what the plan falls back to."""
out = {}
over = {"static_profile": profile} if profile else {}
with Wired(NOW, **over) as W:
c = W.ctl
await W.refresh()
await W.tick()
out["cheap"], out["gate"] = c.cheap, c._charge_gate_open(c.inverters[0])
out["words"] = c.tariff_words
out["origin_up"] = c.tariff_plan.origin
W.net.down()
later = NOW + timedelta(days=5) # saved prices run out
await W.refresh(later)
await W.tick(later)
out["origin_down"] = c.tariff_plan.origin
out["gate_down"] = c._charge_gate_open(c.inverters[0])
out["points"] = c.tariff_plan.read.match(
series=lambda r: [pt.eur_per_kwh for pt in r.points][:2],
no_data=lambda r: [])
return out
_with = run(_scenario_profile_roles(_PROFILE))
_without = run(_scenario_profile_roles(None))
check("with prices up, the cheap window and the gate are the same with or "
"without a fixed tariff set",
(_with["cheap"], _with["gate"], _with["words"])
== (_without["cheap"], _without["gate"], _without["words"])
and _with["origin_up"] == "provider")
check("with every source down, the plan falls back to the fixed tariff "
f"({_with['origin_down']}, {_with['points']}) and without it there is none",
_with["origin_down"] == "fallback" and _with["points"]
and set(_with["points"]) == {0.25}
and _without["origin_down"] == "none")
check("...and the gate stays shut on that fallback (it is not a firm price)",
_with["gate_down"] is False)
# =========================================================================== #
print("\n15. round 2: a token shorter than 16 characters is refused (F5)")
+3 -1
View File
@@ -454,7 +454,9 @@ check("the saved blob passes the normal gate: no refusal, not setup-only",
print("\n after the restart, on the saved settings:")
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
services = [e for e in ev if e[0] == "SERVICE"]
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
services = [e for e in ev if e[0] == "SERVICE"
and e[3].get("notification_id") != "goodwe_fixed_tariff_unused"]
writes = [e[3].get("value") for e in services
if e[1:3] == ["number", "set_value"]]
print(f" after: exit {rc}, {len(services)} service calls, set_value "
+5 -2
View File
@@ -1111,8 +1111,11 @@ check("...carrying the reason with it, so the operator who has to decide "
check("...and it is still commanding 0 W on the far side of the restart",
_b.target == 0.0 and "circulation" in _b.reason)
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"]
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"]
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"
and c[2].get("notification_id") != "goodwe_fixed_tariff_unused"]
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"
and c[2].get("notification_id") != "goodwe_fixed_tariff_unused"]
print(f" notifications raised: {len(_notes1)} before the restart, "
f"{len(_notes2)} after it")
check("the alarm was raised in the first process", len(_notes1) >= 1)