diff --git a/AGENTS.md b/AGENTS.md index df7e49c..cfc0f7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - 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. - `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. +- `energy_totals` (Teslemetry/api PR 316, trimmed by PR 321) is shaped differently again: the site id rides the `id` field, not `site_id` - filter on `id` and `totals`, 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. As of PR 321 the wire payload is trimmed to `id`/`createdAt`/`totals` plus `isCache` only when true (`product_type`/`topic`/`url` were dropped as redundant with the event's own topic name and site id); `Key.PRODUCT_TYPE`/`Key.TOPIC`/`Key.URL` in `const.py` remain defined for other event kinds but are no longer part of the energy_totals filter. - `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. diff --git a/teslemetry_stream/energysite.py b/teslemetry_stream/energysite.py index 49adf6d..d1099f5 100644 --- a/teslemetry_stream/energysite.py +++ b/teslemetry_stream/energysite.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Callable -from .const import EnergyHistoryTotals, Key, ProductType, RefreshTopic +from .const import EnergyHistoryTotals, Key if TYPE_CHECKING: from .stream import TeslemetryStream @@ -80,15 +80,13 @@ def listen_EnergyTotals( Unlike live_status/site_info, this event carries no full document - just cumulative totals and the event fires only when the server's 5-minute poll actually detects a change. Silence means no change, - never staleness; there is no snapshot-on-connect delivery. A - consumer wanting the full time series must GET the event's `url` - via their own REST client - this listener only exposes the totals. + never staleness; there is no snapshot-on-connect delivery. This + listener only exposes the totals. """ return self.stream.async_add_listener( lambda x: callback(EnergyHistoryTotals.from_dict(x[Key.TOTALS])), { Key.ID: self.site_id, - Key.PRODUCT_TYPE: ProductType.ENERGY_SITE, - Key.TOPIC: RefreshTopic.ENERGY_TOTALS, + Key.TOTALS: None, }, ) diff --git a/tests/test_energysite_events.py b/tests/test_energysite_events.py index a8b84aa..9c8749e 100644 --- a/tests/test_energysite_events.py +++ b/tests/test_energysite_events.py @@ -3,11 +3,9 @@ Fixtures mirror the `liveStatusSchema`/`siteInfoSchema` from Teslemetry/api PR 310: a flat envelope of `createdAt`, `site_id`, optional `isCache`, and the full document under `live_status`/`site_info` (opaque, not a delta). -`energy_totals` fixtures mirror PR 316's notification schema (renamed -from `calendar_history_refreshed` to `energy_totals` post-merge): the -uniform notification shape -(`id`/`product_type`/`topic`/`url`/`createdAt`/`isCache`) plus a compact -`totals` object - no `site_id` key, the site id rides `id` instead. +`energy_totals` fixtures mirror PR 321's trimmed notification schema: +`id`/`createdAt`/`totals`, with `isCache` present only when true - no +`site_id`, `product_type`, `topic`, or `url` keys. """ from __future__ import annotations @@ -87,11 +85,7 @@ ENERGY_TOTALS_EVENT: dict[str, Any] = { "id": SITE_A, - "product_type": "energy_site", - "topic": "energy_totals", - "url": f"/api/1/energy_sites/{SITE_A}/calendar_history?kind=energy&period=day", "createdAt": "2026-07-29T10:16:00.000Z", - "isCache": False, "totals": ENERGY_TOTALS_FIXTURE, }