Skip to content

Commit 3fbad17

Browse files
committed
feat(energy): add typed live_status/site_info listeners for energy sites
Mirrors the vehicle listener idiom: get_energysite(site_id) returns a TeslemetryStreamEnergySite with listen_LiveStatus/listen_SiteInfo, unwrapping the flat live_status/site_info envelope (full documents, not field deltas) and delivering snapshot-then-live the same way vehicle State does.
1 parent f21237a commit 3fbad17

8 files changed

Lines changed: 324 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This file is the project's committed home for project-intrinsic agent knowledge:
99
- `Signal` in `const.py` tracks <https://api.teslemetry.com/fields.json>; the config route rejects names it does not know with `fst_err_validation`. Fields the API has retired are not rejected - it accepts the request and names them in a top-level `ignoredFields` list - so the library can lag the published list without breaking.
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.
12+
- 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.
1213

1314
## Maintaining this file
1415

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,42 @@ async def main():
114114
await stream.close()
115115
```
116116

117+
## Energy Site Streaming
118+
119+
Energy sites stream two events, `live_status` and `site_info`. Unlike vehicle
120+
signals these are delivered as full documents rather than field deltas, and
121+
there is nothing to enable - subscribed sites are polled automatically. On
122+
connect you get an initial snapshot event for each, the same way vehicle
123+
`State` is delivered on connect.
124+
125+
```python
126+
async def main():
127+
async with aiohttp.ClientSession() as session:
128+
async with TeslemetryStream(
129+
access_token="<token>",
130+
session=session,
131+
) as stream:
132+
133+
site = stream.get_energysite("<site_id>")
134+
135+
def live_status_callback(live_status):
136+
print(f"Battery Power: {live_status.get('battery_power')}")
137+
138+
def site_info_callback(site_info):
139+
print(f"Site Name: {site_info.get('site_name')}")
140+
141+
remove_live_status_listener = site.listen_LiveStatus(live_status_callback)
142+
remove_site_info_listener = site.listen_SiteInfo(site_info_callback)
143+
144+
print("Running")
145+
await asyncio.sleep(60)
146+
remove_live_status_listener()
147+
remove_site_info_listener()
148+
```
149+
150+
> **Note:** Energy site streaming ships flag-gated behind [Teslemetry/api#310](https://github.com/Teslemetry/api/pull/310).
151+
> Until that server feature is enabled, `listen_LiveStatus`/`listen_SiteInfo` will simply never fire.
152+
117153
## Public Methods in TeslemetryStream Class
118154

119155
### `__init__(session: aiohttp.ClientSession, access_token: str, server: str | None = None, vin: str | None = None, parse_timestamp: bool = False)`
@@ -122,6 +158,9 @@ Initialize the TeslemetryStream client.
122158
### `get_vehicle(vin: str) -> TeslemetryStreamVehicle`
123159
Create a vehicle object to manage config and create listeners.
124160

161+
### `get_energysite(site_id: str | int) -> TeslemetryStreamEnergySite`
162+
Create an energy site object to create listeners for `live_status` and `site_info`.
163+
125164
### `connected -> bool`
126165
Return if connected.
127166

@@ -197,3 +236,14 @@ Listen for vehicle error events. The callback receives a list of dictionaries co
197236
The `TeslemetryStreamVehicle` class contains a `listen_*` methods for each telemetry signal.
198237
These methods allow you to listen to specific signals and handle their data in a type-safe manner.
199238
A full list of fields and metadata can be found at [api.teslemetry.com/fields.json](https://api.teslemetry.com/fields.json)
239+
240+
## Public Methods in TeslemetryStreamEnergySite Class
241+
242+
### `__init__(stream: TeslemetryStream, site_id: str)`
243+
Initialize the TeslemetryStreamEnergySite instance.
244+
245+
### `listen_LiveStatus(callback: Callable[[dict], None]) -> Callable[[],None]`
246+
Listen for energy site live status events. The callback receives the full `live_status` document.
247+
248+
### `listen_SiteInfo(callback: Callable[[dict], None]) -> Callable[[],None]`
249+
Listen for energy site info events. The callback receives the full `site_info` document.

teslemetry_stream/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .stream import TeslemetryStream
22
from .vehicle import TeslemetryStreamVehicle
3+
from .energysite import TeslemetryStreamEnergySite
34
from .exception import (
45
TeslemetryStreamError,
56
TeslemetryStreamConnectionError,
@@ -11,6 +12,7 @@
1112
__all__ = [
1213
"TeslemetryStream",
1314
"TeslemetryStreamVehicle",
15+
"TeslemetryStreamEnergySite",
1416
"TeslemetryStreamError",
1517
"TeslemetryStreamConnectionError",
1618
"TeslemetryStreamVehicleNotConfigured",

teslemetry_stream/const.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ class Key(StrEnum):
2424
STATE = "state"
2525
STATUS = "status"
2626
NETWORK_INTERFACE = "networkInterface"
27+
SITE_ID = "site_id"
28+
LIVE_STATUS = "live_status"
29+
SITE_INFO = "site_info"
30+
IS_CACHE = "isCache"
31+
CREATED_AT = "createdAt"
2732

2833

2934
class Signal(StrEnum):

teslemetry_stream/energysite.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Energy site class for handling streaming live_status and site_info updates."""
2+
3+
from __future__ import annotations
4+
from typing import TYPE_CHECKING, Any, Callable
5+
6+
from .const import Key
7+
8+
if TYPE_CHECKING:
9+
from .stream import TeslemetryStream
10+
else:
11+
TeslemetryStream = None
12+
13+
14+
class TeslemetryStreamEnergySite:
15+
"""Handle streaming energy site updates.
16+
17+
Unlike vehicle signals, energy `live_status` and `site_info` events are
18+
full documents rather than field deltas, and there is no per-field
19+
config to enable - the server auto-polls subscribed sites - so listeners
20+
here just filter and unwrap the matching topic.
21+
"""
22+
23+
def __init__(self, stream: TeslemetryStream, site_id: str):
24+
self.stream = stream
25+
self.site_id = str(site_id)
26+
27+
def listen_LiveStatus(
28+
self, callback: Callable[[dict[str, Any]], None]
29+
) -> Callable[[], None]:
30+
"""Listen for energy site live status.
31+
32+
The callback receives the full live_status document. On connect (and
33+
whenever a snapshot exists), an initial event is delivered with
34+
`isCache` set, matching the same snapshot-then-live semantics as
35+
vehicle state.
36+
"""
37+
return self.stream.async_add_listener(
38+
lambda x: callback(x[Key.LIVE_STATUS]),
39+
{Key.SITE_ID: self.site_id, Key.LIVE_STATUS: None},
40+
)
41+
42+
def listen_SiteInfo(
43+
self, callback: Callable[[dict[str, Any]], None]
44+
) -> Callable[[], None]:
45+
"""Listen for energy site info.
46+
47+
The callback receives the full site_info document. On connect (and
48+
whenever a snapshot exists), an initial event is delivered with
49+
`isCache` set, matching the same snapshot-then-live semantics as
50+
vehicle state.
51+
"""
52+
return self.stream.async_add_listener(
53+
lambda x: callback(x[Key.SITE_INFO]),
54+
{Key.SITE_ID: self.site_id, Key.SITE_INFO: None},
55+
)

teslemetry_stream/stream.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .exception import TeslemetryStreamEnded
1111
from .vehicle import TeslemetryStreamVehicle
12+
from .energysite import TeslemetryStreamEnergySite
1213

1314
LOGGER = logging.getLogger(__package__)
1415

@@ -53,6 +54,7 @@ def __init__(
5354
self.manual = manual
5455
self.retries: int = 0
5556
self.vehicles: dict[str, TeslemetryStreamVehicle] = {}
57+
self.energysites: dict[str, TeslemetryStreamEnergySite] = {}
5658
self.fields: dict[str, Any] = {}
5759

5860
if self.vin:
@@ -80,6 +82,18 @@ def get_vehicle(self, vin: str) -> TeslemetryStreamVehicle:
8082
self.vehicles[vin] = TeslemetryStreamVehicle(self, vin)
8183
return self.vehicles[vin]
8284

85+
def get_energysite(self, site_id: str | int) -> TeslemetryStreamEnergySite:
86+
"""
87+
Create an energy site stream.
88+
89+
:param site_id: Numeric energy site ID.
90+
:return: TeslemetryStreamEnergySite instance.
91+
"""
92+
site_id = str(site_id)
93+
if site_id not in self.energysites:
94+
self.energysites[site_id] = TeslemetryStreamEnergySite(self, site_id)
95+
return self.energysites[site_id]
96+
8397
@property
8498
def connected(self) -> bool:
8599
"""

tests/test_energysite_events.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""Checks energy site listener filtering against PR 310's SSE event shapes.
2+
3+
Fixtures mirror the `liveStatusSchema`/`siteInfoSchema` from Teslemetry/api
4+
PR 310: a flat envelope of `createdAt`, `site_id`, optional `isCache`, and
5+
the full document under `live_status`/`site_info` (opaque, not a delta).
6+
"""
7+
from __future__ import annotations
8+
9+
from typing import Any
10+
11+
from teslemetry_stream.stream import TeslemetryStream, recursive_match
12+
13+
SITE_A = "12345"
14+
SITE_B = "67890"
15+
16+
LIVE_STATUS_SNAPSHOT: dict[str, Any] = {
17+
"createdAt": "2026-07-28T10:15:30.000Z",
18+
"site_id": SITE_A,
19+
"isCache": True,
20+
"live_status": {
21+
"battery_power": 1200,
22+
"load_power": 900,
23+
"grid_power": -300,
24+
"solar_power": 2400,
25+
"percentage_charged": 82.5,
26+
},
27+
}
28+
29+
LIVE_STATUS_LIVE: dict[str, Any] = {
30+
"createdAt": "2026-07-28T10:16:00.000Z",
31+
"site_id": SITE_A,
32+
"live_status": {
33+
"battery_power": 1100,
34+
"load_power": 950,
35+
"grid_power": -150,
36+
"solar_power": 2200,
37+
"percentage_charged": 82.6,
38+
},
39+
}
40+
41+
SITE_INFO_SNAPSHOT: dict[str, Any] = {
42+
"createdAt": "2026-07-28T10:15:30.000Z",
43+
"site_id": SITE_A,
44+
"isCache": True,
45+
"site_info": {
46+
"site_name": "Home",
47+
"backup_reserve_percent": 20,
48+
"default_real_mode": "self_consumption",
49+
},
50+
}
51+
52+
OTHER_SITE_LIVE_STATUS: dict[str, Any] = {
53+
"createdAt": "2026-07-28T10:16:00.000Z",
54+
"site_id": SITE_B,
55+
"live_status": {"battery_power": 0},
56+
}
57+
58+
CREDITS_EVENT: dict[str, Any] = {
59+
"credits": {"type": "snapshot", "cost": 0, "name": "snapshot", "balance": 5, "quota": {}},
60+
"createdAt": "2026-07-28T10:16:00.000Z",
61+
"isCache": True,
62+
}
63+
64+
65+
def make_stream() -> TeslemetryStream:
66+
"""Build a stream that never actually connects."""
67+
return TeslemetryStream(session=None, access_token="test-token", manual=True) # type: ignore[arg-type]
68+
69+
70+
def dispatch(stream: TeslemetryStream, event: dict[str, Any]) -> None:
71+
"""Replicate stream.listen()'s per-event dispatch without a live connection."""
72+
for listener, filters in list(stream._listeners.values()):
73+
if recursive_match(filters, event):
74+
listener(event)
75+
76+
77+
def check(label: str, ok: bool, detail: str = "") -> bool:
78+
print(f"{label:<64} {'PASS' if ok else 'FAIL'}{' ' + detail if detail else ''}")
79+
return ok
80+
81+
82+
def main() -> None:
83+
results = []
84+
85+
# listen_LiveStatus receives the unwrapped live_status document, snapshot and live alike.
86+
stream = make_stream()
87+
site = stream.get_energysite(SITE_A)
88+
received: list[dict[str, Any]] = []
89+
site.listen_LiveStatus(received.append)
90+
dispatch(stream, LIVE_STATUS_SNAPSHOT)
91+
dispatch(stream, LIVE_STATUS_LIVE)
92+
results.append(
93+
check(
94+
"listen_LiveStatus unwraps the live_status document",
95+
received == [LIVE_STATUS_SNAPSHOT["live_status"], LIVE_STATUS_LIVE["live_status"]],
96+
f"got {received}",
97+
)
98+
)
99+
100+
# listen_SiteInfo receives the unwrapped site_info document.
101+
stream = make_stream()
102+
site = stream.get_energysite(SITE_A)
103+
received = []
104+
site.listen_SiteInfo(received.append)
105+
dispatch(stream, SITE_INFO_SNAPSHOT)
106+
results.append(
107+
check(
108+
"listen_SiteInfo unwraps the site_info document",
109+
received == [SITE_INFO_SNAPSHOT["site_info"]],
110+
f"got {received}",
111+
)
112+
)
113+
114+
# A live_status event for another site is not delivered.
115+
stream = make_stream()
116+
site = stream.get_energysite(SITE_A)
117+
received = []
118+
site.listen_LiveStatus(received.append)
119+
dispatch(stream, OTHER_SITE_LIVE_STATUS)
120+
results.append(
121+
check(
122+
"a different site's live_status is filtered out",
123+
received == [],
124+
f"got {received}",
125+
)
126+
)
127+
128+
# A site_info listener does not receive live_status events for the same site.
129+
stream = make_stream()
130+
site = stream.get_energysite(SITE_A)
131+
received = []
132+
site.listen_SiteInfo(received.append)
133+
dispatch(stream, LIVE_STATUS_SNAPSHOT)
134+
results.append(
135+
check(
136+
"listen_SiteInfo ignores live_status events",
137+
received == [],
138+
f"got {received}",
139+
)
140+
)
141+
142+
# Unrelated account-wide events (e.g. credits) are not delivered to energy listeners.
143+
stream = make_stream()
144+
site = stream.get_energysite(SITE_A)
145+
live_received: list[dict[str, Any]] = []
146+
info_received: list[dict[str, Any]] = []
147+
site.listen_LiveStatus(live_received.append)
148+
site.listen_SiteInfo(info_received.append)
149+
dispatch(stream, CREDITS_EVENT)
150+
results.append(
151+
check(
152+
"credits events are not delivered to energy site listeners",
153+
live_received == [] and info_received == [],
154+
f"live={live_received} info={info_received}",
155+
)
156+
)
157+
158+
# Removing a listener stops further delivery.
159+
stream = make_stream()
160+
site = stream.get_energysite(SITE_A)
161+
received = []
162+
remove = site.listen_LiveStatus(received.append)
163+
dispatch(stream, LIVE_STATUS_SNAPSHOT)
164+
remove()
165+
dispatch(stream, LIVE_STATUS_LIVE)
166+
results.append(
167+
check(
168+
"removing a listener stops further delivery",
169+
received == [LIVE_STATUS_SNAPSHOT["live_status"]],
170+
f"got {received}",
171+
)
172+
)
173+
174+
# get_energysite is idempotent per id, mirroring get_vehicle.
175+
stream = make_stream()
176+
results.append(
177+
check(
178+
"get_energysite returns the same instance for the same id",
179+
stream.get_energysite(SITE_A) is stream.get_energysite(SITE_A),
180+
)
181+
)
182+
results.append(
183+
check(
184+
"get_energysite normalizes int and str ids to the same instance",
185+
stream.get_energysite(12345) is stream.get_energysite("12345"),
186+
)
187+
)
188+
189+
print("-" * 72)
190+
print("ALL PASS" if all(results) else "FAILURES PRESENT")
191+
if not all(results):
192+
raise SystemExit(1)
193+
194+
195+
if __name__ == "__main__":
196+
main()

0 commit comments

Comments
 (0)