Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ This file is the project's committed home for project-intrinsic agent knowledge:
- `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.
- 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.
- `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.
- `site_info` events no longer carry `tariff_content`/`tariff_content_v2` (Teslemetry/api PR 318); the V2 tariff is its own `tariff_content_v2` event/listener (`listen_TariffContentV2`), same envelope shape as `site_info`, with a `None` body meaning an explicit server-side removal rather than "not received yet". Both share the same silence-means-no-change contract - freshness lives in REST, never in event cadence. There is deliberately no library helper recombining `site_info` and `tariff_content_v2` into one document - that would only ever cover the V2 tariff (legacy V1 `tariff_content` has no SSE topic and stays REST-only by design), so it can't actually promise the whole REST-shaped document; a consumer wanting both tariffs together should use the REST site_info endpoint.
- `TeslemetryStream(topics=...)` (Teslemetry/api PR 319) is an optional exact SSE wire-event allowlist sent as the connection's `topics` query param; `SseTopic` in `const.py` is the closed set the server recognizes (must stay in sync with the api's `SSE_TOPICS`), and `SSE_VEHICLE_TOPICS`/`SSE_ENERGY_TOPICS`/`SSE_ALL_TOPICS` are client-side presets - flat per-product-kind lists of exact wire names, deliberately not further split by whether a topic happens to have a connect-time snapshot server-side; that's upstream server behavior, not something this library encodes. Omitting `topics` (`None`) is legacy-all forever - every applicable event delivered unfiltered - and existing callers that never pass it are unaffected. An explicitly empty iterable is rejected with `ValueError` at construction time rather than silently falling back to legacy-all - "no topics" must not mean "all topics", mirroring the server's own 400 on an empty `topics` value. A bare `str`/`SseTopic` is accepted as a single topic rather than iterated character-by-character - `topics` type-checks `str | Iterable[str] | None` precisely because a lone string also satisfies `Iterable[str]`, the classic footgun. `tests/test_sse_topics.py` covers the tariff listener, its null-removal signal, the `topics` param's URL construction, the empty-iterable rejection, and the bare-string/bare-`SseTopic` case.

## Maintaining this file

