Files
goodwe-addon/goodwe_controller/test_price_service.py
T
glenn schrooyenandClaude 891ed7bdd7
checks / unit (pull_request) Successful in 47m49s
infra-pin / fresh (pull_request) Successful in 2s
PRICE-02 round 2: require now, undo the test loosening, analyse ties
- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are
  homeview.trade_verdict/refusal_key's now. Existing test calls pass one.
- test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a
  one-day plan after its extremes now hold two days (as a site does); the
  flap day gets a unique cheapest and dearest hour; the replay breaks its
  ties without direction. The flap bound is <= 4 and the replay check is
  len(_trades) < 365 again. 228 checks pass on the old and the new code.
- test_price02.py part 6: ties (equal quarters, a real 15-minute day,
  alternating hourly prices) and the required now.
- Identity pins: the masked digest is only for the older-tree comparison;
  this tree's own digests are pinned with nothing hidden.
- DECISIONS: the tie analysis with numbers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 10:37:43 +02:00

1747 lines
72 KiB
Python

"""PRICE-01 - the owner's price server as a price source, and its fallbacks.
`python3 test_price_service.py`
WHAT THIS FILE DEFENDS, in the order the card names it:
1. `price_service` fetches from ONE fixed HTTPS host with a bearer token, and
the price it returns is wholesale energy, so markup and taxes are added
once, exactly as for `generic_dayahead`.
2. The chain own server -> energy-charts -> stored prices: every way the
server can fail steps down, every step down and back up is logged once,
and a day with every source down still plans from what was kept.
3. No price for now: one status line and one notification, once, gone when a
price returns. English and Dutch.
4. The token is in no log, no status row, no notification, no error text.
5. The maintenance charge gate keeps the Home Assistant rule (current price
at or below today's average), read from the provider. The same day is fed
both ways and every 15-minute period must give the same answer.
6. Modes plan on these prices.
THE SERVER IS A MOCK. This session holds no token, so it cannot ask the real
one for a price. `Server` below answers the layout the design's API section
gives (`unix_seconds`, `price`, `unit`, `zone`, `covers`, `source`,
`fetched_at`), over a transport with the same contract as `price.https_get`.
Section 11 drives the REAL `https_get` against a local aiohttp server, for the
parts a mock transport cannot show: the header on the wire, the status words,
the timeout, a refused connection.
THE PRICES ARE NOT A FLAT LINE. They are deterministic and uneven, with a
negative midday, so "at or below the average" separates quarters on both sides
and a rule that always says yes or always says no cannot pass.
⚠️ MUTATING A MODULE THIS FILE GRADES? Purge `__pycache__` first, or use
`tools/mutation_run.sh`. See MUTATION.md.
"""
import asyncio
import io
import json
import logging
import os
import shutil
import sys
import tempfile
from datetime import date, datetime, timedelta, timezone
from named_absence import nth
from app import main as _main
from app import messages, price as P, price_chain as C
from app.price import (
PROVIDERS, PriceProduct, SourceUnavailable, brussels, brussels_offset,
load_prices, scrub, status_words)
from app.price_history import PriceHistory
from app.static_tariff import resolve_tariff
from app.validate import validate_startup_config
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def run(coro):
return asyncio.run(coro)
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
TOKEN = "tk-7Hq2-secret-9xZ4-PRICE01"
HOST = P.PriceService.HOST
EC_HOST = "api.energy-charts.info"
MARKUP, TAXES = 0.031, 0.057
_TMP = []
def tmpdir(prefix="price01-"):
d = tempfile.mkdtemp(prefix=prefix)
_TMP.append(d)
return d
# --------------------------------------------------------------------------- #
# The mock server, and the day it serves
# --------------------------------------------------------------------------- #
def local_midnight(d: date) -> datetime:
"""Brussels midnight of `d` as a UTC instant. Midnight is never inside the
02:00-03:00 change, so one of the two offsets is always right."""
for off in (P.CET, P.CEST):
t = datetime(d.year, d.month, d.day, tzinfo=off)
if brussels_offset(t) == off:
return t.astimezone(UTC)
raise AssertionError(d)
def day_starts(d: date) -> list:
"""The UTC instants of every 15-minute period of Brussels day `d`: 96, or
92 and 100 on the two clock-change days."""
a, b = local_midnight(d), local_midnight(d + timedelta(days=1))
out, t = [], a
while t < b:
out.append(t)
t += timedelta(seconds=900)
return out
def ws_price(d: date, i: int) -> float:
"""EUR/MWh for period `i` of day `d`. Uneven, two decimals, negative at
midday on some days. Not a function of anything the code under test sees."""
base = 40 + 0.9 * ((d.toordinal() * 131 + i * i * 7) % 120)
if 44 <= i <= 58 and d.toordinal() % 2 == 0:
base -= 80
return round(base, 2)
def day_rows(d: date) -> list:
return [(int(t.timestamp()), ws_price(d, i))
for i, t in enumerate(day_starts(d))]
class Clock:
def __init__(self, now):
self.now = now
def __call__(self):
return self.now
class Server:
"""The price server, as the design's API section describes it.
`published(now)` is the days it holds: today always, tomorrow from 13:00
Brussels time if `tomorrow` is on. `mode` picks what it does to a request;
every mode that is not "up" is a way a real server or the road to it can
fail.
"""
def __init__(self, clock, token=TOKEN):
self.clock, self.token = clock, token
self.mode = "up"
self.tomorrow = True
self.requests = []
def days(self):
today = brussels(self.clock()).date()
return [today] + ([today + timedelta(days=1)] if self.tomorrow else [])
def doc(self, days=None):
days = self.days() if days is None else days
rows = [r for d in days for r in day_rows(d)]
return {"unix_seconds": [r[0] for r in rows],
"price": [r[1] for r in rows],
"unit": "EUR / MWh", "zone": "BE",
"covers": [d.isoformat() for d in days],
"source": {d.isoformat(): "entsoe" for d in days},
"fetched_at": self.clock().strftime("%Y-%m-%dT%H:%M:%SZ")}
def answer(self, headers):
self.requests.append(dict(headers or {}))
m = self.mode
if (headers or {}).get("Authorization") != "Bearer " + self.token \
and m == "up":
raise SourceUnavailable(status_words(401))
if m == "refused":
# A hostile library text: it carries the header it was sent.
raise ConnectionRefusedError(
f"Cannot connect to host {HOST}:443 ssl:default "
f"[Connect call failed] Authorization: Bearer {self.token}")
if m == "timeout":
raise asyncio.TimeoutError()
if m in ("401", "403", "429", "500", "503"):
raise SourceUnavailable(status_words(int(m)))
if m == "bad_json":
return b"<html>Bad gateway</html>"
d = self.doc()
if m == "no_arrays":
del d["price"]
elif m == "unequal":
d["price"] = d["price"][:-3]
elif m == "wrong_unit":
d["unit"] = "EUR / kWh"
elif m == "bad_covers":
d["covers"] = "2026-10-14"
elif m == "empty":
d["unix_seconds"], d["price"] = [], []
elif m == "stale":
d = self.doc([brussels(self.clock()).date() - timedelta(days=3)])
elif m == "uncovered":
d["covers"] = []
elif m == "partial_tomorrow":
# Tomorrow's rows are there but the server does not call the day
# complete, so it must not be used.
d["covers"] = d["covers"][:1]
return json.dumps(d).encode()
class EnergyCharts:
"""api.energy-charts.info: the columnar layout, for the dates asked for."""
def __init__(self, clock):
self.clock = clock
self.mode = "up"
self.tomorrow = True
self.requests = []
def answer(self, url):
self.requests.append(url)
if self.mode == "down":
raise SourceUnavailable("energy-charts is not answering")
if self.mode == "503":
raise ConnectionError("503 Service Unavailable")
q = dict(p.split("=") for p in url.split("?", 1)[1].split("&"))
a, b = date.fromisoformat(q["start"]), date.fromisoformat(q["end"])
today = brussels(self.clock()).date()
out, d = [], a
while d <= b:
if d <= today or (d == today + timedelta(days=1)
and self.tomorrow):
out += day_rows(d)
d += timedelta(days=1)
return json.dumps({"unix_seconds": [r[0] for r in out],
"price": [r[1] for r in out],
"unit": "EUR / MWh", "license_info": "x",
"deprecated": False}).encode()
class Net:
"""The transport. Same contract as `price.https_get`."""
def __init__(self, clock):
self.clock = clock
self.server = Server(clock)
self.ec = EnergyCharts(clock)
self.calls = []
async def __call__(self, url, timeout_s, headers=None):
self.calls.append((url, dict(headers or {})))
host = url.split("/")[2]
if host == HOST:
return self.server.answer(headers)
if host == EC_HOST:
return self.ec.answer(url)
raise AssertionError(f"a request left for an undeclared host: {url}")
def down(self):
self.server.mode, self.ec.mode = "refused", "down"
def up(self):
self.server.mode, self.ec.mode = "up", "up"
# --------------------------------------------------------------------------- #
# A place to read logs, notifications and statuses from
# --------------------------------------------------------------------------- #
class Tap(logging.Handler):
def __init__(self):
super().__init__(logging.DEBUG)
self.records = []
def emit(self, record):
self.records.append(record)
def lines(self, minimum=logging.INFO):
return [r.getMessage() for r in self.records
if r.levelno >= minimum]
def text(self):
return "\n".join(f"{r.levelname} {r.name} {r.getMessage()}"
for r in self.records)
TAP = Tap()
_root = logging.getLogger()
_root.addHandler(TAP)
_root.setLevel(logging.DEBUG)
def mark():
return len(TAP.records)
def since_mark(n, minimum=logging.INFO):
return [r.getMessage() for r in TAP.records[n:] if r.levelno >= minimum]
def opts(**over):
o = {"price_provider": "price_service", "price_service_token": TOKEN,
"price_markup_eur_kwh": MARKUP, "price_taxes_eur_kwh": TAXES,
"price_timeout_s": 5}
o.update(over)
return o
NOW = datetime(2026, 10, 14, 15, 7, tzinfo=UTC) # 17:07 Brussels (CEST)
def make_ctx(now=NOW, **over):
clock = Clock(now)
net = Net(clock)
hist = PriceHistory(os.path.join(tmpdir(), "price_history.json"))
return type("Ctx", (), {"clock": clock, "net": net, "hist": hist,
"watch": C.PriceWatch(), "o": opts(**over)})()
def refresh(ctx, now=None):
"""One background refresh, exactly as `run_tariff_refresh` calls it."""
if now is not None:
ctx.clock.now = now
now = ctx.clock.now
return run(resolve_tariff(ctx.o, now, now + timedelta(hours=48),
transport=ctx.net, now=now, history=ctx.hist,
watch=ctx.watch))
def covers_now(plan, now):
return plan.published is not None and plan.published.covers(now)
def all_in(ws, markup=MARKUP, taxes=TAXES):
return round(ws / 1000 + markup + taxes, 6)
# =========================================================================== #
print("0. the mock is the server the design describes, and the day is uneven")
_ctx = make_ctx()
_d = _ctx.net.server.doc()
check("the answer has the design's keys",
{"unix_seconds", "price", "unit", "zone", "covers", "source",
"fetched_at"} <= set(_d))
check("...with one price per start, 900 s apart, today and tomorrow",
len(_d["unix_seconds"]) == len(_d["price"]) == 192
and {b - a for a, b in zip(_d["unix_seconds"],
_d["unix_seconds"][1:])} == {900}
and _d["covers"] == ["2026-10-14", "2026-10-15"])
check("the fixture is uneven and has a negative price, so 'at or below the "
"average' separates periods on both sides",
min(_d["price"]) < 0 < max(_d["price"])
and 20 < sum(1 for p in _d["price"][:96]
if p <= sum(_d["price"][:96]) / 96) < 76)
check("clock-change days have 92 and 100 periods, not 96",
(len(day_starts(date(2026, 3, 29))), len(day_starts(date(2026, 10, 25))))
== (92, 100))
# =========================================================================== #
print("\n1. the provider: one fixed host, a bearer token, wholesale energy")
_ctx = make_ctx()
_n = mark()
_plan = refresh(_ctx)
check("the first step answers: the plan came from the price service",
_plan.step == C.STEP_OWN and _plan.published.provider == "price_service"
and _plan.from_feed)
_url, _hdr = _ctx.net.calls[0]
check("the request went to the one fixed HTTPS URL, and only to it",
_url == "https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE"
and [u for u, _h in _ctx.net.calls if HOST not in u] == [])
check("...with the token as a bearer header", _hdr == {"Authorization":
"Bearer " + TOKEN})
check("...and the token is not in the URL", TOKEN not in _url)
check("the host is declared as the plugin's one egress host, so the framework "
"refuses any other",
P.PriceService.capabilities().egress_hosts == (HOST,))
def _refused(fn):
try:
fn()
except SourceUnavailable:
return True
return False
check("...an http:// URL on the right host is refused",
_refused(lambda: P.check_egress("http://" + HOST + "/v1/dayahead/BE",
P.PriceService.capabilities())))
check("...and so is another host over https",
_refused(lambda: P.check_egress("https://example.org/v1/dayahead/BE",
P.PriceService.capabilities())))
_ctx2 = make_ctx(price_feed_url="http://evil.example/x")
refresh(_ctx2)
check("the operator's feed override is ignored by this provider: the request "
"still went to the fixed URL",
_ctx2.net.calls[0][0] == P.PriceService.URL)
# markup and taxes are added once, as for generic_dayahead
_ps = _plan.published
_gd = run(load_prices(opts(price_provider="generic_dayahead"), NOW,
NOW + timedelta(hours=48), transport=_ctx.net))
_ps_prices = [p.eur_per_kwh for p in _ps.points]
_want = [all_in(r[1]) for d in _ctx.net.server.days() for r in day_rows(d)]
check("the price is the wholesale figure plus markup and taxes, once: every "
"point equals ws/1000 + markup + taxes",
_ps_prices == _want)
check("...and a price_service series equals the generic_dayahead one for the "
"same days, point for point (start and price)",
[(p.start, p.eur_per_kwh) for p in _ps.points]
== [(p.start, p.eur_per_kwh) for p in _gd.points])
check("the provider declares energy only, so the framework adds markup and "
"taxes, and the product is day-ahead",
P.PriceService.capabilities().adds
== P.GenericDayAhead.capabilities().adds
and P.PriceService.capabilities().product is PriceProduct.DAY_AHEAD)
check("the plugin cannot reach a framework key (register() would refuse it)",
P.code_leaks(P.PriceService) == [])
_ctx3 = make_ctx(price_service_token="")
_p3 = refresh(_ctx3)
check("an empty token sends NO request to the server and falls to energy-charts",
not any(HOST in u for u, _h in _ctx3.net.calls)
and _p3.step == C.STEP_PUBLIC)
check("a token with a line break in it is never sent",
not any(HOST in u for u, _h in
(lambda c: (refresh(c), c.net.calls)[1])(
make_ctx(price_service_token="abc\r\nX-Evil: 1"))))
# =========================================================================== #
print("\n2. every way the server can fail steps down to energy-charts")
_FAILURES = {
"refused": "does not answer",
"timeout": "does not answer",
"401": "refused the token",
"403": "refused the token",
"429": "does not answer",
"500": "does not answer",
"503": "does not answer",
"bad_json": "does not answer",
"no_arrays": "does not answer",
"unequal": "does not answer",
"wrong_unit": "does not answer",
"bad_covers": "does not answer",
"empty": "does not answer",
"stale": "does not answer",
"uncovered": "does not answer",
}
for _mode, _words in _FAILURES.items():
_c = make_ctx()
_c.net.server.mode = _mode
_n = mark()
_p = refresh(_c)
_warn = since_mark(_n, logging.WARNING)
check(f"server {_mode}: prices come from energy-charts, firm, covering now",
_p.step == C.STEP_PUBLIC and _p.from_feed
and _p.published.provider == "generic_dayahead"
and covers_now(_p, _c.clock.now))
check(f"...one WARNING says why, plainly ({_warn})",
len(_warn) == 1 and _words in _warn[0]
and "energy-charts" in _warn[0])
check(f"...and the energy-charts request never carried the token",
all(TOKEN not in json.dumps(h)
for u, h in _c.net.calls if EC_HOST in u))
_n2 = mark()
refresh(_c, _c.clock.now + timedelta(minutes=30))
check(f"...and the next refresh of the same failure logs nothing again",
since_mark(_n2, logging.INFO) == [])
_c = make_ctx(price_feed_url="https://api.energy-charts.info/price?bzn=BE")
_c.net.server.mode = "refused"
_p = refresh(_c)
_ec_urls = [u for u, _h in _c.net.calls if EC_HOST in u]
check("the operator's feed override is not used for the energy-charts step: it "
"is asked for its own feed with the date range, and answers",
_p.step == C.STEP_PUBLIC and len(_ec_urls) == 1
and "start=" in _ec_urls[0] and "end=" in _ec_urls[0])
# partial day: tomorrow not published yet (before ~13:00 the server holds today)
_c = make_ctx(now=datetime(2026, 10, 14, 6, 0, tzinfo=UTC))
_c.net.server.tomorrow = False
_n = mark()
_p = refresh(_c)
check("tomorrow missing: the server is still the source, no warning",
_p.step == C.STEP_OWN and since_mark(_n, logging.WARNING) == [])
_days = {brussels(x.start).date() for x in _p.published.points}
check("...the series holds today only, and the plan carries the rest from "
"yesterday's saved day as estimated, not firm",
_days == {date(2026, 10, 14)})
_c = make_ctx()
_c.net.server.mode = "partial_tomorrow"
_p = refresh(_c)
check("a day the server does not list as complete is not used, even if its "
"rows are there",
_p.step == C.STEP_OWN
and {brussels(x.start).date() for x in _p.published.points}
== {date(2026, 10, 14)})
# =========================================================================== #
print("\n3. both sources down: the prices the history kept")
_c = make_ctx()
refresh(_c) # a fetch that worked
_snap = open(_c.hist.path, "rb").read()
_st0 = os.stat(_c.hist.path)
_c.net.down()
_n = mark()
_p = refresh(_c, NOW + timedelta(minutes=30))
_warn = since_mark(_n, logging.WARNING)
check("the plan comes from the saved prices, is firm, and covers now",
_p.step == C.STEP_STORED and _p.from_feed
and _p.published.provider == "price_history"
and covers_now(_p, _c.clock.now))
check(f"...and one WARNING says so plainly ({_warn})",
_warn == ["No price source answers, so the add-on uses the prices it "
"saved."])
check("...with the reasons of each step on a DEBUG line, token-free",
any("Price sources said" in r.getMessage() and r.levelno == logging.DEBUG
for r in TAP.records[_n:])
and all(TOKEN not in r.getMessage() for r in TAP.records[_n:]))
_st1 = os.stat(_c.hist.path)
check("...and the saved file was not written again from a read of itself (the "
"file is replaced on every save, so the same inode and time mean no save)",
open(_c.hist.path, "rb").read() == _snap
and (_st1.st_ino, _st1.st_mtime_ns) == (_st0.st_ino, _st0.st_mtime_ns))
_stored_prices = [p.eur_per_kwh for p in _p.published.points]
check("the saved prices are the ones fetched, not carried guesses",
all(not p.provisional for p in _p.published.points)
and _stored_prices == _want)
_n = mark()
refresh(_c, NOW + timedelta(minutes=60))
check("a second refresh in the same state logs nothing",
since_mark(_n, logging.INFO) == [])
# nothing stored, everything down: no price
_c = make_ctx()
_c.net.down()
_n = mark()
_p = refresh(_c)
check("with nothing saved and every source down there is no plan from a feed",
_p.step == C.STEP_NONE and not _p.from_feed and _p.published is None)
check("...the chain says so in one WARNING",
[r.getMessage() for r in TAP.records[_n:]
if r.name == "goodwe.price.chain" and r.levelno >= logging.WARNING]
== ["No price source answers, and no saved price covers now."])
check("...and what each step said is kept for DEBUG, without the token",
len(_p.reasons) == 3 and all(TOKEN not in r for r in _p.reasons)
and "price_service" in _p.reasons[0]
and "generic_dayahead" in _p.reasons[1]
and "price_history" in _p.reasons[2])
# =========================================================================== #
print("\n4. back up the chain, one line per step")
_c = make_ctx()
_c.net.down()
refresh(_c)
_n = mark()
_c.net.ec.mode = "up"
refresh(_c, NOW + timedelta(minutes=30))
check("energy-charts back: INFO says so once",
since_mark(_n, logging.INFO)
== ["Prices come from energy-charts again."])
_n = mark()
_c.net.server.mode = "up"
refresh(_c, NOW + timedelta(minutes=60))
check("the server back: INFO says so once",
since_mark(_n, logging.INFO) == ["The price service answers again."])
_n = mark()
refresh(_c, NOW + timedelta(minutes=90))
check("and nothing more while it stays up", since_mark(_n, logging.INFO) == [])
_c = make_ctx()
refresh(_c)
_c.net.down()
refresh(_c, NOW + timedelta(minutes=30))
_n = mark()
_c.net.server.mode = "up"
refresh(_c, NOW + timedelta(minutes=60))
check("stored -> server directly is one 'answers again' line",
since_mark(_n, logging.INFO) == ["The price service answers again."])
_c = make_ctx()
_n = mark()
_c.net.server.mode = "401"
refresh(_c)
check("a start-up with the server refusing is one WARNING, not an INFO then a "
"WARNING",
since_mark(_n, logging.INFO)
== ["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
# 24 hours. Every 30-minute refresh must still produce a firm plan that covers
# now, until the saved day itself runs out at midnight after tomorrow.
_start = datetime(2026, 10, 14, 11, 30, tzinfo=UTC) # 13:30 CEST
_c = make_ctx(now=_start)
refresh(_c)
_c.net.down()
_bad = []
_t = _start
_gate_nones = 0
while _t < _start + timedelta(hours=24):
_t += timedelta(minutes=30)
_p = refresh(_c, _t)
if not (_p.from_feed and _p.step == C.STEP_STORED
and covers_now(_p, _t)):
_bad.append(_t.isoformat())
if not _p.firm_points():
_bad.append("no firm point at " + _t.isoformat())
check(f"48 refreshes over 24 h, all from the saved day, all firm, all covering "
f"now ({_bad[:3]})", not _bad)
_g = C.gate_for(_p.published, _t)
check("...and the charge gate still gives an answer 24 h later",
_g.covered and _g.words.startswith("Prices from saved prices"))
_end = local = local_midnight(date(2026, 10, 16))
_p = refresh(_c, _end - timedelta(minutes=15))
check("the last saved period still covers", covers_now(_p, _end
- timedelta(minutes=15)))
_p = refresh(_c, _end)
check("and at the first moment after the saved days there is no price, and "
"the series says since when",
_p.step == C.STEP_NONE and C.gate_for(
_c.hist.stored(_end).match(series=lambda r: r, no_data=None), _end
).since == _end)
# =========================================================================== #
print("\n6. no price for now: one status line, one notification, cleared")
OPTS_C = {"setpoint_entity": "number.sp", "max_w": 5000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "meter_entity": "sensor.grid",
"maintenance_enabled": True,
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
class FakeHass:
def __init__(self):
self.calls = []
self.values = {"sensor.grid": 0.0, "sensor.soc": 55.0,
"sensor.batt": 0.0}
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
return self.values.get(entity)
async def set_number(self, entity, value):
return True
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
return True
class FakeStore:
data = {}
def set(self, *a):
pass
def get_time(self, key):
return None
class FakePub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class FakeDT(datetime):
"""`datetime` with a clock the test moves."""
_clock = None
@classmethod
def now(cls, tz=None):
t = cls._clock.now
return t.astimezone(tz) if tz else t.replace(tzinfo=None)
def controller(clock, o, tmp=None):
tmp = tmp or tmpdir()
stop = asyncio.Event()
ctl = _main.Controller(dict(OPTS_C, **o), FakeHass(), FakeStore(),
FakePub(), stop=stop,
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
return ctl
_REAL_DT, _REAL_GET = _main.datetime, P.https_get
_real_resolve = _main.resolve_tariff
class Wired:
"""A controller whose clock and network are the test's."""
def __init__(self, now, **over):
self.clock = Clock(now)
FakeDT._clock = self.clock
self.net = Net(self.clock)
self.ctl = controller(self.clock, opts(**over))
self.refreshes = 0
def __enter__(self):
_main.datetime = FakeDT
P.https_get = self.net
W = self
async def counting(*a, **k):
r = await _real_resolve(*a, **k)
W.refreshes += 1
return r
_main.resolve_tariff = counting
return self
def __exit__(self, *exc):
_main.datetime, P.https_get = _REAL_DT, _REAL_GET
_main.resolve_tariff = _real_resolve
async def refresh(self, now=None):
if now is not None:
self.clock.now = now
c = self.ctl
n = self.refreshes
c.stopping = False
c.stop_event.clear()
task = asyncio.create_task(c.run_tariff_refresh())
for _ in range(500):
if self.refreshes > n:
break
await asyncio.sleep(0.01)
await asyncio.sleep(0.02)
c.stopping = True
c.stop_event.set()
await asyncio.wait_for(task, timeout=5)
async def tick(self, now=None):
"""One control tick as `run_control` makes it: read inputs, then the
cycle, which is where the price-gap check is called from."""
if now is not None:
self.clock.now = now
self.ctl.auto = True
await self.ctl.read_inputs()
await self.ctl.cycle()
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 = {}
with Wired(NOW) as W:
c = W.ctl
out["rows_before"] = dict(c.status_payload()["rows"])
await W.tick()
out["before_first_refresh"] = (c.price_gap, list(W.notes()))
W.net.down()
await W.refresh()
for k in range(6): # six ticks inside the 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
out["open_while_charging"] = c._charge_gate_open(c.inverters[0])
c.inverters[0].batt = None
# the prices come back
W.net.up()
await W.refresh(NOW + timedelta(minutes=30))
for k in range(4):
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
_S = run(_scenario_gap())
check("before the first refresh has finished there is no gap and no "
"notification, so a start-up is not an alarm",
_S["before_first_refresh"] == (False, []) and
"Prices" not in _S["rows_before"])
_gap, _since, _notes = _S["gap"]
check("every source down with nothing saved: a gap, since nothing ever "
"published", _gap is True and _since is None)
check("...exactly ONE notification over six ticks, with the id the docs name",
len(_notes) == 1 and _notes[0][0] == "create"
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 "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",
_S["rows_gap"].get("Prices") == "No prices yet, maintenance waits.")
check("...Cheap window is no, and the charge gate is shut",
_S["cheap_gap"] == (False, True, False))
check("...but a pack that is really taking charge is not stopped by it",
_S["open_while_charging"] is True)
_gap2, _notes2 = _S["back"]
check("a price returns: the gap ends, the same notification is taken down "
"once, and no second one was raised",
_gap2 is False and len(_notes2) == 2 and _notes2[1][0] == "dismiss"
and _notes2[1][1] == {"notification_id": "goodwe_no_prices"})
check("...and the row goes with it", "Prices" not in _S["rows_back"])
check("...and the Tariff row names the source and the rule",
_S["words_back"].startswith("Prices from the price service,"))
# the refresh task stops: after 90 minutes the plan is retired and the gate
# shuts, with the moment it was retired as 'since'
async def _scenario_dead_task():
start = datetime(2026, 10, 14, 11, 30, tzinfo=UTC)
with Wired(start) as W:
c = W.ctl
await W.refresh()
await W.tick(start + timedelta(minutes=80))
before = (c.price_gap, c.cheap is not None, len(W.notes()))
await W.tick(start + timedelta(minutes=95))
await W.tick(start + timedelta(minutes=96))
mid = (c.price_gap, c.price_gap_since, list(W.notes()),
c._charge_gate_open(c.inverters[0]))
await W.refresh(start + timedelta(minutes=100))
await W.tick(start + timedelta(minutes=101))
return before, mid, (c.price_gap, list(W.notes()))
_b, _m, _a = run(_scenario_dead_task())
check("a plan 80 minutes old is still current: no gap, no notification",
_b == (False, True, 0))
check("at 95 minutes with no refresh the plan is retired: a gap, since the "
"moment it was retired (90 minutes after the fetch), one notification, "
"gate shut",
_m[0] is True and _m[1] == datetime(2026, 10, 14, 11, 30, tzinfo=UTC)
+ timedelta(seconds=_main.TARIFF_MAX_AGE_S)
and len(_m[2]) == 1 and _m[3] is False)
check("...and the next refresh that works takes it down again",
_a[0] is False and [n[0] for n in _a[1]] == ["create", "dismiss"])
# a gap that begins mid-day, with a price history that ends: 'since' is shown
async def _scenario_since():
start = datetime(2026, 10, 14, 11, 30, tzinfo=UTC)
with Wired(start) as W:
c = W.ctl
await W.refresh() # a good fetch
W.net.down()
end = local_midnight(date(2026, 10, 16)) # the saved days run out
for k in range(3):
await W.refresh(end - timedelta(minutes=30 * (2 - k)))
await W.tick(end + timedelta(minutes=5))
gap = (c.price_gap, c.price_gap_since)
row = dict(c.status_payload()["rows"])
notes = list(W.notes())
# the Home screen
from app import homeview
plan = homeview.build_plan(c, W.clock.now, False, True)
return gap, row, notes, plan["lines"], end
_gap, _row, _notes, _lines, _end = run(_scenario_since())
check("when the saved days run out, the gap begins at that moment",
_gap == (True, _end))
check("...the row reads 'No prices since 00:00, maintenance waits.' (Brussels "
"time, the card's words)",
_row.get("Prices") == "No prices since 00:00, maintenance waits.")
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 "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."
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")
_everything = [TAP.text(),
json.dumps(_S, default=str), json.dumps(
[_row, _notes, _lines], default=str)]
check("across every scenario above the token never appeared in any log record "
"at any level",
all(TOKEN not in r.getMessage() and TOKEN not in str(r.args)
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"),
c.net.ec.__setattr__("mode", "down"),
refresh(c).read.reason)[2])(make_ctx()))
_o = opts()
check("scrub() takes the value out of any text, and leaves other text alone",
scrub(f"x {TOKEN} y", _o) == "x *** y"
and scrub("no secret here", _o) == "no secret here"
and scrub(f"x {TOKEN}", {"price_service_token": ""}) == f"x {TOKEN}")
async def _hostile(url, timeout_s, headers=None):
# A transport that fails with the full request in its text, by any means.
raise RuntimeError(f"boom {headers} {url} {TOKEN}")
_r = run(load_prices(opts(), NOW, NOW + timedelta(hours=48),
transport=_hostile))
check("an exception from the transport that carries the token reaches no "
"reason text", _r.kind == "no_data" and TOKEN not in _r.reason)
def _fetch_error(transport):
"""What the PLUGIN itself raises, before the framework's scrub sees it."""
try:
run(P.PriceService().fetch(P.PluginOptions(opts()), NOW,
NOW + timedelta(hours=48), transport))
except SourceUnavailable as e:
return str(e)
return ""
_own = _fetch_error(_hostile)
check("the plugin's own error text has no token in it even before the "
"framework scrubs (defence in depth: two layers, each graded alone)",
_own.startswith("the price service did not answer (RuntimeError)")
and TOKEN not in _own)
async def _hostile2(url, timeout_s, headers=None):
raise SourceUnavailable(f"bad: {headers}")
check("a SourceUnavailable built from the headers is scrubbed too",
TOKEN not in run(load_prices(opts(), NOW, NOW + timedelta(hours=48),
transport=_hostile2)).reason)
from app import uilayout as _L, uipage as _UP # noqa: E402
check("the settings page never draws the token (it is a hidden, secret key), "
"and it can keep the provider but not choose it",
"price_service_token" in _L.HIDDEN
and _L.field("price_service_token") is None
and "price_service" in _UP.ONLY_IF_HELD)
# =========================================================================== #
print("\n8. the maintenance charge gate: the Home Assistant rule, from the "
"provider's prices")
def ha_state_now(rows, t):
"""sensor.price_service_now, as price_service.yaml computes it."""
u = [r[0] for r in rows]
p = [r[1] for r in rows]
step = (u[1] - u[0]) if len(u) > 1 else 900
v = None
for i in range(len(u)):
if u[i] <= t < u[i] + step:
v = p[i]
if not (len(u) > 0 and u[0] <= t < u[-1] + 900):
return None
return None if v is None else round(v / 1000, 5)
def ha_state_avg(rows, day_start, day_end):
"""sensor.price_service_avg, as price_service.yaml computes it."""
s = n = 0
for ts, pr in rows:
if day_start <= ts < day_end:
s += pr
n += 1
return round(s / n / 1000, 5) if n else None
def ha_cheap(rows, t, today):
"""The add-on's old rule: `now_p <= avg_p`, None means not cheap."""
now_p = ha_state_now(rows, t)
a = int(local_midnight(today).timestamp())
b = int(local_midnight(today + timedelta(days=1)).timestamp())
avg_p = ha_state_avg(rows, a, b)
return now_p is not None and avg_p is not None and now_p <= avg_p
def published_series(day_list, markup=MARKUP, taxes=TAXES):
"""The all-in series the provider would publish for these days, built by
the real framework from the mock's rows."""
c = make_ctx(now=local_midnight(day_list[0]) + timedelta(hours=10),
price_markup_eur_kwh=markup, price_taxes_eur_kwh=taxes)
c.net.server.days = lambda: day_list
p = refresh(c)
assert p.step == C.STEP_OWN, p.step
return p.published, [r for d in day_list for r in day_rows(d)]
_days = [date(2026, 10, 14), date(2026, 10, 15), date(2026, 3, 29),
date(2026, 10, 25), date(2026, 7, 1), date(2026, 1, 2)]
_diffs, _checked, _cheap_yes = [], 0, 0
for _d in _days:
_ser, _rows = published_series([_d, _d + timedelta(days=1)])
for _t in day_starts(_d):
_want = ha_cheap(_rows, int(_t.timestamp()), _d)
for dt in (0, 450, 899): # first, middle and last second
_tt = _t + timedelta(seconds=dt)
_got = C.gate_for(_ser, _tt).cheap
_checked += 1
if _got != _want:
_diffs.append((_d.isoformat(), _tt.isoformat(), _want, _got))
_cheap_yes += _want
check(f"{_checked} instants over {len(_days)} days (96-period days, the two "
f"clock-change days, winter, summer): the provider's gate equals "
f"now_p <= avg_p on the same prices, every time ({_diffs[:2]})",
not _diffs and _checked > 1000)
check("...and the days really separate: a fifth to four fifths of periods are "
"cheap, so neither 'always' nor 'never' passes",
0.2 < _cheap_yes / (_checked / 3) < 0.8)
_diffs = []
for _mk, _tx in ((0.0, 0.0), (0.03, 0.11736), (0.04321, 0.09876),
(0.0, 0.2)):
_ser, _rows = published_series([date(2026, 10, 14), date(2026, 10, 15)],
_mk, _tx)
for _t in day_starts(date(2026, 10, 14)):
if C.gate_for(_ser, _t).cheap != ha_cheap(_rows, int(_t.timestamp()),
date(2026, 10, 14)):
_diffs.append((_mk, _tx, _t.isoformat()))
check(f"...for markup and taxes of up to five decimals, with the wholesale "
f"sensor unchanged: the same answer ({_diffs[:2]})", not _diffs)
# the same day through the REAL controller, both ways
async def _both_ways(day):
rows = [r for d in (day, day + timedelta(days=1)) for r in day_rows(d)]
out = []
t0 = local_midnight(day)
with Wired(t0 + timedelta(hours=10), price_markup_eur_kwh=MARKUP,
price_taxes_eur_kwh=TAXES) as W:
W.net.server.days = lambda: [day, day + timedelta(days=1)]
W.net.ec.tomorrow = True
W.clock.now = t0 + timedelta(hours=10)
await W.refresh()
ps = W.ctl
ha_hass = FakeHass()
FakeDT._clock = W.clock
ha = controller(W.clock, {"price_provider": "off",
"price_now_entity": "sensor.now",
"price_avg_entity": "sensor.avg"})
ha.hass = ha_hass
a, b = (int(t0.timestamp()),
int(local_midnight(day + timedelta(days=1)).timestamp()))
last = None
for t in day_starts(day):
W.clock.now = t + timedelta(seconds=100)
# The background refresh runs every 30 minutes; a plan older than
# 90 minutes is retired, which is what keeps this gate shut when
# the refresh task has died.
if last is None or (t - last) >= timedelta(minutes=30):
await W.refresh(W.clock.now)
last = t
ha_hass.values["sensor.now"] = ha_state_now(
rows, int(W.clock.now.timestamp()))
ha_hass.values["sensor.avg"] = ha_state_avg(rows, a, b)
await ps.read_inputs()
await ha.read_inputs()
out.append((ha.cheap, ps.cheap,
ha._charge_gate_open(ha.inverters[0]),
ps._charge_gate_open(ps.inverters[0]),
ha._price_gated, ps._price_gated))
return out
_rows_both = run(_both_ways(date(2026, 10, 14)))
check(f"the same day fed through the Home Assistant pair and through "
f"price_service: {len(_rows_both)} periods, the cheap flag and the "
f"charge gate agree in every one",
len(_rows_both) == 96
and all(a == b and c == d and e == f
for a, b, c, d, e, f in _rows_both))
check("...with both kinds of period present (cheap and not cheap)",
{r[0] for r in _rows_both} == {True, False})
check("...and an open gate whenever it is cheap, a shut one when it is not "
"(no pack is charging)",
all(r[3] == r[1] for r in _rows_both))
# what the old rule did when the price was unavailable, and what this does
_ser, _rows = published_series([date(2026, 10, 14)])
_late = local_midnight(date(2026, 10, 15)) + timedelta(minutes=1)
check("with no price for now both rules say not cheap (the HA sensor was "
"'unavailable'), and the gate stays shut",
C.gate_for(_ser, _late).cheap is False
and ha_cheap(_rows, int(_late.timestamp()), date(2026, 10, 15)) is False)
check("it is NOT TariffPlan.cheap_now(): on a price exactly at the average "
"this gate says cheap where cheap_now (strictly below) says no",
C.gate_for(
P.PriceRead.series(
[P.PricePoint(local_midnight(date(2026, 10, 14))
+ timedelta(minutes=15 * i), v, False)
for i, v in enumerate((0.10, 0.20, 0.30))],
PriceProduct.DAY_AHEAD, "price_service", NOW),
local_midnight(date(2026, 10, 14)) + timedelta(minutes=16)).cheap
is True)
# The Home Assistant average was rounded to 5 decimals before it was compared,
# so a quarter that sits ABOVE the exact average but AT the rounded one was
# cheap. Real days almost never land on that, so it is built here: three
# periods at 0.20000 and five at 0.20001 average 0.20000625, which rounds to
# 0.20001, and a period at 0.20001 is therefore cheap by the old rule.
_t0 = local_midnight(date(2026, 10, 14))
_vals = [0.20000] * 3 + [0.20001] * 5
_toy = P.PriceRead.series(
[P.PricePoint(_t0 + timedelta(minutes=15 * i), v, False)
for i, v in enumerate(_vals)], PriceProduct.DAY_AHEAD, "price_service",
NOW)
_toy_rows = [(int((_t0 + timedelta(minutes=15 * i)).timestamp()),
round(v * 1000, 2)) for i, v in enumerate(_vals)]
_at = lambda i: _t0 + timedelta(minutes=15 * i + 1) # noqa: E731
check("a period above the exact average but at the 5-decimal rounded one is "
"cheap, as the Home Assistant pair said: the gate and the pair agree on "
"all eight periods of a day built to sit on that edge, and the edge "
"period is cheap",
[C.gate_for(_toy, _at(i)).cheap for i in range(8)]
== [ha_cheap(_toy_rows, int(_at(i).timestamp()), date(2026, 10, 14))
for i in range(8)]
and C.gate_for(_toy, _at(7)).cheap is True)
# =========================================================================== #
print("\n9. battery modes plan on the provider's prices")
async def _modes():
day = date(2026, 10, 14)
t = local_midnight(day) + timedelta(hours=8)
with Wired(t, battery_modes_enabled=True) as W:
await W.refresh()
c = W.ctl
plan = c.fresh_tariff_plan(t)
return (plan is not None and plan.has_prices, plan.origin,
plan.n_firm > 150,
[p.provider for p in [plan.read]][0], c.modes_on)
_m = run(_modes())
check("the standing plan is a feed plan of firm price_service prices covering "
"the two days, and battery modes are on",
_m[0] and _m[1] == "provider" and _m[2] and _m[3] == "price_service"
and _m[4] is True)
async def _modes_plan():
day = date(2026, 10, 14)
t = local_midnight(day) + timedelta(hours=8)
with Wired(t, battery_modes_enabled=True, arbitrage_enabled=True) as W:
await W.refresh()
c = W.ctl
plan = c.fresh_tariff_plan(t)
verdict = plan.arbitrage(
now=t, peak_guard_active=False, min_spread_eur_kwh=0.05,
uncertainty=frozenset())
return verdict.match(allowed=lambda v: ("allowed", v.spread_eur_kwh),
refused=lambda v: ("refused", v.reason))
_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):
o = {"price_provider": "price_service", "price_service_token": TOKEN}
o.update(over)
base = dict(OPTS_C)
base.update(o)
return validate_startup_config(base, None, None)
check("price_service with a token starts", problems() == [])
_e = problems(price_service_token="")
check("...without a token it is refused, naming both settings by label and key "
f"({_e})", len(_e) == 1 and "“Dynamic price source” (price_provider)"
in _e[0] and "“Price service token” (price_service_token)" in _e[0]
and TOKEN not in _e[0])
# Long enough to pass the length rule, so only the space can refuse it.
_SPACED = "tk-0123456789 abcdefgh"
_e = problems(price_service_token=_SPACED)
check("...a token with a space in it is refused for the space, and the token "
"is not echoed",
len(_e) == 1 and "space" in _e[0] and _SPACED not in _e[0])
_e = problems(price_now_entity="sensor.a", price_avg_entity="sensor.b")
check(f"the existing conflict refusal stands: the Home Assistant pair beside "
f"this provider is refused ({len(_e)} problem)",
any("would win over it" in x for x in _e))
check("a static_profile beside it is accepted (it is the fallback for "
"trading)", problems(static_profile=json.dumps({"periods": [
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
"eur_per_kwh": 0.25}]})) == [])
check("the shipped default token is empty and the option is a password field",
P.PRICE_OPTION_DEFAULTS["price_service_token"] == ""
and "price_service_token: password?" in open(
os.path.join(HERE, "config.yaml")).read())
check("config.yaml offers the new provider id",
"price_service" in open(os.path.join(HERE, "config.yaml")).read()
.split("price_provider: list(")[1].split(")")[0])
check("only this provider asks for the chain; the others take the road they "
"always took",
[pid for pid in sorted(PROVIDERS)
if C.uses_chain({"price_provider": pid})] == ["price_service"]
and not C.uses_chain({"price_provider": "off"}))
# =========================================================================== #
print("\n11. the real transport, against a local server")
async def _wire():
from aiohttp import web
seen = []
hold = {"mode": "ok"}
async def handler(request):
seen.append(dict(request.headers))
if hold["mode"] == "slow":
await asyncio.sleep(3)
if hold["mode"] in ("401", "403", "429", "503", "418"):
return web.Response(status=int(hold["mode"]), text="nope " + TOKEN)
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)
await site.start()
port = site._server.sockets[0].getsockname()[1]
url = f"http://127.0.0.1:{port}/v1/dayahead/BE"
res = {}
hdr = {"Authorization": "Bearer " + TOKEN}
res["ok"] = await P.https_get(url, 3, headers=hdr)
res["seen"] = nth(seen, -1)
for st in ("401", "403", "429", "503", "418"):
hold["mode"] = st
try:
await P.https_get(url, 3, headers=hdr)
res[st] = "no error"
except SourceUnavailable as e:
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)
res["slow"] = "no error"
except SourceUnavailable as e:
res["slow"] = str(e)
except Exception as e: # noqa: BLE001
res["slow"] = "LEAK " + repr(e)
await runner.cleanup()
try:
await P.https_get(url, 1, headers=hdr)
res["closed"] = "no error"
except SourceUnavailable as e:
res["closed"] = str(e)
except Exception as e: # noqa: BLE001
res["closed"] = "LEAK " + repr(e)
# without headers it is the old transport: a status raises aiohttp's error
return res
_w = run(_wire())
check("the token went on the wire as a bearer header, and the body came back",
_w["seen"].get("Authorization") == "Bearer " + TOKEN
and _w["ok"] == b'{"ok": 1}')
check(f"401 and 403 are 'refused the token', with the status and no body "
f"({_w['401']!r})",
_w["401"] == "the price service refused the token (401)"
and _w["403"] == "the price service refused the token (403)")
check(f"429, 5xx and other statuses have their own plain words "
f"({_w['429']!r}, {_w['503']!r}, {_w['418']!r})",
_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 (")
and _w["closed"].startswith("the price service did not answer ("))
check("...and nothing the server sent back, token echo included, reached any "
"message", all(TOKEN not in v for k, v in _w.items()
if isinstance(v, str)))
# =========================================================================== #
print("\n12. restart in the middle of an outage, and the shipped logger names")
_c = make_ctx()
refresh(_c)
_c.net.down()
_hist2 = PriceHistory(_c.hist.path) # a restart: new objects
_c2 = type("C", (), {"clock": _c.clock, "net": _c.net, "hist": _hist2,
"watch": C.PriceWatch(), "o": _c.o})()
_n = mark()
_p = refresh(_c2, NOW + timedelta(hours=1))
check("a restart with every source down plans from what the file kept",
_p.step == C.STEP_STORED and covers_now(_p, _c2.clock.now))
check("...and says so once, at WARNING",
since_mark(_n, logging.WARNING)
== ["No price source answers, so the add-on uses the prices it saved."])
check("the chain logs under goodwe.price.chain",
all(r.name == "goodwe.price.chain" for r in TAP.records[_n:]
if "price source" in r.getMessage() or "price service"
in r.getMessage()))
# =========================================================================== #
print("\n13. round 2: the no-price words only with the maintenance charge on (F3)")
async def _scenario_maint(on):
"""Every source down, six ticks, then the prices come back. `on` is the
maintenance switch."""
from app import homeview
with Wired(NOW, maintenance_enabled=on) as W:
c = W.ctl
W.net.down()
await W.refresh()
for k in range(6):
await W.tick(NOW + timedelta(seconds=30 * k))
mid = {"gap": c.price_gap, "notes": list(W.notes()),
"row": dict(c.status_payload()["rows"]).get("Prices"),
"home": [x for x in homeview.build_plan(
c, W.clock.now, False, True)["lines"]
if "No prices" in x],
"gate": c._charge_gate_open(c.inverters[0]),
"cheap": c.cheap}
W.net.up()
await W.refresh(NOW + timedelta(minutes=30))
await W.tick(NOW + timedelta(minutes=30))
return mid, list(W.notes())
_on_mid, _on_all = run(_scenario_maint(True))
_off_mid, _off_all = run(_scenario_maint(False))
check("maintenance on: the row, the Home line and ONE notification appear",
_on_mid["gap"] is True and len(_on_mid["notes"]) == 1
and _on_mid["row"] == "No prices yet, maintenance waits."
and _on_mid["home"] == ["No prices yet, maintenance waits."])
check("...and the notification is taken down once when prices return",
[n[0] for n in _on_all] == ["create", "dismiss"])
check("maintenance off: no row, no Home line, no notification, at any tick",
_off_mid["gap"] is False and _off_mid["notes"] == []
and _off_mid["row"] is None and _off_mid["home"] == []
and _off_all == [])
check("...and the gate itself does not change with the switch: still shut "
"with no price",
_off_mid["gate"] is False and _on_mid["gate"] is False
and _off_mid["cheap"] is False)
async def _scenario_switched_off():
"""A notification raised with the cycle on is cleared when it is off."""
with Wired(NOW) as W:
c = W.ctl
W.net.down()
await W.refresh()
await W.tick(NOW)
raised = list(W.notes())
c.o["maintenance_enabled"] = False
await W.tick(NOW + timedelta(seconds=30))
await W.tick(NOW + timedelta(seconds=60))
return raised, list(W.notes()), c.price_gap
_r, _n, _g = run(_scenario_switched_off())
check("a notification raised while the cycle was on is cleared, once, when "
"it is off",
len(_r) == 1 and [x[0] for x in _n] == ["create", "dismiss"]
and _g is False)
async def _scenario_key_missing():
"""No `maintenance_enabled` key at all: its shipped default (off) holds."""
with Wired(NOW) as W:
c = W.ctl
c.o.pop("maintenance_enabled", None)
W.net.down()
await W.refresh()
for k in range(3):
await W.tick(NOW + timedelta(seconds=30 * k))
return c.price_gap, list(W.notes())
_g2, _n2 = run(_scenario_key_missing())
check("with no maintenance_enabled key its default (off) holds: no no-price "
"words, no notification", _g2 is False and _n2 == [])
async def _modes_no_price():
"""Battery modes on, maintenance off, no price anywhere: what happens."""
from app import packmode, homeview
with Wired(NOW, battery_modes_enabled=True, capacity_kwh=10,
maintenance_enabled=False) as W:
c = W.ctl
W.net.down()
await W.refresh()
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
await W.tick(NOW)
runs = c._price_runs(NOW, c._mode_packs(), 20, 95)
plan = homeview.build_plan(c, NOW, False, True)["lines"]
return runs, c.price_gap, list(W.notes()), plan
_mr, _mg, _mn, _ml = run(_modes_no_price())
check("battery modes with no price and maintenance off: no price run is "
"made (so no battery holds or trades on price), and nothing about "
"'maintenance waits' is shown or notified",
_mr is None and _mg is False and _mn == []
and not [x for x in _ml if "maintenance waits" in x])
print(" Home plan lines in that state:", _ml)
# =========================================================================== #
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))
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
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",
_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 == [])
async def _scenario_fixed_level_and_raise():
"""The line's level, and a dismiss that raises: the cycle goes on."""
with Wired(NOW, static_profile=_PROFILE) as W:
real = W.ctl.hass.call
async def call(domain, service, data):
if service == "dismiss" and data.get("notification_id") == _OLD_ID:
raise RuntimeError("Home Assistant said no")
return await real(domain, service, data)
W.ctl.hass.call = call
before = mark()
await W.refresh()
try:
for k in range(2):
await W.tick(NOW + timedelta(seconds=30 * k))
done = True
except Exception as e: # noqa: BLE001 - the check reports it
done = repr(e)
mine = [(r.levelno, r.getMessage()) for r in TAP.records[before:]
if "only used if no prices" in r.getMessage()]
return done, mine
_done, _lvl = run(_scenario_fixed_level_and_raise())
check(f"the start line is INFO, not a warning ({_lvl})",
_lvl == [(logging.INFO, _LINE)])
check(f"a dismiss that raises does not stop the cycle ({_done})", _done is True)
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)")
_short = problems(price_service_token="a" * 15)
check(f"15 characters: refused with one short plain sentence ({_short})",
_short == ["“Price service token” (price_service_token) is too short. "
"Paste the whole token."])
check("...16 characters start", problems(price_service_token="a" * 16) == [])
check("...the refusal does not echo the value",
"a" * 15 not in "".join(_short))
# =========================================================================== #
print("\n16. round 2: the guards the reviewer's mutations R01 and R10 got past")
_t0 = local_midnight(date(2026, 10, 14))
_now0 = _t0 + timedelta(minutes=31) # inside period 2
_pts = [P.PricePoint(_t0 + timedelta(minutes=15 * i), v, i == 2)
for i, v in enumerate((0.10, 0.20, 0.05, 0.40))]
_prov = P.PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "price_service", NOW)
_g = C.gate_for(_prov, _now0)
check("R01: if the quarter holding now is an estimate it is not priced as "
"firm, even though it is cheap: the gate is shut and says 'no price'",
_g.cheap is False and _g.covered is False
and _g.words == "No price for now")
_firm = P.PriceRead.series(
[P.PricePoint(p.start, p.eur_per_kwh, False) for p in _pts],
PriceProduct.DAY_AHEAD, "price_service", NOW)
check("...the same series with every quarter firm opens (it is cheap), so "
"only the estimate flag shut it",
C.gate_for(_firm, _now0).cheap is True
and C.gate_for(_firm, _now0).covered is True)
_prov2 = P.PriceRead.series(
[P.PricePoint(_t0 + timedelta(minutes=15 * i), v, i == 3)
for i, v in enumerate((0.10, 0.15, 0.05, 0.40))],
PriceProduct.DAY_AHEAD, "price_service", NOW)
check("an estimated quarter elsewhere in the day stays out of today's "
"average: 0.15 against 0.10, 0.15, 0.05 is above their average (0.10), "
"where with the estimated 0.40 counted it would be cheap (0.175)",
C.gate_for(_prov2, _t0 + timedelta(minutes=16)).cheap is False)
async def _plain_wire():
from aiohttp import web, ClientResponseError
seen, elsewhere = [], []
async def ok(request):
seen.append(dict(request.headers))
return web.Response(body=b'{"ok": 1}')
async def moved(request):
raise web.HTTPFound("/elsewhere")
async def other(request):
elsewhere.append(1)
return web.Response(body=b'{"ok": 2}')
async def missing(request):
return web.Response(status=404, text="no")
async def broken(request):
return web.Response(status=503, text="no")
app = web.Application()
for path, fn in (("/ok", ok), ("/moved", moved), ("/elsewhere", other),
("/missing", missing), ("/broken", broken)):
app.router.add_get(path, fn)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
base = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}"
res = {}
try:
res["ok"] = await P.https_get(base + "/ok", 3)
res["moved"] = await P.https_get(base + "/moved", 3)
for name in ("missing", "broken"):
try:
await P.https_get(base + "/" + name, 3)
res[name] = "no error"
except ClientResponseError as e:
res[name] = e.status
except Exception as e: # noqa: BLE001
res[name] = "other " + type(e).__name__
finally:
await runner.cleanup()
res["headers"] = seen[0] if seen else {}
res["followed"] = len(elsewhere)
return res
_pw = run(_plain_wire())
check("R10: the plain transport the other providers use returns the body "
"of a 200", _pw["ok"] == b'{"ok": 1}')
check("...follows a redirect, as it always did (the price service's own "
"path does not)",
_pw["moved"] == b'{"ok": 2}' and _pw["followed"] == 1)
check("...raises the library's own error for a 4xx and a 5xx, not the price "
"service's words",
_pw["missing"] == 404 and _pw["broken"] == 503)
check("...and sends no Authorization header of its own",
"Authorization" not in _pw["headers"])
for d in _TMP:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")