diff --git a/app/integrations/tibber/client.py b/app/integrations/tibber/client.py index 072caf5..f4756ec 100644 --- a/app/integrations/tibber/client.py +++ b/app/integrations/tibber/client.py @@ -30,26 +30,39 @@ logger = logging.getLogger(__name__) _TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql" _DEFAULT_TIMEOUT = 15.0 -# GraphQL query to fetch a range of 15-minute price nodes. -# ``priceInfoRange`` is a Relay-style cursor connection over the subscription's -# entire available price history. With no before/after cursor: -# * ``first: N`` returns the OLDEST N nodes (anchored at the subscription -# start date) — a fixed window that never advances, so it is WRONG for -# "current + upcoming" prices. -# * ``last: N`` returns the NEWEST N nodes (ending at the latest published -# slot: tomorrow 23:45 once day-ahead prices are out, else today 23:45). -# We want the newest slots, so we use ``last``. 192 = 2 days × 24 h × 4 slots, -# which fully covers the "today + tomorrow" window that GET /api/energy/prices -# queries (the earliest of the 192 newest slots reaches back past today 00:00 -# UTC in either publish state). +# GraphQL query to fetch the forward-looking today + tomorrow price curve at +# 15-minute resolution. +# +# ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists: +# * ``today`` — always the full current local day (96 quarter-hourly slots, +# 00:00 → 23:45 local), regardless of the current time. +# * ``tomorrow`` — the full next local day (96 slots) once Tibber publishes the +# day-ahead prices (around 13:00–15:00 local); empty before that. +# +# This is deliberately NOT ``priceInfoRange``: that field is a historical cursor +# connection whose range ends at "now" (it never returns future slots), so it +# cannot supply upcoming prices. ``priceInfo`` is forward-looking, so every +# 15-minute slot's price is present in the DB *before* the slot closes — which is +# what makes per-slot billing accurate (each period finds its own exact slot +# instead of falling back to a stale earlier price) and keeps the live current- +# price entity fresh. The hourly refresh job re-runs this query, so tomorrow's +# prices are picked up within an hour of publication without a restart. _PRICE_RANGE_QUERY = """ { viewer { homes { id currentSubscription { - priceInfoRange(resolution: QUARTER_HOURLY, last: 192) { - nodes { + priceInfo(resolution: QUARTER_HOURLY) { + today { + startsAt + total + energy + tax + currency + level + } + tomorrow { startsAt total energy @@ -236,13 +249,15 @@ def fetch_price_range( *, timeout: float = _DEFAULT_TIMEOUT, ) -> list[PricePoint]: - """Fetch a range of 15-minute price nodes from the Tibber API. + """Fetch the forward-looking today + tomorrow 15-minute price curve from Tibber. - Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, last: 192)`` query - and parses every returned node into a ``PricePoint``. ``last`` (not - ``first``) is used so the newest slots are returned; ``first`` would anchor - at the subscription start date and never advance. The number of nodes is - not assumed — all returned nodes are parsed regardless of count. + Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query + and parses every node from both lists (today first, then tomorrow) into a + ``PricePoint``. ``priceInfo`` is forward-looking — ``today`` is always the + full current local day and ``tomorrow`` is populated once Tibber publishes the + day-ahead prices — so upcoming slots are returned, unlike ``priceInfoRange`` + which only reaches "now". ``tomorrow`` may be empty (before publication); the + number of nodes is not assumed and all returned nodes are parsed. Parameters ---------- @@ -276,10 +291,15 @@ def fetch_price_range( home = _pick_home(homes, home_id) try: - nodes = home["currentSubscription"]["priceInfoRange"]["nodes"] + price_info = home["currentSubscription"]["priceInfo"] + today = price_info["today"] + tomorrow = price_info["tomorrow"] except (KeyError, TypeError) as exc: - raise TibberError("Tibber API response missing priceInfoRange nodes") from exc + raise TibberError("Tibber API response missing priceInfo today/tomorrow") from exc + # tomorrow is null/empty until Tibber publishes the day-ahead prices; treat + # a missing list as empty so we still return today's slots. + nodes = list(today or []) + list(tomorrow or []) return [_parse_node(node, "QUARTER_HOURLY") for node in nodes] diff --git a/tests/test_tibber_client.py b/tests/test_tibber_client.py index 5ea1886..9e7822e 100644 --- a/tests/test_tibber_client.py +++ b/tests/test_tibber_client.py @@ -69,22 +69,28 @@ _THREE_NODES = [ }, ] -_PRICE_RANGE_RESPONSE = { - "data": { - "viewer": { - "homes": [ - { - "id": "home-id-1", - "currentSubscription": { - "priceInfoRange": { - "nodes": _THREE_NODES, - } - }, - } - ] +def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict: + """Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response.""" + return { + "data": { + "viewer": { + "homes": [ + { + "id": "home-id-1", + "currentSubscription": { + "priceInfo": { + "today": today, + "tomorrow": tomorrow if tomorrow is not None else [], + } + }, + } + ] + } } } -} + + +_PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES) _CURRENT_PRICE_RESPONSE = { "data": { @@ -173,23 +179,8 @@ def test_fetch_price_range_parses_nodes(monkeypatch): def test_fetch_price_range_does_not_assume_node_count(monkeypatch): """Parser handles an arbitrary number of nodes (not hardcoded to 96).""" - # Build a response with a single node only. - one_node_response = { - "data": { - "viewer": { - "homes": [ - { - "id": "home-id-1", - "currentSubscription": { - "priceInfoRange": { - "nodes": [_THREE_NODES[0]], - } - }, - } - ] - } - } - } + # Build a response with a single today node and no tomorrow yet. + one_node_response = _price_info_response([_THREE_NODES[0]]) transport = _make_transport(200, one_node_response) def _patched_post(url, *, json, headers, timeout): # noqa: A002 @@ -202,6 +193,68 @@ def test_fetch_price_range_does_not_assume_node_count(monkeypatch): assert len(points) == 1 +def test_fetch_price_range_concatenates_today_and_tomorrow(monkeypatch): + """today and tomorrow node lists are both parsed (today first, then tomorrow).""" + tomorrow_nodes = [ + { + "startsAt": "2026-06-24T00:00:00.000+02:00", + "total": 0.40, + "energy": 0.32, + "tax": 0.08, + "currency": "EUR", + "level": "EXPENSIVE", + }, + ] + response = _price_info_response(_THREE_NODES, tomorrow_nodes) + transport = _make_transport(200, response) + + def _patched_post(url, *, json, headers, timeout): # noqa: A002 + client = httpx.Client(transport=transport) + return client.post(url, json=json, headers=headers, timeout=timeout) + + monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) + + points = fetch_price_range(_FAKE_TOKEN) + # 3 today + 1 tomorrow, in order. + assert len(points) == 4 + # First node is today's first; last node is tomorrow's. + assert points[0].starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC) + # 2026-06-24T00:00:00+02:00 → 2026-06-23T22:00:00Z + assert points[-1].starts_at == datetime(2026, 6, 23, 22, 0, 0, tzinfo=UTC) + assert points[-1].total == pytest.approx(0.40) + + +def test_fetch_price_range_tomorrow_null_returns_today_only(monkeypatch): + """A null tomorrow (before day-ahead publication) yields today's nodes only.""" + response = { + "data": { + "viewer": { + "homes": [ + { + "id": "home-id-1", + "currentSubscription": { + "priceInfo": { + "today": _THREE_NODES, + "tomorrow": None, + } + }, + } + ] + } + } + } + transport = _make_transport(200, response) + + def _patched_post(url, *, json, headers, timeout): # noqa: A002 + client = httpx.Client(transport=transport) + return client.post(url, json=json, headers=headers, timeout=timeout) + + monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) + + points = fetch_price_range(_FAKE_TOKEN) + assert len(points) == 3 + + def test_fetch_price_range_home_id_selection(monkeypatch): """When home_id is specified, the matching home is selected.""" two_homes_response = { @@ -211,16 +264,18 @@ def test_fetch_price_range_home_id_selection(monkeypatch): { "id": "home-id-first", "currentSubscription": { - "priceInfoRange": { - "nodes": [_THREE_NODES[0]], + "priceInfo": { + "today": [_THREE_NODES[0]], + "tomorrow": [], } }, }, { "id": "home-id-second", "currentSubscription": { - "priceInfoRange": { - "nodes": [_THREE_NODES[1], _THREE_NODES[2]], + "priceInfo": { + "today": [_THREE_NODES[1], _THREE_NODES[2]], + "tomorrow": [], } }, },