Expand Down
53 changes: 50 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,22 @@ async def main():
> **Note:** Energy site streaming ships flag-gated behind [Teslemetry/api#310](https://github.com/Teslemetry/api/pull/310).
> Until that server feature is enabled, `listen_LiveStatus`/`listen_SiteInfo` will simply never fire.

`site_info` never carries `tariff_content`/`tariff_content_v2` - the site's V2
tariff is its own `tariff_content_v2` event, published only when it changes.
Like `site_info`, event silence means no change, never staleness; freshness
always lives in a REST call, never in event cadence. A `None` body is an
explicit removal signal (the site's V2 tariff was cleared), not "no data yet".

```python
def tariff_callback(tariff_content_v2):
if tariff_content_v2 is None:
print("V2 tariff removed")
else:
print(f"Tariff code: {tariff_content_v2.get('code')}")

remove_tariff_listener = site.listen_TariffContentV2(tariff_callback)
```

A third event, `energy_totals`, fires when the server's periodic
`calendar_history` poll detects the day's history actually changed. It never
carries the full time series - just cumulative per-type totals and a `url`
Expand All @@ -164,10 +180,38 @@ change, never a stale value.
remove_energy_totals_listener = site.listen_EnergyTotals(energy_totals_callback)
```

## SSE Topic Selection

By default a connection receives every applicable event (legacy-all
behavior, unchanged forever). Pass `topics` to `TeslemetryStream` to
subscribe to only the SSE wire events you need - an exact allowlist,
comma-joined onto the connection's `topics` query parameter:

```python
from teslemetry_stream import TeslemetryStream, SseTopic, SSE_ENERGY_TOPICS

stream = TeslemetryStream(
access_token="<token>",
session=session,
topics=[SseTopic.LIVE_STATUS, SseTopic.SITE_INFO],
)

# Or use a preset that expands client-side to every topic in a group:
stream = TeslemetryStream(
access_token="<token>",
session=session,
topics=SSE_ENERGY_TOPICS,
)
```

`SseTopic` is the closed set of exact wire names the server recognizes;
`SSE_VEHICLE_TOPICS`, `SSE_ENERGY_TOPICS`, and `SSE_ALL_TOPICS` are
convenience presets that expand to those exact names client-side.

## Public Methods in TeslemetryStream Class

### `__init__(session: aiohttp.ClientSession, access_token: str, server: str | None = None, vin: str | None = None, parse_timestamp: bool = False)`
Initialize the TeslemetryStream client.
### `__init__(session: aiohttp.ClientSession, access_token: str, server: str | None = None, vin: str | None = None, parse_timestamp: bool = False, manual: bool = False, topics: str | Iterable[str] | None = None)`
Initialize the TeslemetryStream client. `topics` is an optional exact SSE wire event allowlist (see `SseTopic`) - a single topic or an iterable of them; omitting it preserves legacy-all behavior.

### `get_vehicle(vin: str) -> TeslemetryStreamVehicle`
Create a vehicle object to manage config and create listeners.
Expand Down Expand Up @@ -260,7 +304,10 @@ Initialize the TeslemetryStreamEnergySite instance.
Listen for energy site live status events. The callback receives the full `live_status` document.

### `listen_SiteInfo(callback: Callable[[dict], None]) -> Callable[[],None]`
Listen for energy site info events. The callback receives the full `site_info` document.
Listen for energy site info events. The callback receives the `site_info` document. This document never carries `tariff_content`/`tariff_content_v2` - use `listen_TariffContentV2` for the V2 tariff.

### `listen_TariffContentV2(callback: Callable[[dict | None], None]) -> Callable[[],None]`
Listen for the site's V2 tariff document. The callback receives the `tariff_content_v2` document verbatim, or `None` when the server sends an explicit removal signal. Published only when it changes.

### `listen_EnergyTotals(callback: Callable[[EnergyHistoryTotals], None]) -> Callable[[],None]`
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.
15 changes: 13 additions & 2 deletions teslemetry_stream/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
TeslemetryStreamVehicleNotConfigured,
TeslemetryStreamEnded
)
from .const import Signal, Alert
from .const import (
Signal,
Alert,
SseTopic,
SSE_VEHICLE_TOPICS,
SSE_ENERGY_TOPICS,
SSE_ALL_TOPICS,
)

__all__ = [
"TeslemetryStream",
Expand All @@ -18,5 +25,9 @@
"TeslemetryStreamVehicleNotConfigured",
"TeslemetryStreamEnded",
"Signal",
"Alert"
"Alert",
"SseTopic",
"SSE_VEHICLE_TOPICS",
"SSE_ENERGY_TOPICS",
"SSE_ALL_TOPICS",
]
55 changes: 55 additions & 0 deletions teslemetry_stream/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class Key(StrEnum):
TOPIC = "topic"
URL = "url"
TOTALS = "totals"
TARIFF_CONTENT_V2 = "tariff_content_v2"


class Signal(StrEnum):
Expand Down Expand Up @@ -328,6 +329,60 @@ class RefreshTopic(StrEnum):
ENERGY_TOTALS = "energy_totals"


class SseTopic(StrEnum):
"""Exact SSE wire event names selectable via `TeslemetryStream(topics=...)`.

Mirrors the api's closed `SSE_TOPICS` set (`src/lib/sseTopics.ts`) - a
name here must match the server's allowlist exactly, since the server
validates `topics` and 400s on anything it does not recognize.
"""

STATE = "state"
DATA = "data"
ALERTS = "alerts"
ERRORS = "errors"
CONNECTIVITY = "connectivity"
VEHICLE_DATA = "vehicle_data"
CONFIG = "config"
LIVE_STATUS = "live_status"
SITE_INFO = "site_info"
TARIFF_CONTENT_V2 = "tariff_content_v2"
ENERGY_TOTALS = "energy_totals"
CREDITS = "credits"


#: Convenience preset - every vehicle topic. Expands client-side to exact
#: wire names; passing this to `TeslemetryStream(topics=...)` is equivalent
#: to legacy-all for a vehicle connection, minus energy/account topics.
SSE_VEHICLE_TOPICS: tuple[SseTopic, ...] = (
SseTopic.STATE,
SseTopic.DATA,
SseTopic.ALERTS,
SseTopic.ERRORS,
SseTopic.CONNECTIVITY,
SseTopic.VEHICLE_DATA,
SseTopic.CONFIG,
)

#: Convenience preset - every energy site topic.
SSE_ENERGY_TOPICS: tuple[SseTopic, ...] = (
SseTopic.LIVE_STATUS,
SseTopic.SITE_INFO,
SseTopic.TARIFF_CONTENT_V2,
SseTopic.ENERGY_TOTALS,
)

#: Convenience preset - every account-wide topic.
SSE_ACCOUNT_TOPICS: tuple[SseTopic, ...] = (SseTopic.CREDITS,)

#: Convenience preset - every known topic, equivalent to omitting `topics`.
SSE_ALL_TOPICS: tuple[SseTopic, ...] = (
*SSE_VEHICLE_TOPICS,
*SSE_ENERGY_TOPICS,
*SSE_ACCOUNT_TOPICS,
)


@dataclass
class EnergyHistoryTotals:
"""Cumulative per-type totals from a refreshed energy_totals document.
Expand Down
26 changes: 22 additions & 4 deletions teslemetry_stream/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,34 @@ def listen_SiteInfo(
) -> Callable[[], None]:
"""Listen for energy site info.

The callback receives the full site_info document. On connect (and
whenever a snapshot exists), an initial event is delivered with
`isCache` set, matching the same snapshot-then-live semantics as
vehicle state.
The callback receives the site_info document. This document no
longer carries `tariff_content`/`tariff_content_v2` - subscribe to
`listen_TariffContentV2` for the V2 tariff, or use the REST
site_info endpoint for the full Tesla-shaped document including
both tariffs. On connect (and whenever a snapshot exists), an
initial event is delivered with `isCache` set, matching the same
snapshot-then-live semantics as vehicle state.
"""
return self.stream.async_add_listener(
lambda x: callback(x[Key.SITE_INFO]),
{Key.SITE_ID: self.site_id, Key.SITE_INFO: None},
)

def listen_TariffContentV2(
self, callback: Callable[[dict[str, Any] | None], None]
) -> Callable[[], None]:
"""Listen for the site's V2 tariff document.

The callback receives the `tariff_content_v2` document verbatim, or
`None` when the server sends an explicit removal signal (the
site's V2 tariff was cleared). Published only when it changes -
silence means no change, never staleness, matching `listen_SiteInfo`.
"""
return self.stream.async_add_listener(
lambda x: callback(x[Key.TARIFF_CONTENT_V2]),
{Key.SITE_ID: self.site_id, Key.TARIFF_CONTENT_V2: None},
)

def listen_EnergyTotals(
self, callback: Callable[[EnergyHistoryTotals], None]
) -> Callable[[], None]:
Expand Down
23 changes: 22 additions & 1 deletion teslemetry_stream/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json
import logging
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, cast
from typing import Any, Awaitable, Callable, Iterable, cast

import aiohttp

Expand All @@ -27,6 +27,7 @@ def __init__(
vin: str | None = None,
parse_timestamp: bool = False,
manual: bool = False,
topics: str | Iterable[str] | None = None,
):
"""
Initialize the TeslemetryStream client.
Expand All @@ -37,13 +38,31 @@ def __init__(
:param vin: Vehicle Identification Number.
:param parse_timestamp: Whether to parse timestamps.
:param manual: Whether to start listening manually.
:param topics: Exact SSE wire event names (see `SseTopic` and its
presets in `const.py`) to subscribe to - a single topic or an
iterable of them. Omitting this (`None`) preserves legacy-all
behavior: every applicable event is delivered unfiltered,
forever. An explicitly empty iterable is rejected - it means
"no topics", not "all topics", mirroring the server's own 400
on an empty `topics` value.
"""
if server and not server.endswith(".teslemetry.com"):
raise ValueError("Server must be on the teslemetry.com domain")

self.active: bool = False
self.server = server
self.vin = vin
self.topics: list[str] | None
if topics is not None:
# A bare str (or SseTopic, itself a str) is iterable character-by-character -
# wrap it as a single topic rather than silently splitting it into letters.
self.topics = [topics] if isinstance(topics, str) else list(topics)
if not self.topics:
raise ValueError(
"topics must not be empty - omit it (None) for legacy-all behavior"
)
else:
self.topics = None
self._listeners: dict[
Callable[..., Any], tuple[Callable[[dict[str, Any]], None], dict[str, Any] | None]
] = {}
Expand Down Expand Up @@ -214,9 +233,11 @@ async def connect(self) -> None:
if self.vin:
url += f"/{self.vin}"
headers = await self.headers()
params = {"topics": ",".join(self.topics)} if self.topics else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty topic lists instead of enabling all topics

When a caller dynamically constructs topics and the iterable is empty, this truthiness check treats it the same as None and omits the query parameter. Because omission explicitly enables legacy-all behavior, an exact empty allowlist silently receives every applicable event instead of none, potentially increasing traffic and exposing data the caller intended to filter. Distinguish None from an empty list and either send the server-supported empty representation or reject the empty list explicitly.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None would have no value, no topics is no data?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - topics=[] now raises ValueError at construction time instead of falling through to legacy-all. None still means omit the param (legacy-all); an explicit empty iterable is now rejected client-side, mirroring the server's 400 on an empty topics value. Covered in tests/test_sse_topics.py (f698194).

self._response = await self._session.get(
url,
headers=headers,
params=params,
raise_for_status=True,
timeout=aiohttp.ClientTimeout(
connect=5, sock_connect=5, sock_read=30, total=None
Expand Down
Loading
Loading