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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te
- **Cross-transport parity (cloud REST `VehicleFleet` vs BLE `Commands`)**: the same-named command on both paths should build a semantically equivalent instruction from identical args - a divergence there is a bug, but response *bodies* legitimately differ (REST JSON dict vs decoded protobuf) and are not. `tests/test_cross_transport_parity.py` locks the equivalence in with mocked-both-transports tests. Known **non-bug FORM differences** (do not "fix"): `set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` (no proto fields), `window_control` lat/lon and `navigation_sc_request` `id` (no proto fields), `navigation_request`'s `type`/`locale`/`timestamp_ms` (REST share-intent framing), and `media_volume_up` (no Tesla REST endpoint - BLE-only; cloud raises volume via `adjust_volume`). Two **open divergences left unfixed** pending live verification: `clear_pin_to_drive_admin` builds `DrivingClearSpeedLimitPinAction` (speed-limit PIN, not PIN-to-Drive - suspected mismapping, security-sensitive), and `navigation_gps_request`'s `order` is required on BLE but optional on cloud (signature mismatch; null-order wire semantics undecided).
- **Per-command debug logging chokepoints and the `command=` name it derives**: `LOGGER.debug` lines of the form `command=<name> transport=<t> result=...` are emitted from exactly four places, not per-method - `Commands._sendVehicleSecurity`/`_getVehicleSecurity`/`_sendInfotainment`/`_getInfotainment` (`commands.py`, covers both BLE and Fleet-signed) and `TeslaFleetApi._request` (`fleet.py`, covers Fleet/Teslemetry/Tessie REST). `transport` comes from a `_transport_name` `ClassVar` set per concrete class (`"bluetooth"`/`"fleet"`/`"teslemetry"`/`"tessie"`), mirroring the existing `_auth_method` pattern - add that ClassVar to any new `Commands`/`TeslaFleetApi` subclass. For BLE/Fleet-signed, `command` is **not** the Python method name; it's derived from the populated protobuf oneof field (`vcsec_command_name`/`infotainment_command_name` in `commands.py`), e.g. `door_lock()` logs as `RKE_ACTION_LOCK` and `set_charge_limit()` as `chargingSetLimitAction` - deliberately robust to call-site changes since it reads the message being sent, not the call stack. `VehicleBluetooth`'s `verify_commands` resolution logs a second, separate line (`verify_commands=resolved`/`unresolved`) rather than duplicating the base class's raw-attempt line. `Router._dispatch` (`router/base.py`) logs `command=... backend=<ClassName> result=...` per backend tried, independent of the above. See `docs/bluetooth_vehicles.md`'s "Troubleshooting: Enable Debug Logging" section for the user-facing format; `tests/test_command_logging.py` locks in the exact line shapes.
- **`_log_request_result` (`fleet.py`) must tolerate any JSON-legal REST body, not just dicts**: it runs after the HTTP request already succeeded, so it's a logging convenience only - a non-dict body (`null`, a list, a bare scalar; live case: Teslemetry's `list_authorized_clients` returning `null`) must never raise there. It guards with `isinstance(data, dict)` before calling `.get()`, logging `result=success` and returning for anything else. Regression tests in `tests/test_command_logging.py` (`test_null_json_body_returns_none_without_raising` etc.) cover null/list/scalar bodies.
- **Typed accessor pattern for undocumented raw-dict responses**: `TeslemetryEnergySite.find_authorized_clients()` (`teslemetry/energysite.py`) is the library's first frozen-dataclass typed wrapper over a raw `dict[str, Any]`/`None`/`list` REST response, added so API-parsing logic (envelope unwrap, field lookup, null-body handling, enum typing) lives in the library instead of each consumer (e.g. Home Assistant's config flow) reimplementing it. Deliberately scoped tight rather than defensively broad, since no site has been observed with a populated client list to confirm the per-entry shape against: `_authorized_clients_list()` does one precise, non-recursive envelope unwrap (`{"response": {"authorized_clients": [...]}}`, or a bare list with no envelope) rather than a multi-key/depth-first search, and `AuthorizedClient` models only the two fields a pairing flow actually reads (`public_key`, `state`), each accepting only the specific key-name variant pairs empirically evidenced, not speculative extras. Two rules any future typed accessor over an undocumented response shape must keep, demonstrated here even though `AuthorizedClientState` (`const.py`) has no falsy member: (1) field lookup must check key presence (`key in payload`), never `payload.get(key) or default` - a legal falsy value is not "missing", and a present-but-unrecognized enum value (`_normalize_state()`) is returned raw rather than coerced to `None`; (2) a `None` body, an unrecognized shape, and an explicit empty list are NOT distinguished here - they all mean "no clients" for this endpoint and collapse to `AuthorizedClients.clients == []` (never `None`), a narrower policy than the general absent-vs-empty distinction principle because both known real inputs currently only produce "empty". Tesla has not published an OpenAPI schema for pairing endpoints, so `const.py`'s enums are the schema of record; a live sample with an actual paired client is a follow-up to confirm the populated-entry shape and may require widening `AuthorizedClient`. The raw `list_authorized_clients()` method is kept alongside as the untyped escape hatch. Tests: `tests/test_teslemetry_authorized_clients.py`.
- **Typed accessor pattern for undocumented raw-dict responses**: `TeslemetryEnergySite.find_authorized_clients()` (`teslemetry/energysite.py`) is the library's first frozen-dataclass typed wrapper over a raw `dict[str, Any]`/`None`/`list` REST response, added so API-parsing logic (envelope unwrap, field lookup, shape validation, enum typing) lives in the library instead of each consumer (e.g. Home Assistant's config flow) reimplementing it. Deliberately scoped tight rather than defensively broad, since no site has been observed with a populated client list to confirm the per-entry shape against: `_authorized_clients_list()` does one precise, non-recursive envelope unwrap (`{"response": {"authorized_clients": [...]}}`, or a bare list with no envelope) rather than a multi-key/depth-first search, and `AuthorizedClient` models only the two fields a pairing flow actually reads (`public_key`, `state`), each accepting only the specific key-name variant pairs empirically evidenced, not speculative extras. Two rules any future typed accessor over an undocumented response shape must keep, demonstrated here even though `AuthorizedClientState` (`const.py`) has no falsy member: (1) field lookup must check key presence (`key in payload`), never `payload.get(key) or default` - a legal falsy value is not "missing", and a present-but-unrecognized enum value (`_normalize_state()`) is returned raw rather than coerced to `None`; (2) a `None` body and an unrecognized response shape (not a dict/list, or an envelope that unwraps to no `authorized_clients` list) are malformed data, not "zero clients" - Tesla's endpoint intermittently returns HTTP 200 with a null body, so `_authorized_clients_list()` raises `InvalidResponse` (`exceptions.py`) for both rather than collapsing them to `[]`; only a genuinely empty `authorized_clients` list parses to `AuthorizedClients.clients == []` without raising. Tesla has not published an OpenAPI schema for pairing endpoints, so `const.py`'s enums are the schema of record; a live sample with an actual paired client is a follow-up to confirm the populated-entry shape and may require widening `AuthorizedClient`. The raw `list_authorized_clients()` method is kept alongside as the untyped escape hatch and still returns a null body unchanged rather than raising. Tests: `tests/test_teslemetry_authorized_clients.py`.

