PRICE-01: own price server as a price source, with fallbacks #190
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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 (")
|
||||
|
||||
Reference in New Issue
Block a user