Skip to content

Commit 5dfd8a9

Browse files
committed
feat(energy): add typed energy_totals listener for calendar_history refreshes
Teslemetry/api PR 316 adds server-side polling of energy calendar history, publishing a compact energy_totals notification (id/product_type/topic/ url/createdAt/isCache/totals) instead of the full document. Adds listen_EnergyTotals on TeslemetryStreamEnergySite plus the typed EnergyHistoryTotals dataclass to expose the cumulative totals.
1 parent 384d9c7 commit 5dfd8a9

5 files changed

Lines changed: 182 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This file is the project's committed home for project-intrinsic agent knowledge:
1010
- Config responses are shaped inconsistently: success is flat, `{"updated_vehicles": n}` plus `ignoredFields` when some were dropped, while errors are wrapped, `{"response": null, "error": ...}`. Do not look for `updated_vehicles` under `response`; that lookup silently never matches.
1111
- `update_config` funnels every caller through one per-vehicle single-flight flush (`TeslemetryStreamVehicle._flush`): the first caller starts it, later callers merge into the same pending config and await it rather than starting their own PATCH. This exists because a batch of listeners scheduled at once (e.g. HA integration setup) must produce one PATCH, not one per listener - see `tests/test_batch_retry_storm.py`. A body-shaped error (`{"error": ...}`) is terminal for that batch: it is not replayed, but the pending config is kept for the next explicit `update_config` call. A transport-level failure (`aiohttp.ClientError`/timeout) gets one bounded retry inside the same flush. `tests/test_config_update.py` covers the response-shape handling.
1212
- Energy site events (`teslemetry_stream/energysite.py`) are shaped differently from vehicle signals: `live_status`/`site_info` are flat top-level envelopes (`{createdAt, site_id, isCache?, live_status|site_info}`), not nested under `data`, and the payload is a full opaque document rather than a field delta - there is no per-field config to enable, the server auto-polls subscribed sites. Contract source: Teslemetry/api PR 310 (`src/routes/sse/index.ts`, `liveStatusSchema.ts`, `siteInfoSchema.ts`), flag-gated server-side as of this writing - `tests/test_energysite_events.py` fixtures mirror that PR's schemas.
13+
- `energy_totals` (Teslemetry/api PR 316) is shaped differently again: the site id rides the `id` field, not `site_id`, alongside `product_type: "energy_site"` and `topic: "energy_totals"` - filter on those three keys, not `site_id`. It carries a compact cumulative `totals` object (`EnergyHistoryTotals` in `const.py`) instead of a document, fires only when the server's periodic `calendar_history` poll detects a change (silence is not staleness), and has no snapshot-on-connect delivery. The `url` field is the canonical REST path to GET the full time series.
1314