## Maintaining this file

Expand Down
15 changes: 10 additions & 5 deletions docs/energy_local_control.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,16 @@ Tesla's cloud endpoint for this is undocumented, and Teslemetry's
JSON `null` with a `200` status rather than an envelope; that behavior may
recur, so do not treat this endpoint as authoritative, and never let it
override a signed local read that already succeeded or failed.
`TeslemetryEnergySite.find_authorized_clients()` parses this defensively
(null body, list vs. dict envelope, `state` typing) and always returns a
typed `AuthorizedClients` with `clients == []` rather than raising, which
keeps a "not verified yet" read from looking like an error - but a `null`
response here still tells you nothing about whether the key actually works.
`TeslemetryEnergySite.find_authorized_clients()` parses the recognized
shapes (list vs. dict envelope, `state` typing) into a typed
`AuthorizedClients`, but raises
`tesla_fleet_api.exceptions.InvalidResponse` on a null body or any other
unrecognized response shape so that malformed data is distinguishable from
a genuinely empty client list. Catch `InvalidResponse` (or
`TeslaFleetError`) around this call and treat it as "no signal" - note that
`TeslaFleetError` subclasses `BaseException`, so a bare `except Exception`
will not catch it. Either way, a `null` response here tells you nothing
about whether the key actually works.

