diff --git a/AGENTS.md b/AGENTS.md index 943230f..fdd3d2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,31 +1,65 @@ # Project agent memory -This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code. - -- Add durable project-specific notes here as they are discovered through real work. -- CI runs on push/PR: `.github/workflows/ci.yml`. Uses `uv` (see `uv.lock`). Three jobs: `lint` (ruff + mypy), `test` (matrix), `build` (`uv build` + `twine check` + artifact upload). Ruff and twine, like mypy, aren't declared dev dependencies - CI installs them ephemerally via `uv run --with ...`, matching the existing mypy pattern. -- `[tool.ruff]` in `pyproject.toml` selects `E, F, W, I, B, UP, ASYNC, SIM, RUF` and ignores `RUF006` - the stream's background `listen`/refresh tasks (`stream.py`, `vehicle.py`) are intentionally untracked fire-and-forget `asyncio.create_task` calls, not a lint oversight. -- The `test` job's step fails outright (non-zero exit) if `tests/test_*.py` matches nothing, rather than skipping - do not reintroduce a silent-skip fallback there. -- `tests/` files are plain scripts (`if __name__ == "__main__"`), not pytest-based - pytest would collect zero tests here. Run each directly, e.g. `uv run python tests/test_config_events.py`. -- `pyproject.toml` has a `[tool.mypy]` config but no dev-dependency group declares mypy, so `uv sync` alone won't install it. CI installs it ephemerally via `uv run --with mypy mypy teslemetry_stream`. -- `Signal` in `const.py` tracks ; 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. -- The `TeslemetryEnum` value tables in `const.py` (`ShiftState`, `BMSState`, `DetailedChargeState`, etc.) are hand-maintained against the `tesla-protocol` PyPI package's proto enum descriptors (`tesla_protocol.telemetry.vehicle_data_pb2`), not derived from it at runtime: nearly every table matches its proto enum byte-for-byte under simple prefix-stripping, but `tesla-protocol` requires `protobuf` + `googleapis-common-protos` as runtime dependencies, which is disproportionate for sourcing ~40 static string lists in a library whose only current dependency is `aiohttp` and whose consumers (Home Assistant integrations) are sensitive to protobuf version pinning. `ChargeState` is the one table that does not match the proto at all (already commented in `const.py` - deprecated field). `tests/test_enum_tables.py` pins the tables most likely to drift against the proto names actually observed in `tesla-protocol` 1.4.0, as a manual re-check aid, not a live comparison. -- Adding a new streamable field (e.g. from an upstream `teslamotors/fleet-telemetry` `Field` enum addition): give it a `Signal` entry in `const.py` in alphabetical order by the Python constant name, but append its `listen_` method to the *end* of `TeslemetryStreamVehicle` in `vehicle.py`, not alphabetically re-inserted - the method order there is chronological-by-addition (see the tail of the class), not sorted. Pick `make_int`/`make_float`/`make_bool`/`make_dict` by matching the closest existing field of the same shape (unit suffix, boolean vs measurement, etc.); there is no per-field firmware-version metadata tracked anywhere in the library, so omit it. -- 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. `tests/test_energysite_events.py` fixtures mirror the server schemas. -- `energy_totals` 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. The server sends a connect-time snapshot (`isCache: true`), then fires again only when the periodic `calendar_history` poll detects a change (silence between events is not staleness). The wire payload is `id`/`createdAt`/`totals` plus `isCache` only when true; `Key.PRODUCT_TYPE`/`Key.TOPIC`/`Key.URL` in `const.py` remain defined for other event kinds but are not part of the energy_totals filter. -- `site_info` events do not carry `tariff_content`/`tariff_content_v2`; 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); a consumer wanting both tariffs together should use the REST site_info endpoint. -- Releases (tag `v*.*.*`) go through `.github/workflows/release.yml` directly - it's the sole top-level workflow, triggered on the tag push: `lint` + the full `test` python-version matrix (mirrors `ci.yml`) must pass on the exact release SHA before `build` (single Python, build+twine) runs, and only then do the `pypi` environment's protection rules (and its trusted-publishing OIDC) allow `publish-to-pypi`. It must stay a top-level workflow, not a `workflow_call` reusable one - PyPI's trusted publisher is configured for the `release.yml` + `pypi` environment identity, and a reusable-workflow caller signs PEP 740 attestations under the caller's identity instead, which that publisher check rejects. The `pypi` GitHub environment itself carries no protection rules (no required reviewers, no deployment branch policy) - once CI passes on the release SHA, publishing proceeds automatically with no manual approval step. Merging the release PR is the effective publish approval. `jobs..environment.name`/`.url` cannot reference the `env` context (only `github`, `inputs`, `vars`, `needs`, `secrets`, `strategy`, `matrix` resolve there) - referencing `env.*` there is a workflow-file parse error that fails the whole file at startup, before trigger filtering, so it fails every push (not just tags) with zero jobs and no logs. Use literal values or `vars.*` instead. `release.yml` also carries `workflow_dispatch` so a tag whose run failed before publish can be re-run manually without tag surgery. -- `TeslemetryStream(topics=...)` 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, 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. 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. -- `TeslemetryStreamVehicle` keeps `fields` fresh against server-side changes (another client, the console, a Teslemetry migration), not just this client's own history. `__init__` registers an internal listener (`_on_config_event`, filtered on `{Key.VIN, Key.CONFIG: None}`) on the `config` SSE topic, shaped `{vin, config: {fields}}` like the REST `get_config` body, unconditionally at construction - not lazily - so no connection can ever predate it and miss an event. A well-typed `fields` piece replaces the record (every nested entry must itself be a dict - one bad entry, e.g. a null, rejects the whole piece rather than leaving something `add_field` would later crash on); a missing piece is left untouched; a malformed piece is logged and skipped without touching the pending `_config`. The stored `fields` dict (and each nested per-field dict) is copied, never the same object handed to public listeners for that same event - a consumer mutating its event in place must not corrupt the record. `preferTyped`/`prefer_typed()` (the per-vehicle opt-in that used to control this) no longer exists in this library - prefer_typed is enabled by default server-side now, so there is nothing left to toggle. It is still only a default, not a guarantee: some vehicles haven't picked it up and still stream string-encoded numeric/boolean values, so `make_int`/`make_float`/`make_bool` (`vehicle.py`) keep coercing a `str` payload to native `int`/`float`/`bool` rather than assuming every vehicle is typed. `tests/test_field_type_coercion.py` covers this coercion against real observed telemetry. -- `TeslemetryStream.async_add_listener(..., internal=True)` marks a listener as bookkeeping-only (the vehicle's config-sync listener is the only current user). Its `schedule_refresh` gate on the `asyncio.create_task()` call is unconditionally false for `internal=True`, regardless of loop state - this is what makes eager, construction-time registration of the config listener safe outside a running event loop, not deferred timing. Both the "first listener starts the task" and "last listener removed auto-closes" checks also count only non-internal (public) listeners for the same underlying reason: an internal listener alone must not itself start the task, and one surviving an auto-close must not block a later public listener's own zero-to-one transition from restarting it. Any stream stand-in passed to `TeslemetryStreamVehicle` (test doubles included) must implement `async_add_listener(callback, filters, internal=False)` and `async_add_connection_listener(callback)`. -- `add_field` gates its no-op skip on `TeslemetryStreamVehicle._populated`, not on connection/topic state: an unpopulated vehicle awaits `_ensure_populated()` (a single-flight `get_config()` REST fetch - concurrent callers, e.g. a batch of `listen_*` calls at HA integration setup, join one GET instead of each starting their own) before deciding; a populated one trusts `fields` outright with no network call. `_populated` is set by a successful `get_config()` (200 or 404 - both are an authoritative answer; 404 also clears `fields`, since no config existing is itself the authoritative state, not a fetch to ignore) and by every `_on_config_event` push, and cleared by an `_on_connection_event` disconnect notification (registered via `async_add_connection_listener` at construction, alongside the config-sync listener) - a disconnect leaves the record possibly stale until the next connection's config snapshot arrives, so a field-config call landing in that reconnect window re-fetches instead of trusting pre-disconnect data. A failed populating fetch (`aiohttp.ClientError`/timeout, or a non-200/404 status via `raise_for_status()`) is not authoritative like a 404: `_ensure_populated()` catches it, logs, and leaves the vehicle unpopulated rather than letting it propagate - every `listen_*` method reaches `add_field()` through `_enable_field()`'s fire-and-forget `asyncio.create_task()`, where an uncaught exception would just silently abandon the field request instead of sending the PATCH. -- `tests/test_config_events.py` covers the record merge and nested-entry validation; `tests/test_config_listener_lifecycle.py` covers construction-time registration (including outside a running loop), the auto-close exclusion, and the populated/unpopulated no-op-skip gating; `tests/test_stream_lifecycle.py` covers the internal-listener-survives-close restart case and a vehicle discovered mid-dispatch of its own config event (misses that event, stays unpopulated, and self-corrects via the lazy fetch on its first field-config call); `tests/test_reconnect_config_window.py` covers the reconnect-window race (a field-config call between `connect()` and that connection's config snapshot re-fetches rather than trusting the stale pre-disconnect record). -- `TeslemetryStream` has no `__aenter__`/`__aexit__` - do not reintroduce `async with TeslemetryStream(...)` in the README or examples. Connection lifecycle is entirely listener-driven: `async_add_listener` connects on the first (public) listener and disconnects on the last one removed; `connect()`/`close()`/`listen()` exist for callers who want to manage the connection themselves instead. -- `__anext__` treats a `aiohttp.ClientResponseError` with `status` 401 or 403 as terminal, not transient: it sets `active = False` and raises `TeslemetryStreamAuthenticationError` (chaining the original error) rather than retrying, since a rejected token can never succeed on retry. Every other `aiohttp.ClientError` (including other response statuses) keeps the pre-existing backoff-and-reconnect behavior. `tests/test_auth_failure.py` covers both the 401/403 surfacing and that a genuine transient `ClientError` still retries and reconnects. -- `TeslemetryStream` owns exactly one `_listen_task`: `async_add_listener`'s zero-to-one transition only creates it when absent/done, and `listen()` itself checks `asyncio.current_task()` against `self._listen_task` - a second concurrent `listen()` call joins the owner via `await existing_task` instead of racing it for the connection. `connect()` serializes the actual GET behind `self._connect_lock` and re-checks `self.active` after acquiring both the lock and the response, discarding a response that arrived after a stop/supersede rather than publishing it. Internal reconnect paths (EOF, `ClientError`, unexpected exceptions in `__anext__`) call `_close_response()`, which only clears the response and notifies connection listeners - they must not call `close()`, which additionally flips `active=False` and cancels the owned task, i.e. a real stop. `listen()`'s `finally` calls `_close_response()` unconditionally, so task cancellation (however triggered) still releases the connection. `listen()` dispatches through `_dispatch()` (shared with `ingest()`), over a *sorted* snapshot of `_listeners.values()` (never the live dict) with internal listeners ordered first, regardless of registration order: a callback that adds a listener mid-dispatch (e.g. `get_vehicle()` for an uncached VIN) must not raise `RuntimeError: dictionary changed size during iteration` and kill the loop, and a public callback must not get a chance to mutate the event in place before an internal (bookkeeping) listener has cached from it. `_update_connection_listeners()` has the same hazard for `_connection_listeners` (a connection listener calling `get_vehicle()` registers that vehicle's own connection listener mid-dispatch) and is fixed the same way, over a plain `list(...)` snapshot - no ordering requirement there, since connection listeners don't share a mutable event object. `tests/test_stream_lifecycle.py` covers the add/remove/re-add race, duplicate `listen()` calls, cancellation while blocked reading content, close-during-connect, close-during-backoff, a listener mutating `_listeners` mid-dispatch, internal-before-public dispatch order, and a connection listener mutating `_connection_listeners` mid-dispatch. -- `TeslemetryStream.ingest()` (and `TeslemetryStreamVehicle.ingest()`, the same call with the VIN filled in) is the ingestion point for an observation the library did not read off its own SSE connection - a Bluetooth broadcast, today. It builds the native wire event (`vin`/`data`/`createdAt`) plus an open-ended `metadata` dict (`Metadata.SOURCE`/`Metadata.RAW` in `const.py`) and hands it to `_dispatch`, the single fan-out `listen()` also uses - so native events pass through untouched and a consumer's existing `listen_*` callbacks receive both sources with no translation and no second subscription. Dispatch is arrival-ordered and the stream holds no per-field value: nothing is deduplicated, reordered, or dropped, and there is deliberately no source ranking, precedence, or preferred-source field - which report to believe is the consumer's decision, made on `metadata`. Ingesting neither requires nor opens a connection. Neither library depends on the other: the BLE-side shim that shapes a broadcast into this format lives in `tesla-fleet-api` and the consumer wires the two, following the `aiopowerwall`/`EnergySiteRouter` duck-typing precedent. `tests/test_external_ingest.py` covers the native-event regression, the two sources being indistinguishable apart from metadata, and the no-dedup/no-ranking contract. +Project-intrinsic knowledge that should travel with the code: build, test, release, architecture, and sharp edges. Add durable notes here as real work uncovers them. + +## Build, test, release + +- CI (`.github/workflows/ci.yml`) runs `lint` (ruff + mypy), `test` (Python 3.9-3.13 matrix), `build` (`uv build` + `twine check`). Ruff, mypy and twine are deliberately not dev dependencies - `uv sync` won't install them; CI runs each ephemerally via `uv run --with ...`. Do the same locally. +- `tests/` files are plain scripts (`if __name__ == "__main__"`), not pytest-based - pytest would collect zero tests. Run each directly: `uv run python tests/test_config_events.py`. +- The `test` job fails outright when `tests/test_*.py` matches nothing. Do not reintroduce a silent-skip fallback. +- Releases go through `.github/workflows/release.yml` on a `v*.*.*` tag: lint + the full test matrix must pass on the release SHA before build, then trusted-publishing OIDC publishes to PyPI and a GitHub release is cut. It carries `workflow_dispatch` so a run that failed before publish can be re-run without tag surgery. + - It **must stay a top-level workflow**, never a `workflow_call` reusable one: PyPI's trusted publisher is bound to the `release.yml` + `pypi` environment identity, and a reusable workflow signs PEP 740 attestations under the *caller's* identity, which that publisher rejects. + - `jobs..environment.name`/`.url` **cannot reference the `env` context** (only `github`, `inputs`, `vars`, `needs`, `secrets`, `strategy`, `matrix` resolve there). Doing so is a workflow-file parse error that fails the whole file at startup, before trigger filtering - every push, zero jobs, no logs. Use literals or `vars.*`. + +## Fields and enums (`const.py`) + +- `Signal` tracks . The config route rejects unknown names with `fst_err_validation`, but *retired* names are accepted and returned in a top-level `ignoredFields` list - so the library can lag the published list without breaking. +- The `TeslemetryEnum` value tables are hand-maintained against the `tesla-protocol` PyPI package's proto enum descriptors, not derived at runtime: that package pulls in `protobuf` + `googleapis-common-protos`, disproportionate for ~40 static string lists in a library whose only dependency is `aiohttp` and whose consumers (Home Assistant integrations) are sensitive to protobuf pinning. `ChargeState` is the one table that does not match the proto at all (deprecated field, commented in place). `tests/test_enum_tables.py` pins the drift-prone tables as a manual re-check aid, not a live comparison. +- **Adding a streamable field**: give it a `Signal` entry in `const.py` in alphabetical order by constant name, but *append* its `listen_` method to the **end** of `TeslemetryStreamVehicle` in `vehicle.py` - that method order is chronological-by-addition, not sorted. Pick `make_int`/`make_float`/`make_bool`/`make_dict` by matching the closest existing field of the same shape; there is no per-field firmware-version metadata anywhere in the library, so omit it. + +## Stream lifecycle (`stream.py`) + +- `TeslemetryStream` has no `__aenter__`/`__aexit__` - do not reintroduce `async with TeslemetryStream(...)` in the README or examples. Lifecycle is listener-driven: `async_add_listener` connects on the first public listener, disconnects on the last one removed; `connect()`/`close()`/`listen()` are for callers managing it themselves. +- Exactly one `_listen_task` is owned. A second concurrent `listen()` joins the owner via `await existing_task` rather than racing it; `connect()` serializes the GET behind `_connect_lock` and re-checks `active` after both the lock and the response, discarding a response that arrived after a stop. +- Internal reconnect paths (EOF, `ClientError`, unexpected exceptions) call `_close_response()`, **never** `close()` - `close()` additionally flips `active=False` and cancels the owned task, i.e. a real stop. `listen()`'s `finally` calls `_close_response()` unconditionally so cancellation still releases the connection. +- Dispatch iterates a **sorted snapshot** of `_listeners.values()` with internal listeners first, never the live dict: a callback adding a listener mid-dispatch (e.g. `get_vehicle()` for an uncached VIN) must not raise `RuntimeError: dictionary changed size during iteration`, and a public callback must not mutate the event before an internal bookkeeping listener has cached from it. `_update_connection_listeners()` has the same mutation hazard and iterates a plain `list(...)` snapshot (no ordering requirement - connection listeners share no mutable event). +- `__anext__` treats `ClientResponseError` with status 401/403 as terminal: sets `active = False` and raises `TeslemetryStreamAuthenticationError`. Every other `aiohttp.ClientError` keeps backoff-and-reconnect. +- `async_add_listener(..., internal=True)` marks a bookkeeping-only listener (the vehicle config-sync listener is the only user). Its `schedule_refresh` gate is unconditionally false, which is what makes construction-time registration safe outside a running event loop. Both the "first listener starts the task" and "last listener removed auto-closes" checks count only public listeners, so an internal listener can neither pin the connection open nor block a later public listener's zero-to-one restart. **Any stream stand-in (test doubles included) must implement `async_add_listener(callback, filters, internal=False)` and `async_add_connection_listener(callback)`.** +- `topics=` is an optional exact SSE wire-event allowlist sent as the connection's `topics` query param. `SseTopic` is the closed server-recognized set; `SSE_VEHICLE_TOPICS`/`SSE_ENERGY_TOPICS`/`SSE_ALL_TOPICS` are client-side presets, flat per-product-kind lists deliberately not split by whether a topic has a connect-time snapshot (server behavior, not something this library encodes). Omitting it (`None`) is legacy-all forever. An explicitly empty iterable raises `ValueError` at construction - "no topics" must not mean "all topics", mirroring the server's 400. A bare `str`/`SseTopic` is accepted as a single topic, not iterated character-by-character; `topics: str | Iterable[str] | None` exists precisely because a lone string also satisfies `Iterable[str]`. +- `ingest()` (and `TeslemetryStreamVehicle.ingest()`, same call with the VIN filled in) is the entry point for an observation not read off the SSE connection - a Bluetooth broadcast, today. It builds the native wire event (`vin`/`data`/`createdAt`) plus an open-ended `metadata` dict (`Metadata.SOURCE`/`Metadata.RAW`) and hands it to the same `_dispatch` `listen()` uses, so existing `listen_*` callbacks receive both sources with no translation and no second subscription. Dispatch is arrival-ordered and the stream holds no per-field value: **nothing is deduplicated, reordered, or dropped, and there is deliberately no source ranking or precedence** - which report to believe is the consumer's decision, made on `metadata`. Ingesting neither requires nor opens a connection. Neither library depends on the other: the BLE-side shim shaping a broadcast into this format lives in `tesla-fleet-api` and the consumer wires the two. + +## Vehicle config (`vehicle.py`) + +- Config responses are shaped inconsistently: success is flat (`{"updated_vehicles": n}`, plus `ignoredFields` when some were dropped), 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 (`_flush`): the first caller starts it, later callers merge into the same pending config and await it. This is why a batch of listeners scheduled at once (e.g. HA integration setup) produces one PATCH, not one per listener. A body-shaped error (`{"error": ...}`) is terminal for that batch - not replayed, but the pending config survives for the next explicit `update_config`. A transport-level failure (`ClientError`/timeout) gets one bounded retry inside the same flush. +- `fields` is kept fresh against *server-side* changes (another client, the console, a Teslemetry migration), not just this client's own history. `__init__` registers `_on_config_event` as an internal listener unconditionally at construction - not lazily - so no connection can predate it and miss an event. A well-typed `fields` piece replaces the record (every nested entry must itself be a dict; one bad entry rejects the whole piece rather than leaving something `add_field` would crash on); a missing piece is left untouched; a malformed piece is logged and skipped without touching pending `_config`. The stored dict and each nested per-field dict are **copied, never aliased** to the event handed to public listeners. +- prefer_typed is server-side default now (no per-vehicle toggle exists), but it is a default, not a guarantee: some vehicles still stream string-encoded numerics/booleans, so `make_int`/`make_float`/`make_bool` must keep coercing a `str` payload. +- `add_field` gates its no-op skip on `_populated`, not on connection/topic state. An unpopulated vehicle awaits `_ensure_populated()` - a single-flight `get_config()` GET that concurrent callers join - before deciding; a populated one trusts `fields` with no network call. `_populated` is set by a successful `get_config()` (200 **or** 404 - both authoritative; 404 also clears `fields`, since "no config exists" is itself the answer) and by every `_on_config_event`, and cleared by `_on_connection_event` on disconnect, so a field-config call landing in a reconnect window re-fetches instead of trusting pre-disconnect data. A *failed* fetch is not authoritative: `_ensure_populated()` catches, logs, and leaves the vehicle unpopulated rather than propagating - every `listen_*` reaches `add_field()` through `_enable_field()`'s fire-and-forget `create_task()`, where an uncaught exception would silently abandon the field request. + +## Energy sites (`energysite.py`) + +- Energy events are shaped unlike vehicle signals: `live_status`/`site_info` are flat top-level envelopes (`{createdAt, site_id, isCache?, live_status|site_info}`), not nested under `data`, and carry a full opaque document rather than a field delta. There is no per-field config to enable - the server auto-polls subscribed sites. +- `energy_totals` differs again: the site id rides `id`, **not** `site_id` - filter on `id` and `totals`. It carries a compact cumulative `totals` object (`EnergyHistoryTotals`), not a document. Wire payload is `id`/`createdAt`/`totals`, plus `isCache` only when true. +- `site_info` does **not** carry `tariff_content`/`tariff_content_v2`. The V2 tariff is its own event/listener (`listen_TariffContentV2`), same envelope shape, with a `None` body meaning explicit server-side removal rather than "not received yet". +- All of these share a silence-means-no-change contract: the server sends a connect-time snapshot and then fires only on change. Freshness lives in REST, never in event cadence. +- There is deliberately no helper recombining `site_info` and `tariff_content_v2` - it would only ever cover V2 (legacy V1 `tariff_content` has no SSE topic and stays REST-only). A consumer wanting both tariffs uses the REST site_info endpoint. + +## Test map + +| Area | Test | +| --- | --- | +| Config record merge, nested-entry validation | `test_config_events.py` | +| Config listener registration, auto-close exclusion, populated gating | `test_config_listener_lifecycle.py` | +| Config response-shape handling | `test_config_update.py` | +| One-PATCH-per-batch coalescing | `test_batch_retry_storm.py` | +| Reconnect-window re-fetch race | `test_reconnect_config_window.py` | +| Listen task ownership, dispatch snapshots/order, close races | `test_stream_lifecycle.py` | +| 401/403 terminal vs transient retry | `test_auth_failure.py` | +| `str` payload coercion against real telemetry | `test_field_type_coercion.py` | +| `topics` URL construction, empty/bare-string handling, tariff listener | `test_sse_topics.py` | +| Native-event regression, source indistinguishability, no-dedup contract | `test_external_ingest.py` | +| Energy event fixtures | `test_energysite_events.py` | +| Enum tables vs proto names | `test_enum_tables.py` | ## Maintaining this file