1415
## Maintaining this file
1516

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ async def main():
151151
> **Note:** Energy site streaming ships flag-gated behind [Teslemetry/api#310](https://github.com/Teslemetry/api/pull/310).
152152
> Until that server feature is enabled, `listen_LiveStatus`/`listen_SiteInfo` will simply never fire.
153153
154+
A third event, `energy_totals`, fires when the server's periodic
155+
`calendar_history` poll detects the day's history actually changed. It never
156+
carries the full time series - just cumulative per-type totals and a `url`
157+
to re-fetch the full document via your own REST client. Silence means no
158+
change, never a stale value.
159+
160+
```python
161+
def energy_totals_callback(totals):
162+
print(f"Total home usage: {totals.total_home_usage}")
163+
164+
remove_energy_totals_listener = site.listen_EnergyTotals(energy_totals_callback)
165+
```
166+
154167
## Public Methods in TeslemetryStream Class
155168

156169
### `__init__(session: aiohttp.ClientSession, access_token: str, server: str | None = None, vin: str | None = None, parse_timestamp: bool = False)`
@@ -248,3 +261,6 @@ Listen for energy site live status events. The callback receives the full `live_
248261

249262
### `listen_SiteInfo(callback: Callable[[dict], None]) -> Callable[[],None]`
250263
Listen for energy site info events. The callback receives the full `site_info` document.
264+
265+
### `listen_EnergyTotals(callback: Callable[[EnergyHistoryTotals], None]) -> Callable[[],None]`
266+
Listen for `energy_totals` refresh notifications. The callback receives an `EnergyHistoryTotals` dataclass of cumulative per-type totals - never the full time series. Fires only when the server's periodic history poll detects a change; a consumer wanting the full series should GET the underlying event's `url` via their own REST client.

teslemetry_stream/const.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ class Key(StrEnum):
2929
SITE_INFO = "site_info"
3030
IS_CACHE = "isCache"
3131
CREATED_AT = "createdAt"
32+
ID = "id"
33+
PRODUCT_TYPE = "product_type"
34+
TOPIC = "topic"
35+
URL = "url"
36+
TOTALS = "totals"
3237

3338

3439
class Signal(StrEnum):
@@ -311,6 +316,56 @@ class Status(StrEnum):
311316
DISCONNECTED = "DISCONNECTED"
312317

313318

319+
class ProductType(StrEnum):
320+
"""Product types carried by the uniform refresh-notification schema."""
321+
322+
ENERGY_SITE = "energy_site"
323+
324+
325+
class RefreshTopic(StrEnum):
326+
"""Topics carried by the uniform refresh-notification schema."""
327+
328+
ENERGY_TOTALS = "energy_totals"
329+
330+
331+
@dataclass
332+
class EnergyHistoryTotals:
333+
"""Cumulative per-type totals from a refreshed energy_totals document.
334+
335+
Mirrors the api's `ENERGY_HISTORY_TOTAL_FIELDS` (same names, same
336+
order): each field sums that quantity across the polled day's
337+
time_series, and stays `None` rather than 0 when the field never
338+
appeared in any period.
339+
"""
340+
341+
solar_energy_exported: float | None
342+
generator_energy_exported: float | None
343+
grid_energy_imported: float | None
344+
grid_services_energy_imported: float | None
345+
grid_services_energy_exported: float | None
346+
grid_energy_exported_from_solar: float | None
347+
grid_energy_exported_from_generator: float | None
348+
grid_energy_exported_from_battery: float | None
349+
battery_energy_exported: float | None
350+
battery_energy_imported_from_grid: float | None
351+
battery_energy_imported_from_solar: float | None
352+
battery_energy_imported_from_generator: float | None
353+
consumer_energy_imported_from_grid: float | None
354+
consumer_energy_imported_from_solar: float | None
355+
consumer_energy_imported_from_battery: float | None
356+
consumer_energy_imported_from_generator: float | None
357+
total_home_usage: float | None
358+
total_battery_charge: float | None
359+
total_battery_discharge: float | None
360+
total_solar_generation: float | None
361+
total_grid_energy_exported: float | None
362+
363+
@classmethod
364+
def from_dict(cls, data: dict[str, float | None]) -> "EnergyHistoryTotals":
365+
"""Build from the event's `totals` dict."""
366+
return cls(**{field: data.get(field) for field in cls.__dataclass_fields__})
367+
368+
314369
@dataclass
315370
class TeslaLocation:
316371
"""Location data"""

teslemetry_stream/energysite.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44
from typing import TYPE_CHECKING, Any, Callable
55

6-
from .const import Key
6+
from .const import EnergyHistoryTotals, Key, ProductType, RefreshTopic
77

88
if TYPE_CHECKING:
99
from .stream import TeslemetryStream
@@ -53,3 +53,24 @@ def listen_SiteInfo(
5353
lambda x: callback(x[Key.SITE_INFO]),
5454
{Key.SITE_ID: self.site_id, Key.SITE_INFO: None},
5555
)
56+
57+
def listen_EnergyTotals(
58+
self, callback: Callable[[EnergyHistoryTotals], None]
59+
) -> Callable[[], None]:
60+
"""Listen for energy_totals refresh notifications.
61+
62+
Unlike live_status/site_info, this event carries no full document -
63+
just cumulative totals and the event fires only when the server's
64+
5-minute poll actually detects a change. Silence means no change,
65+
never staleness; there is no snapshot-on-connect delivery. A
66+
consumer wanting the full time series must GET the event's `url`
67+
via their own REST client - this listener only exposes the totals.
68+
"""
69+
return self.stream.async_add_listener(
70+
lambda x: callback(EnergyHistoryTotals.from_dict(x[Key.TOTALS])),
71+
{
72+
Key.ID: self.site_id,
73+
Key.PRODUCT_TYPE: ProductType.ENERGY_SITE,
74+
Key.TOPIC: RefreshTopic.ENERGY_TOTALS,
75+
},
76+
)

tests/test_energysite_events.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
"""Checks energy site listener filtering against PR 310's SSE event shapes.
1+
"""Checks energy site listener filtering against PR 310/316's SSE event shapes.
22
33
Fixtures mirror the `liveStatusSchema`/`siteInfoSchema` from Teslemetry/api
44
PR 310: a flat envelope of `createdAt`, `site_id`, optional `isCache`, and
55
the full document under `live_status`/`site_info` (opaque, not a delta).
6+
`energy_totals` fixtures mirror PR 316's notification schema (renamed
7+
from `calendar_history_refreshed` to `energy_totals` post-merge): the
8+
uniform notification shape
9+
(`id`/`product_type`/`topic`/`url`/`createdAt`/`isCache`) plus a compact
10+
`totals` object - no `site_id` key, the site id rides `id` instead.
611
"""
712
from __future__ import annotations
813

914
from typing import Any
1015

16+
from teslemetry_stream.const import EnergyHistoryTotals
1117
from teslemetry_stream.stream import TeslemetryStream, recursive_match
1218

1319
SITE_A = "12345"
@@ -55,6 +61,45 @@
5561
"live_status": {"battery_power": 0},
5662
}
5763

64+
ENERGY_TOTALS_FIXTURE: dict[str, float | None] = {
65+
"solar_energy_exported": 12.3,
66+
"generator_energy_exported": None,
67+
"grid_energy_imported": 4.5,
68+
"grid_services_energy_imported": None,
69+
"grid_services_energy_exported": None,
70+
"grid_energy_exported_from_solar": None,
71+
"grid_energy_exported_from_generator": None,
72+
"grid_energy_exported_from_battery": None,
73+
"battery_energy_exported": 1.1,
74+
"battery_energy_imported_from_grid": None,
75+
"battery_energy_imported_from_solar": None,
76+
"battery_energy_imported_from_generator": None,
77+
"consumer_energy_imported_from_grid": None,
78+
"consumer_energy_imported_from_solar": None,
79+
"consumer_energy_imported_from_battery": None,
80+
"consumer_energy_imported_from_generator": None,
81+
"total_home_usage": 20.0,
82+
"total_battery_charge": None,
83+
"total_battery_discharge": None,
84+
"total_solar_generation": 12.3,
85+
"total_grid_energy_exported": None,
86+
}
87+
88+
ENERGY_TOTALS_EVENT: dict[str, Any] = {
89+
"id": SITE_A,
90+
"product_type": "energy_site",
91+
"topic": "energy_totals",
92+
"url": f"/api/1/energy_sites/{SITE_A}/calendar_history?kind=energy&period=day",
93+
"createdAt": "2026-07-29T10:16:00.000Z",
94+
"isCache": False,
95+
"totals": ENERGY_TOTALS_FIXTURE,
96+
}
97+
98+
OTHER_SITE_ENERGY_TOTALS: dict[str, Any] = {
99+
**ENERGY_TOTALS_EVENT,
100+
"id": SITE_B,
101+
}
102+
58103
CREDITS_EVENT: dict[str, Any] = {
59104
"credits": {"type": "snapshot", "cost": 0, "name": "snapshot", "balance": 5, "quota": {}},
60105
"createdAt": "2026-07-28T10:16:00.000Z",
@@ -139,6 +184,48 @@ def main() -> None:
139184
)
140185
)
141186

187+
# listen_EnergyTotals receives the parsed totals dataclass.
188+
stream = make_stream()
189+
site = stream.get_energysite(SITE_A)
190+
totals_received: list[EnergyHistoryTotals] = []
191+
site.listen_EnergyTotals(totals_received.append)
192+
dispatch(stream, ENERGY_TOTALS_EVENT)
193+
results.append(
194+
check(
195+
"listen_EnergyTotals parses the totals dict",
196+
totals_received == [EnergyHistoryTotals(**ENERGY_TOTALS_FIXTURE)],
197+
f"got {totals_received}",
198+
)
199+
)
200+
201+
# An energy_totals event for another site is not delivered.
202+
stream = make_stream()
203+
site = stream.get_energysite(SITE_A)
204+
totals_received = []
205+
site.listen_EnergyTotals(totals_received.append)
206+
dispatch(stream, OTHER_SITE_ENERGY_TOTALS)
207+
results.append(
208+
check(
209+
"a different site's energy_totals is filtered out",
210+
totals_received == [],
211+
f"got {totals_received}",
212+
)
213+
)
214+
215+
# listen_LiveStatus does not receive energy_totals events.
216+
stream = make_stream()
217+
site = stream.get_energysite(SITE_A)
218+
received = []
219+
site.listen_LiveStatus(received.append)
220+
dispatch(stream, ENERGY_TOTALS_EVENT)
221+
results.append(
222+
check(
223+
"listen_LiveStatus ignores energy_totals events",
224+
received == [],
225+
f"got {received}",
226+
)
227+
)
228+
142229
# Unrelated account-wide events (e.g. credits) are not delivered to energy listeners.
143230
stream = make_stream()
144231
site = stream.get_energysite(SITE_A)

0 commit comments

Comments
 (0)