## 5. Compose local + cloud with EnergySiteRouter

Expand Down
8 changes: 5 additions & 3 deletions docs/teslemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,9 +548,11 @@ consumers that need to inspect the client list. The helper returns an
`AuthorizedClients` result. Tesla has not published a schema for this pairing
endpoint, so the typed helper only unwraps the one envelope shape and models
the two client fields (`public_key`, `state`) confirmed by the endpoint's own
known consumer; `clients` is always a list - a null response body, an
unrecognized response shape, and an explicitly empty client list all mean "no
authorized clients" and return `[]`. `state` is typed as
known consumer; `clients` is always a list. Only an explicitly empty
`authorized_clients` list parses to `clients == []`; a null response body or
an unrecognized response shape raises
`tesla_fleet_api.exceptions.InvalidResponse` instead, so malformed data is
never mistaken for "no authorized clients". `state` is typed as
`AuthorizedClientState`. The raw response is still available on `raw` for
anything not modeled.

Expand Down
51 changes: 36 additions & 15 deletions tesla_fleet_api/teslemetry/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AuthorizedClientType,
Method,
)
from tesla_fleet_api.exceptions import InvalidResponse
from tesla_fleet_api.tesla.energysite import EnergySite, EnergySites


Expand Down Expand Up @@ -84,10 +85,14 @@ class AuthorizedClient:
class AuthorizedClients:
"""Parsed result of :meth:`TeslemetryEnergySite.find_authorized_clients`.

``clients`` is always a list: a ``None`` response body, an unrecognized
response shape, and an explicitly empty ``authorized_clients`` list all
mean "no authorized clients to report" for this endpoint and collapse
to ``[]`` rather than being distinguished.
``clients`` is a list of typed entries parsed from a well-formed
response. A ``None`` response body or a response whose shape doesn't
match the one confirmed envelope raises
:class:`~tesla_fleet_api.exceptions.InvalidResponse` instead of being
silently collapsed to "no clients" - Tesla's endpoint intermittently
returns HTTP 200 with a null body, which is malformed data, not zero
clients. A genuinely empty ``authorized_clients`` list is not malformed
and parses to ``[]``.

The envelope this unwraps (``{"response": {"authorized_clients": [...]}}``)
is pinned from the pairing flow's own defensive handling of this
Expand All @@ -101,27 +106,39 @@ class AuthorizedClients:


def _authorized_clients_list(payload: Any) -> list[Any]:
"""Return the raw authorized-clients list from a command response, or [].
"""Return the raw authorized-clients list from a command response.

A precise, single-path unwrap of the one confirmed envelope - not a
A precise, single-path unwrap of the one confirmed envelope (a bare
list, or ``{"response": {"authorized_clients": [...]}}``) - not a
search across candidate wrapper keys for this undocumented endpoint.
Raises :class:`~tesla_fleet_api.exceptions.InvalidResponse` for a null
body or any other shape, since a 200 that doesn't carry the expected
envelope is malformed, not "zero clients". A genuinely empty
``authorized_clients`` list is not malformed and returns ``[]``.
"""
if payload is None:
return []
raise InvalidResponse("authorized_clients response body was null")
if isinstance(payload, list):
return cast("list[Any]", payload)
if not isinstance(payload, dict):
return []
raise InvalidResponse(str(payload))
body = cast("dict[str, Any]", payload)
response = body.get("response")
if isinstance(response, dict):
body = cast("dict[str, Any]", response)
value = body.get("authorized_clients")
return cast("list[Any]", value) if isinstance(value, list) else []
if not isinstance(value, list):
raise InvalidResponse(cast("dict[str, Any]", payload))
return cast("list[Any]", value)


