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
8 changed files with 152 additions and 38 deletions
Showing only changes of commit 87ad958842 - Show all commits
+9 -1
View File
@@ -1274,7 +1274,11 @@ def line_no_prices(since, now) -> str:
"""PRICE-01: no price covers now. `since` is an instant or None."""
if since is None:
return _t("no_prices_yet")
return _t("no_prices_since", at=messages.since_text(brussels(since), brussels(now)))
local, today = brussels(since), brussels(now)
if local.date() == today.date():
return _t("no_prices_since", at=f"{local:%H:%M}")
return _t("no_prices_since_day", day=local.day, month=month_name(local),
at=f"{local:%H:%M}")
def line_peak_limit(limit_w, shadow) -> str:
@@ -1398,6 +1402,10 @@ def _samples() -> list:
("plan", line_cycle_waits("Battery B")),
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
tzinfo=timezone.utc))),
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
tzinfo=timezone.utc),
datetime(2026, 10, 14, 9, 0,
tzinfo=timezone.utc))),
("plan", line_no_prices(datetime(2026, 10, 13, 22, 0,
tzinfo=timezone.utc),
datetime(2026, 10, 14, 9, 0,
+8 -7
View File
@@ -6217,18 +6217,19 @@ class Controller:
if self.price_gap and not self._price_gap_announced:
self._price_gap_announced = True
await self.hass.call("persistent_notification", "create",
messages.no_prices(self._gap_text()).data())
messages.no_prices(*self._gap_args()).data())
elif not self.price_gap and self._price_gap_announced:
self._price_gap_announced = False
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
def _gap_text(self) -> str:
"""`00:00` for a gap that began today, `13 Oct at 21:00` before that."""
def _gap_args(self) -> tuple:
"""`(since, now)` in Brussels time for the gap's words. `since` is None
when no price was ever published."""
now = brussels(datetime.now(timezone.utc))
if self.price_gap_since is None:
return ""
return messages.since_text(brussels(self.price_gap_since),
brussels(datetime.now(timezone.utc)))
return None, now
return brussels(self.price_gap_since), now
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -7442,7 +7443,7 @@ class Controller:
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# PRICE-01: only while no price covers now.
*((("Prices", messages.row_no_prices(self._gap_text())),)
*((("Prices", messages.row_no_prices(*self._gap_args())),)
if self.price_gap else ()),
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
# between a 48-hour plan and a 12-hour one on a weekday morning.
+34 -19
View File
@@ -130,7 +130,7 @@ T_METER_FROZEN = "Meter reading may be stuck"
T_MAINT_FAILED = "Battery cycle did not finish"
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
T_NO_PRICES = "No prices"
T_NO_PRICES_NL = "Geen prijzen"
T_NO_PRICES_NL = "Prijzen ontbreken"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
@@ -317,21 +317,31 @@ def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
def since_text(since, now) -> str:
"""PRICE-01: `00:00` for a moment today, `13 Oct at 21:00` for an earlier
day. Both arguments are already in the site's zone. Empty for no moment."""
NL_MONTHS = ("jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep",
"okt", "nov", "dec")
def since_text(since, now, lang="en") -> str:
"""`00:00` for a moment today, `13 Oct at 21:00` (`13 okt om 21:00` in
Dutch) for an earlier day. Both arguments are already in the site's zone.
Empty for no moment."""
if since is None:
return ""
return f"{since:%H:%M}" if since.date() == now.date() else when(since)
if since.date() == now.date():
return f"{since:%H:%M}"
if lang == "nl":
return f"{since.day} {NL_MONTHS[since.month - 1]} om {since:%H:%M}"
return when(since)
def no_prices(since_text: str = "") -> Notice:
"""PRICE-01: no source has a price for now. Said once, gone with the gap."""
if since_text:
en = (f"No prices since {since_text}, so the monthly battery cycle "
f"waits until they return.")
nl = (f"Geen prijzen sinds {since_text}, dus de maandelijkse "
f"batterijcyclus wacht tot ze terug zijn.")
def no_prices(since=None, now=None) -> Notice:
"""PRICE-01: no source has a price for now. Said once, gone with the gap.
`since` is the moment the last price ran out, or None."""
if since is not None:
en = (f"No prices since {since_text(since, now)}, so the monthly "
f"battery cycle waits until they return.")
nl = (f"Sinds {since_text(since, now, 'nl')} zijn er geen prijzen, dus "
f"de maandelijkse batterijcyclus wacht tot ze terug zijn.")
else:
en = ("No prices have come in yet, so the monthly battery cycle waits "
"until they do.")
@@ -355,10 +365,10 @@ def prices_back() -> Dismiss:
return Dismiss(ID_NO_PRICES)
def row_no_prices(since_text: str = "") -> str:
def row_no_prices(since=None, now=None) -> str:
"""PRICE-01: the status row while no price covers now."""
if since_text:
return f"No prices since {since_text}, maintenance waits."
if since is not None:
return f"No prices since {since_text(since, now)}, maintenance waits."
return "No prices yet, maintenance waits."
@@ -1106,6 +1116,11 @@ def hint_samples() -> list:
RED, AMBER, GREEN = "bad", "warn", "ok"
# PRICE-01 samples: a gap that began today, and one that began the day before.
_SINCE_SAMPLE = (datetime(2026, 10, 14, 0, 0), datetime(2026, 10, 14, 9, 0))
_SINCE_OLDER = (datetime(2026, 10, 13, 21, 0), datetime(2026, 10, 14, 9, 0))
def status_samples() -> list:
"""`(function name, colour, text)` for every row and banner, for the test.
@@ -1234,8 +1249,8 @@ def status_samples() -> list:
True)),
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
("row_no_prices", AMBER, row_no_prices("00:00")),
("row_no_prices", AMBER, row_no_prices("13 Oct at 21:00")),
("row_no_prices", AMBER, row_no_prices(*_SINCE_SAMPLE)),
("row_no_prices", AMBER, row_no_prices(*_SINCE_OLDER)),
("row_no_prices", AMBER, row_no_prices()),
]
@@ -1269,8 +1284,8 @@ def samples() -> list:
meter_frozen(90),
maint_failed("charge", 7),
maint_failed("drain", 7, "inverter-b", "inverter_b"),
no_prices("00:00"),
no_prices("13 Oct at 21:00"),
no_prices(*_SINCE_SAMPLE),
no_prices(*_SINCE_OLDER),
no_prices(),
]
+5 -1
View File
@@ -1598,7 +1598,11 @@ async def https_get(url: str, timeout_s: float, headers=None) -> bytes:
return await r.read()
try:
async with aiohttp.ClientSession(timeout=t) as s:
async with s.get(url, headers=headers) as r:
# No redirects: the host is fixed in code, and a redirect would
# take the request (and the token) somewhere the allow-list never
# saw. A 3xx is reported like any other status.
async with s.get(url, headers=headers,
allow_redirects=False) as r:
if r.status != 200:
raise SourceUnavailable(status_words(r.status))
return await r.read()
+5 -2
View File
@@ -101,8 +101,11 @@ class PriceWatch:
_LOG.warning("The price service does not answer, so prices "
"come from energy-charts.")
elif step == STEP_STORED:
_LOG.warning("No price source answers, so the add-on uses the "
"prices it saved.")
if old is not None and old > step:
_LOG.info("The add-on uses the prices it saved again.")
else:
_LOG.warning("No price source answers, so the add-on uses "
"the prices it saved.")
else:
_LOG.warning("No price source answers, and no saved price covers "
"now.")
+1
View File
@@ -412,6 +412,7 @@
"cycle_waits": "{name} waits its turn for the monthly cycle.",
"no_prices_yet": "No prices yet, maintenance waits.",
"no_prices_since": "No prices since {at}, maintenance waits.",
"no_prices_since_day": "No prices since {day} {month} at {at}, maintenance waits.",
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
+1
View File
@@ -347,6 +347,7 @@
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
"no_prices_since_day": "Geen prijzen sinds {day} {month} om {at}, onderhoud wacht.",
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
+89 -8
View File
@@ -572,6 +572,15 @@ check("a start-up with the server refusing is one WARNING, not an INFO then a "
== ["The price service refused the token, so prices come from "
"energy-charts."])
_w = C.PriceWatch()
_w.note(C.STEP_NONE)
_n = mark()
_w.note(C.STEP_STORED)
check("coming back from no price to the saved prices is one INFO, not a "
"second alarm",
since_mark(_n, logging.INFO)
== ["The add-on uses the prices it saved again."])
# =========================================================================== #
print("\n5. a full day with every source down")
# A good fetch at 13:30 Brussels with tomorrow in it; then nothing answers for
@@ -745,6 +754,7 @@ async def _scenario_gap():
await W.tick(NOW + timedelta(seconds=30 * k))
out["gap"] = (c.price_gap, c.price_gap_since, list(W.notes()))
out["rows_gap"] = dict(c.status_payload()["rows"])
out["payload_gap"] = json.dumps(c.status_payload(), default=str)
out["cheap_gap"] = (c.cheap, c._price_gated,
c._charge_gate_open(c.inverters[0]))
c.inverters[0].batt = -2000.0 # a pack really taking charge
@@ -757,6 +767,7 @@ async def _scenario_gap():
await W.tick(NOW + timedelta(minutes=30, seconds=30 * k))
out["back"] = (c.price_gap, list(W.notes()))
out["rows_back"] = dict(c.status_payload()["rows"])
out["payload_back"] = json.dumps(c.status_payload(), default=str)
out["words_back"] = c.tariff_words
return out
@@ -774,7 +785,7 @@ check("...exactly ONE notification over six ticks, with the id the docs name",
and _notes[0][1]["notification_id"] == "goodwe_no_prices")
check("...in English and Dutch, plain and short",
"No prices have come in yet" in _notes[0][1]["message"]
and "Geen prijzen. Er zijn nog geen prijzen binnen"
and "Prijzen ontbreken. Er zijn nog geen prijzen binnen"
in _notes[0][1]["message"]
and _notes[0][1]["title"] == "No prices")
check("...the status row says it once, in the card's words",
@@ -848,19 +859,39 @@ check("...the one notification carries the same moment, in English and Dutch",
len(_notes) == 1
and "No prices since 00:00, so the monthly battery cycle waits"
in _notes[0][1]["message"]
and "Geen prijzen sinds 00:00, dus de maandelijkse batterijcyclus wacht"
in _notes[0][1]["message"])
and "Prijzen ontbreken. Sinds 00:00 zijn er geen prijzen, dus de "
"maandelijkse batterijcyclus wacht" in _notes[0][1]["message"])
check("...and the Home screen says it once (English)",
_lines.count("No prices since 00:00, maintenance waits.") == 1)
from app import uitext as _uitext # noqa: E402
_nl = _uitext.words("nl")["home_words"]
check("...with the Dutch words 'Geen prijzen sinds {at}, onderhoud wacht.'",
_nl["no_prices_since"] == "Geen prijzen sinds {at}, onderhoud wacht."
and _nl["no_prices_yet"] == "Nog geen prijzen, onderhoud wacht.")
check("an earlier day is spelled with its date, not only a time",
messages.since_text(brussels(datetime(2026, 10, 13, 19, 0, tzinfo=UTC)),
brussels(datetime(2026, 10, 14, 9, 0, tzinfo=UTC)))
== "13 Oct at 21:00")
and _nl["no_prices_yet"] == "Nog geen prijzen, onderhoud wacht."
and _nl["no_prices_since_day"]
== "Geen prijzen sinds {day} {month} om {at}, onderhoud wacht.")
_a = brussels(datetime(2026, 10, 13, 19, 0, tzinfo=UTC))
_b = brussels(datetime(2026, 10, 14, 9, 0, tzinfo=UTC))
check("an earlier day is spelled with its date, not only a time, in each "
"language's own words",
messages.since_text(_a, _b) == "13 Oct at 21:00"
and messages.since_text(_a, _b, "nl") == "13 okt om 21:00"
and messages.since_text(_b, _b) == "11:00")
from app import homeview as _hv # noqa: E402
_prev = _hv._LANG[0]
try:
_hv._LANG[0] = "nl"
_nl_line = _hv.line_no_prices(datetime(2026, 10, 13, 19, 0, tzinfo=UTC),
datetime(2026, 10, 14, 9, 0, tzinfo=UTC))
_hv._LANG[0] = "en"
_en_line = _hv.line_no_prices(datetime(2026, 10, 13, 19, 0, tzinfo=UTC),
datetime(2026, 10, 14, 9, 0, tzinfo=UTC))
finally:
_hv._LANG[0] = _prev
check("...and the Home line for an earlier day is in the page's language "
f"({_en_line!r}, {_nl_line!r})",
_en_line == "No prices since 13 October at 21:00, maintenance waits."
and _nl_line == "Geen prijzen sinds 13 oktober om 21:00, onderhoud wacht.")
# =========================================================================== #
print("\n7. the token is in no log, status row, notification or error text")
@@ -873,6 +904,9 @@ check("across every scenario above the token never appeared in any log record "
and TOKEN not in (r.exc_text or "") for r in TAP.records))
check("...nor in a status row, a notification or a home line",
all(TOKEN not in blob for blob in _everything[1:]))
check("...nor anywhere in the whole status payload, during the gap or after it",
len(_S["payload_gap"]) > 1000 and len(_S["payload_back"]) > 1000
and TOKEN not in _S["payload_gap"] and TOKEN not in _S["payload_back"])
check("...nor in the reason a failed read carries, even when the library's own "
"error text holds the header it was sent",
TOKEN not in (lambda c: (c.net.server.__setattr__("mode", "refused"),
@@ -1142,6 +1176,32 @@ _v = run(_modes_plan())
check(f"...and the arbitrage verdict on them is a real one over firm prices "
f"{_v}", _v[0] == "allowed" and _v[1] > 0.1)
async def _runs_for(provider):
"""The kept price-run plan of a battery in cheap-hours mode, built by the
shipped controller from a plan that came through `provider`."""
from app import packmode
t = local_midnight(date(2026, 10, 14)) + timedelta(hours=8)
over = {"battery_modes_enabled": True, "capacity_kwh": 10}
if provider != "price_service":
over["price_provider"] = provider
with Wired(t, **over) as W:
await W.refresh()
c = W.ctl
c.store = FakeStore()
c.store.data = {packmode.unit_key("inverter-a", packmode.MODE_KEY):
packmode.CHEAP_HOURS}
c.inverters[0].soc, c.inverters[0].capacity_kwh = 40.0, 10.0
return c._price_runs(t, c._mode_packs(), 20, 95)
_ra, _rb = run(_runs_for("price_service")), run(_runs_for("generic_dayahead"))
check("a battery in cheap-hours mode gets a real price-run plan from "
"price_service prices (a cheap run is chosen)",
_ra is not None and _ra.cheap is not None)
check("...the same plan, run for run, that generic_dayahead gives on the same "
"prices: the modes read these prices as they read those",
_ra == _rb)
# =========================================================================== #
print("\n10. the options: validation, defaults, the old pair")
def problems(**over):
@@ -1198,8 +1258,18 @@ async def _wire():
if request.headers.get("Authorization") != "Bearer " + TOKEN:
return web.Response(status=401, text="bad token")
return web.Response(body=b'{"ok": 1}')
elsewhere = []
async def other(request):
elsewhere.append(dict(request.headers))
return web.Response(body=b'{"ok": 2}')
async def moved(request):
raise web.HTTPFound("/elsewhere")
app = web.Application()
app.router.add_get("/v1/dayahead/BE", handler)
app.router.add_get("/moved", moved)
app.router.add_get("/elsewhere", other)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
@@ -1219,6 +1289,13 @@ async def _wire():
res[st] = str(e)
except Exception as e: # noqa: BLE001
res[st] = "LEAK " + repr(e)
try:
await P.https_get(url.replace("/v1/dayahead/BE", "/moved"), 3,
headers=hdr)
res["302"] = "no error"
except SourceUnavailable as e:
res["302"] = str(e)
res["followed"] = len(elsewhere)
hold["mode"] = "slow"
try:
await P.https_get(url, 0.4, headers=hdr)
@@ -1252,6 +1329,10 @@ check(f"429, 5xx and other statuses have their own plain words "
_w["429"] == "the price service says too many requests (429)"
and _w["503"] == "the price service has an error (503)"
and _w["418"] == "the price service answered 418")
check(f"a redirect is not followed, so the token cannot be taken to another "
f"place: it is reported as a status and the target was never asked "
f"({_w['302']!r}, requests to the target: {_w['followed']})",
_w["302"] == "the price service answered 302" and _w["followed"] == 0)
check(f"a timeout and a refused connection are SourceUnavailable with the "
f"exception's NAME only ({_w['slow']!r}, {_w['closed']!r})",
_w["slow"].startswith("the price service did not answer (")