From 87ad958842b03d210c75980b4ba9c62473fae7a5 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 14 Oct 2026 16:54:11 +0200 Subject: [PATCH] PRICE-01: Dutch dates, no redirects with the token, more checks - The notification, status row and Home line name an earlier day in the language of the text ("13 okt om 21:00"); the Dutch title is "Prijzen ontbreken", so the body no longer repeats it. - The token request does not follow redirects: the host is fixed in code. - A step back up to the saved prices is one INFO line. - Tests: whole status payload free of the token, a redirect, the 5-decimal edge of the old average, modes plan equal to generic_dayahead's. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- goodwe_controller/app/homeview.py | 10 ++- goodwe_controller/app/main.py | 15 ++-- goodwe_controller/app/messages.py | 53 +++++++++----- goodwe_controller/app/price.py | 6 +- goodwe_controller/app/price_chain.py | 7 +- goodwe_controller/app/ui_text/en.json | 1 + goodwe_controller/app/ui_text/nl.json | 1 + goodwe_controller/test_price_service.py | 97 +++++++++++++++++++++++-- 8 files changed, 152 insertions(+), 38 deletions(-) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index b8fa8ae..d63c552 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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, diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 4a897ae..5b148eb 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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. diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 3a986d1..8db6fb0 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -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(), ] diff --git a/goodwe_controller/app/price.py b/goodwe_controller/app/price.py index 65fd8c3..40d8982 100644 --- a/goodwe_controller/app/price.py +++ b/goodwe_controller/app/price.py @@ -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() diff --git a/goodwe_controller/app/price_chain.py b/goodwe_controller/app/price_chain.py index ce89325..adb8e6d 100644 --- a/goodwe_controller/app/price_chain.py +++ b/goodwe_controller/app/price_chain.py @@ -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.") diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index a9e496c..0645abd 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -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}.", diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index a3be7a3..4f7102c 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -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}.", diff --git a/goodwe_controller/test_price_service.py b/goodwe_controller/test_price_service.py index 27f0545..87cd687 100644 --- a/goodwe_controller/test_price_service.py +++ b/goodwe_controller/test_price_service.py @@ -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 (")