def _parse_authorized_clients(payload: Any) -> AuthorizedClients:
"""Parse a raw ``list_authorized_clients()`` response into typed clients."""
"""Parse a raw ``list_authorized_clients()`` response into typed clients.

Raises :class:`~tesla_fleet_api.exceptions.InvalidResponse` if
``payload`` is null or doesn't match the confirmed envelope shape - see
:func:`_authorized_clients_list`.
"""
clients = [
_parse_client(cast("dict[str, Any]", entry))
for entry in _authorized_clients_list(payload)
Expand Down Expand Up @@ -201,11 +218,15 @@ async def find_authorized_clients(self) -> AuthorizedClients:

Prefer this over :meth:`list_authorized_clients` for consumers that
need to inspect the client list - it centralizes the response
parsing (envelope unwrap, null-body handling, ``state`` typing)
here instead of in the caller. Treat it as a secondary, best-effort
cloud check during local key pairing; a successful signed local read
through the paired LAN client is the authoritative verification. See
:class:`AuthorizedClients` for the exact parsing semantics.
parsing (envelope unwrap, ``state`` typing) here instead of in the
caller. Raises
:class:`~tesla_fleet_api.exceptions.InvalidResponse` on a null
response body or an unrecognized response shape rather than
treating either as "no clients". Treat it as a secondary,
best-effort cloud check during local key pairing; a successful
signed local read through the paired LAN client is the
authoritative verification. See :class:`AuthorizedClients` for the
exact parsing semantics.
"""
return _parse_authorized_clients(await self.list_authorized_clients())

Expand Down
28 changes: 20 additions & 8 deletions tests/test_teslemetry_authorized_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
has been observed with a populated client list yet, so the per-entry shape
is not live-sample-confirmed - see ``AuthorizedClient`` in
``tesla_fleet_api/teslemetry/energysite.py``.

Tesla's upstream endpoint intermittently returns HTTP 200 with a null body;
a null body or any other unrecognized 200 shape is malformed data and must
raise :class:`~tesla_fleet_api.exceptions.InvalidResponse` rather than being
silently treated as "no clients" - only a genuinely empty
``authorized_clients`` list means that.
"""

from __future__ import annotations
Expand All @@ -17,6 +23,7 @@
from unittest.mock import AsyncMock, MagicMock

from tesla_fleet_api.const import AuthorizedClientState
from tesla_fleet_api.exceptions import InvalidResponse
from tesla_fleet_api.teslemetry.teslemetry import Teslemetry

_UNSET = object()
Expand Down Expand Up @@ -145,20 +152,25 @@ async def test_explicitly_empty_list_returns_typed_empty_list(self) -> None:

self.assertEqual(result.clients, [])

async def test_absent_field_returns_typed_empty_list(self) -> None:
async def test_absent_field_raises_invalid_response(self) -> None:
site = _make_site({"response": {"foo": "bar"}})

result = await site.find_authorized_clients()

self.assertEqual(result.clients, [])
with self.assertRaises(InvalidResponse):
await site.find_authorized_clients()

async def test_null_body_returns_typed_empty_list_without_raising(self) -> None:
async def test_null_body_raises_invalid_response(self) -> None:
site = _make_site(None)

result = await site.find_authorized_clients()
with self.assertRaises(InvalidResponse):
await site.find_authorized_clients()

self.assertEqual(result.clients, [])
self.assertIsNone(result.raw)
async def test_unrecognized_non_dict_non_list_body_raises_invalid_response(
self,
) -> None:
site = _make_site("not-a-valid-shape")

with self.assertRaises(InvalidResponse):
await site.find_authorized_clients()

async def test_non_dict_entries_are_skipped(self) -> None:
site = _make_site(
Expand Down
Loading