From 6c0ca0334b34d8225dd018e2a910e7bba02704dc Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 07:26:26 +1000 Subject: [PATCH 1/6] feat(teslemetry): add typed get_authorized_clients() accessor Adds a typed dataclass accessor over TeslemetryEnergySite's undocumented list_authorized_clients() response so API-response parsing lives in the library instead of being reimplemented in the Home Assistant integration (HA core PR #176328). Field access checks key presence rather than truthiness so a legal falsy value (e.g. authorized_client_type=0) isn't mistaken for a missing field, and an explicitly present but empty authorized_clients list is returned as [] (authoritative: zero clients) rather than collapsed into the same None used for a genuinely unknown (absent field / null body) outcome. --- AGENTS.md | 1 + tesla_fleet_api/teslemetry/energysite.py | 114 ++++++++++++++++- tests/test_teslemetry_authorized_clients.py | 133 ++++++++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 tests/test_teslemetry_authorized_clients.py diff --git a/AGENTS.md b/AGENTS.md index daf7377..6633f27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +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= transport= 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= 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.get_authorized_clients()` (`teslemetry/energysite.py`) is the library's first frozen-dataclass typed wrapper over a raw `dict[str, Any]`/`None` REST response, added so API-parsing logic (field lookup, null-body handling) lives in the library instead of each consumer (e.g. Home Assistant's config flow) reimplementing it. Two rules any future typed accessor over an undocumented response shape must keep: (1) field lookup must check key presence (`key in payload`), never `payload.get(key) or default` - a legal falsy value (e.g. `authorized_client_type=0`/`INVALID`) is not "missing"; (2) when a field can be either absent or an explicit empty list, keep those two outcomes distinct in the return type (`None` = unknown/absent vs `[]` = authoritatively empty) rather than collapsing them - `AuthorizedClients.clients` and `_find_authorized_clients_list()` are the reference implementation. The raw `list_authorized_clients()` method is kept alongside as the untyped escape hatch. Tests: `tests/test_teslemetry_authorized_clients.py`. ## Maintaining this file diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index aad6733..8a9bb80 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -1,7 +1,8 @@ from __future__ import annotations import base64 -from typing import Any +from dataclasses import dataclass +from typing import Any, cast from tesla_fleet_api.const import ( AuthorizedClientKeyType, @@ -11,6 +12,106 @@ from tesla_fleet_api.tesla.energysite import EnergySite, EnergySites +def _field(payload: dict[str, Any], *keys: str) -> Any: + """Return the first present key's value. + + Checks key presence rather than truthiness, so a legal falsy value + (``0``, ``False``, ``""``) is never mistaken for a missing field. + """ + for key in keys: + if key in payload: + return payload[key] + return None + + +def _parse_client(payload: dict[str, Any]) -> AuthorizedClient: + return AuthorizedClient( + public_key=_field(payload, "public_key", "publicKey"), + description=_field(payload, "description"), + key_type=_field(payload, "key_type", "keyType"), + authorized_client_type=_field( + payload, "authorized_client_type", "authorizedClientType" + ), + state=_field(payload, "state", "authorized_client_state"), + raw=payload, + ) + + +@dataclass(frozen=True, slots=True) +class AuthorizedClient: + """One entry from a Teslemetry ``list_authorized_clients`` response. + + Tesla has not published this response shape, so fields are read + defensively (snake_case and camelCase key variants). ``raw`` keeps the + original entry for any field not modeled here. + """ + + public_key: str | None + description: str | None + key_type: int | None + authorized_client_type: int | None + state: int | None + raw: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class AuthorizedClients: + """Parsed result of :meth:`TeslemetryEnergySite.get_authorized_clients`. + + ``clients`` is ``None`` when the outcome is genuinely unknown: the + response body itself was ``None`` (Teslemetry may return a null body), + or no ``authorized_clients``/``authorizedClients`` key could be found in + it. An explicitly present but empty client list is authoritative and + distinct from that - it means the gateway reports zero authorized + clients, not "keep looking elsewhere in the payload" - so it is + returned as ``[]``, never coerced to ``None``. + """ + + clients: list[AuthorizedClient] | None + raw: Any + + +def _find_authorized_clients_list(payload: Any) -> tuple[bool, list[Any]]: + """Locate the raw authorized-clients list in a command response. + + Returns ``(found, entries)``. ``found`` is ``False`` only when no + ``authorized_clients``/``authorizedClients`` key exists anywhere + checked (the payload itself, or its ``response`` wrapper) - an unknown + outcome, never conflated with a found-but-empty list. + """ + body: Any = payload + if isinstance(body, dict): + body = cast("dict[str, Any]", body) + wrapped = _field(body, "response") + if isinstance(wrapped, dict): + body = cast("dict[str, Any]", wrapped) + if isinstance(body, list): + return True, cast("list[Any]", body) + if not isinstance(body, dict): + return False, [] + body = cast("dict[str, Any]", body) + for key in ("authorized_clients", "authorizedClients"): + if key in body: + value = body[key] + return True, cast("list[Any]", value) if isinstance(value, list) else [] + return False, [] + + +def _parse_authorized_clients(payload: Any) -> AuthorizedClients: + """Parse a raw ``list_authorized_clients()`` response into typed clients.""" + if payload is None: + return AuthorizedClients(clients=None, raw=None) + found, entries = _find_authorized_clients_list(payload) + if not found: + return AuthorizedClients(clients=None, raw=payload) + clients = [ + _parse_client(cast("dict[str, Any]", entry)) + for entry in entries + if isinstance(entry, dict) + ] + return AuthorizedClients(clients=clients, raw=payload) + + class TeslemetryEnergySite(EnergySite): """Teslemetry specific energy site.""" @@ -77,6 +178,17 @@ async def list_authorized_clients(self) -> dict[str, Any]: f"api/1/energy_sites/{self.energy_site_id}/command/authorized_clients", ) + async def get_authorized_clients(self) -> AuthorizedClients: + """List authorized clients on the energy gateway, parsed into a typed result. + + Prefer this over :meth:`list_authorized_clients` for consumers that + need to inspect the client list - it centralizes the response + parsing (including the null-body and empty-vs-absent-list cases) + here instead of in the caller. See :class:`AuthorizedClients` for + the exact semantics. + """ + return _parse_authorized_clients(await self.list_authorized_clients()) + async def remove_authorized_client( self, params: dict[str, Any] | None = None ) -> dict[str, Any]: diff --git a/tests/test_teslemetry_authorized_clients.py b/tests/test_teslemetry_authorized_clients.py new file mode 100644 index 0000000..2e7c5d5 --- /dev/null +++ b/tests/test_teslemetry_authorized_clients.py @@ -0,0 +1,133 @@ +"""Tests for TeslemetryEnergySite.get_authorized_clients(), the typed accessor +over the Teslemetry ``command/authorized_clients`` endpoint. + +Covers the two upstream-review-flagged correctness points: a falsy field +value (e.g. an enum ``0``) must survive parsing, and an explicitly present +but empty ``authorized_clients`` list must be treated as authoritative +(zero clients) rather than as "field missing, keep looking". +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Any +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock + +from tesla_fleet_api.teslemetry.teslemetry import Teslemetry + +_UNSET = object() + +# A realistic sanitized fixture shape, matching what the Home Assistant +# Teslemetry integration's config flow (PR #176328) exercises against this +# endpoint. +PUBLIC_KEY_B64 = "MIIBCgKCAQEAsomeBase64EncodedRsaPublicKeyBytes==" + + +def _verified_clients_response() -> dict[str, Any]: + return { + "response": { + "authorized_clients": [ + "not-a-dict", + {"public_key": "some-other-key", "state": 3}, + { + "public_key": PUBLIC_KEY_B64, + "state": 3, + "description": "Home Assistant", + "key_type": 1, + "authorized_client_type": 0, + }, + ] + } + } + + +def _fake_response(*, json_body: object = _UNSET) -> MagicMock: + resp = MagicMock() + resp.status = 200 + resp.ok = True + resp.content_type = "application/json" + resp.url = "https://example.com/x" + resp.headers = {} + resp.json = AsyncMock(return_value={} if json_body is _UNSET else json_body) + resp.text = AsyncMock(return_value="") + return resp + + +def _make_session(response: object) -> MagicMock: + session = MagicMock() + + @asynccontextmanager + async def _ctx(*args: Any, **kwargs: Any): + yield response + + session.request = MagicMock(side_effect=lambda *a, **k: _ctx(*a, **k)) + return session + + +def _make_site(json_body: object): + api = Teslemetry( + session=_make_session(_fake_response(json_body=json_body)), + access_token="token", + ) + return api.energySites.create(12345) + + +class GetAuthorizedClientsTests(IsolatedAsyncioTestCase): + async def test_normal_payload_round_trips(self) -> None: + site = _make_site(_verified_clients_response()) + + result = await site.get_authorized_clients() + + self.assertIsNotNone(result.clients) + assert result.clients is not None + self.assertEqual(len(result.clients), 2) + matched = result.clients[1] + self.assertEqual(matched.public_key, PUBLIC_KEY_B64) + self.assertEqual(matched.state, 3) + self.assertEqual(matched.description, "Home Assistant") + self.assertEqual(matched.key_type, 1) + + async def test_falsy_zero_field_is_preserved(self) -> None: + site = _make_site(_verified_clients_response()) + + result = await site.get_authorized_clients() + + assert result.clients is not None + matched = result.clients[1] + # authorized_client_type=0 (AuthorizedClientType.INVALID) is a legal + # value, not a stand-in for "field absent". + self.assertEqual(matched.authorized_client_type, 0) + self.assertIsNotNone(matched.authorized_client_type) + + async def test_explicitly_empty_list_is_authoritative(self) -> None: + site = _make_site({"response": {"authorized_clients": []}}) + + result = await site.get_authorized_clients() + + self.assertEqual(result.clients, []) + self.assertIsNotNone(result.clients) + + async def test_absent_field_is_distinct_from_empty_list(self) -> None: + site = _make_site({"response": {"foo": "bar"}}) + + result = await site.get_authorized_clients() + + self.assertIsNone(result.clients) + + async def test_null_body_is_handled_without_raising(self) -> None: + site = _make_site(None) + + result = await site.get_authorized_clients() + + self.assertIsNone(result.clients) + self.assertIsNone(result.raw) + + async def test_non_dict_entries_are_skipped(self) -> None: + site = _make_site(_verified_clients_response()) + + result = await site.get_authorized_clients() + + assert result.clients is not None + # The "not-a-dict" entry in the fixture is dropped, not raised on. + self.assertTrue(all(hasattr(c, "public_key") for c in result.clients)) From 6697a3866f4952059b2ccce607097c2e53ca540b Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 07:32:58 +1000 Subject: [PATCH 2/6] no-mistakes(document): Document Teslemetry authorized clients --- docs/teslemetry.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/teslemetry.md b/docs/teslemetry.md index 5071425..2fe2dc5 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -540,6 +540,40 @@ async def main(): asyncio.run(main()) ``` +## Energy Site Authorized Clients + +Teslemetry energy sites support the same raw `list_authorized_clients` command +as Fleet API energy sites, plus a typed `get_authorized_clients` helper for +consumers that need to inspect the client list. The typed helper keeps the raw +response available on `raw`, parses each client into an `AuthorizedClient`, and +preserves the distinction between an unknown response (`clients is None`) and +an explicitly empty client list (`clients == []`). + +```python +async def main(): + async with aiohttp.ClientSession() as session: + teslemetry = Teslemetry( + session=session, + access_token="", + ) + + energy_site = teslemetry.energySites.create(12345) + + result = await energy_site.get_authorized_clients() + if result.clients is None: + print("Authorized clients are not available in this response") + else: + for client in result.clients: + print(client.description, client.state, client.public_key) + + # The untyped response is still available when callers need the exact + # Teslemetry payload. + raw = await energy_site.list_authorized_clients() + print(raw) + +asyncio.run(main()) +``` + ## Migrate to OAuth The `migrate_to_oauth` method migrates from an access token to OAuth. From de77bd00a4f1f23e555931b83775bf15721aa0d5 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 07:50:57 +1000 Subject: [PATCH 3/6] fix(teslemetry): tighten get_authorized_clients() to evidenced wire shape Narrows the typed accessor to what's actually confirmed: a precise, non-recursive envelope unwrap (no multi-key/depth-first search) instead of mirroring the HA config flow's speculative _iter_clients fallback search, and the AuthorizedClient model trimmed to only public_key/state - the two fields a pairing flow reads - since no site has a populated client list yet to confirm any other field names against. state is now typed via AuthorizedClientState (const.py, the schema of record - Tesla has not published this endpoint's schema), accepting both int and string wire forms; a present-but-unrecognized value passes through raw rather than being dropped to None. A None body, an unrecognized response shape, and an explicit empty list all collapse to a typed empty list (AuthorizedClients.clients is now always a list, never None) - this endpoint has no evidence yet that those cases mean anything different from each other. --- AGENTS.md | 2 +- tesla_fleet_api/teslemetry/energysite.py | 118 +++++++++------- tests/test_teslemetry_authorized_clients.py | 146 +++++++++++++------- 3 files changed, 164 insertions(+), 102 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6633f27..1ace744 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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= transport= 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= 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.get_authorized_clients()` (`teslemetry/energysite.py`) is the library's first frozen-dataclass typed wrapper over a raw `dict[str, Any]`/`None` REST response, added so API-parsing logic (field lookup, null-body handling) lives in the library instead of each consumer (e.g. Home Assistant's config flow) reimplementing it. Two rules any future typed accessor over an undocumented response shape must keep: (1) field lookup must check key presence (`key in payload`), never `payload.get(key) or default` - a legal falsy value (e.g. `authorized_client_type=0`/`INVALID`) is not "missing"; (2) when a field can be either absent or an explicit empty list, keep those two outcomes distinct in the return type (`None` = unknown/absent vs `[]` = authoritatively empty) rather than collapsing them - `AuthorizedClients.clients` and `_find_authorized_clients_list()` are the reference implementation. 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.get_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`. ## Maintaining this file diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index 8a9bb80..264c08b 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -6,6 +6,7 @@ from tesla_fleet_api.const import ( AuthorizedClientKeyType, + AuthorizedClientState, AuthorizedClientType, Method, ) @@ -24,15 +25,40 @@ def _field(payload: dict[str, Any], *keys: str) -> Any: return None +def _normalize_state(value: Any) -> AuthorizedClientState | int | str | None: + """Type a raw ``state`` value against ``AuthorizedClientState``. + + Tesla has not published an OpenAPI schema for this pairing endpoint, so + ``const.py``'s enum is the schema of record. A recognized int or member + name (case-insensitive) becomes the enum member; a present-but- + unrecognized value is returned unchanged rather than dropped to + ``None``, since only a genuinely absent field means ``None``. ``bool`` + is excluded from the int branch since it subclasses ``int`` but is + never a legal state code. + """ + if ( + value is None + or isinstance(value, AuthorizedClientState) + or isinstance(value, bool) + ): + return value + if isinstance(value, int): + try: + return AuthorizedClientState(value) + except ValueError: + return value + if isinstance(value, str): + try: + return AuthorizedClientState[value.strip().upper()] + except KeyError: + return value + return value + + def _parse_client(payload: dict[str, Any]) -> AuthorizedClient: return AuthorizedClient( public_key=_field(payload, "public_key", "publicKey"), - description=_field(payload, "description"), - key_type=_field(payload, "key_type", "keyType"), - authorized_client_type=_field( - payload, "authorized_client_type", "authorizedClientType" - ), - state=_field(payload, "state", "authorized_client_state"), + state=_normalize_state(_field(payload, "state", "authorized_client_state")), raw=payload, ) @@ -41,16 +67,16 @@ def _parse_client(payload: dict[str, Any]) -> AuthorizedClient: class AuthorizedClient: """One entry from a Teslemetry ``list_authorized_clients`` response. - Tesla has not published this response shape, so fields are read - defensively (snake_case and camelCase key variants). ``raw`` keeps the - original entry for any field not modeled here. + Only ``public_key`` and ``state`` are modeled - the two fields a + pairing flow needs to confirm a registered key. Tesla has not + published this response's schema, so anything else on an entry is + available via ``raw`` rather than guessed at. Each field accepts the + two key-name variants observed for it (``public_key``/``publicKey``, + ``state``/``authorized_client_state``). """ public_key: str | None - description: str | None - key_type: int | None - authorized_client_type: int | None - state: int | None + state: AuthorizedClientState | int | str | None raw: dict[str, Any] @@ -58,55 +84,47 @@ class AuthorizedClient: class AuthorizedClients: """Parsed result of :meth:`TeslemetryEnergySite.get_authorized_clients`. - ``clients`` is ``None`` when the outcome is genuinely unknown: the - response body itself was ``None`` (Teslemetry may return a null body), - or no ``authorized_clients``/``authorizedClients`` key could be found in - it. An explicitly present but empty client list is authoritative and - distinct from that - it means the gateway reports zero authorized - clients, not "keep looking elsewhere in the payload" - so it is - returned as ``[]``, never coerced to ``None``. + ``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. + + The envelope this unwraps (``{"response": {"authorized_clients": [...]}}``) + is pinned from the pairing flow's own defensive handling of this + undocumented endpoint. No site has been observed with a populated + client list yet, so that per-entry shape is unconfirmed by a live + sample - see :class:`AuthorizedClient`. """ - clients: list[AuthorizedClient] | None + clients: list[AuthorizedClient] raw: Any -def _find_authorized_clients_list(payload: Any) -> tuple[bool, list[Any]]: - """Locate the raw authorized-clients list in a command response. +def _authorized_clients_list(payload: Any) -> list[Any]: + """Return the raw authorized-clients list from a command response, or []. - Returns ``(found, entries)``. ``found`` is ``False`` only when no - ``authorized_clients``/``authorizedClients`` key exists anywhere - checked (the payload itself, or its ``response`` wrapper) - an unknown - outcome, never conflated with a found-but-empty list. + A precise, single-path unwrap of the one documented envelope - not a + search across candidate wrapper keys. """ - body: Any = payload - if isinstance(body, dict): - body = cast("dict[str, Any]", body) - wrapped = _field(body, "response") - if isinstance(wrapped, dict): - body = cast("dict[str, Any]", wrapped) - if isinstance(body, list): - return True, cast("list[Any]", body) - if not isinstance(body, dict): - return False, [] - body = cast("dict[str, Any]", body) - for key in ("authorized_clients", "authorizedClients"): - if key in body: - value = body[key] - return True, cast("list[Any]", value) if isinstance(value, list) else [] - return False, [] + if payload is None: + return [] + if isinstance(payload, list): + return cast("list[Any]", payload) + if not isinstance(payload, dict): + return [] + 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 [] def _parse_authorized_clients(payload: Any) -> AuthorizedClients: """Parse a raw ``list_authorized_clients()`` response into typed clients.""" - if payload is None: - return AuthorizedClients(clients=None, raw=None) - found, entries = _find_authorized_clients_list(payload) - if not found: - return AuthorizedClients(clients=None, raw=payload) clients = [ _parse_client(cast("dict[str, Any]", entry)) - for entry in entries + for entry in _authorized_clients_list(payload) if isinstance(entry, dict) ] return AuthorizedClients(clients=clients, raw=payload) @@ -183,7 +201,7 @@ async def get_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 (including the null-body and empty-vs-absent-list cases) + parsing (envelope unwrap, null-body handling, ``state`` typing) here instead of in the caller. See :class:`AuthorizedClients` for the exact semantics. """ diff --git a/tests/test_teslemetry_authorized_clients.py b/tests/test_teslemetry_authorized_clients.py index 2e7c5d5..eef4f86 100644 --- a/tests/test_teslemetry_authorized_clients.py +++ b/tests/test_teslemetry_authorized_clients.py @@ -1,10 +1,12 @@ """Tests for TeslemetryEnergySite.get_authorized_clients(), the typed accessor over the Teslemetry ``command/authorized_clients`` endpoint. -Covers the two upstream-review-flagged correctness points: a falsy field -value (e.g. an enum ``0``) must survive parsing, and an explicitly present -but empty ``authorized_clients`` list must be treated as authoritative -(zero clients) rather than as "field missing, keep looking". +This endpoint's schema is undocumented; the wire-shape variants covered here +(null body, bare list, wrapper envelope, key-name casing) are pinned from the +Home Assistant Teslemetry integration's own defensive parsing of it. No site +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``. """ from __future__ import annotations @@ -14,34 +16,14 @@ from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock, MagicMock +from tesla_fleet_api.const import AuthorizedClientState from tesla_fleet_api.teslemetry.teslemetry import Teslemetry _UNSET = object() -# A realistic sanitized fixture shape, matching what the Home Assistant -# Teslemetry integration's config flow (PR #176328) exercises against this -# endpoint. PUBLIC_KEY_B64 = "MIIBCgKCAQEAsomeBase64EncodedRsaPublicKeyBytes==" -def _verified_clients_response() -> dict[str, Any]: - return { - "response": { - "authorized_clients": [ - "not-a-dict", - {"public_key": "some-other-key", "state": 3}, - { - "public_key": PUBLIC_KEY_B64, - "state": 3, - "description": "Home Assistant", - "key_type": 1, - "authorized_client_type": 0, - }, - ] - } - } - - def _fake_response(*, json_body: object = _UNSET) -> MagicMock: resp = MagicMock() resp.status = 200 @@ -75,59 +57,121 @@ def _make_site(json_body: object): class GetAuthorizedClientsTests(IsolatedAsyncioTestCase): async def test_normal_payload_round_trips(self) -> None: - site = _make_site(_verified_clients_response()) + site = _make_site( + { + "response": { + "authorized_clients": [ + "not-a-dict", + { + "public_key": PUBLIC_KEY_B64, + "state": 3, + }, + ] + } + } + ) result = await site.get_authorized_clients() - self.assertIsNotNone(result.clients) - assert result.clients is not None - self.assertEqual(len(result.clients), 2) - matched = result.clients[1] + self.assertEqual(len(result.clients), 1) + matched = result.clients[0] self.assertEqual(matched.public_key, PUBLIC_KEY_B64) - self.assertEqual(matched.state, 3) - self.assertEqual(matched.description, "Home Assistant") - self.assertEqual(matched.key_type, 1) + self.assertEqual(matched.state, AuthorizedClientState.VERIFIED) + + async def test_bare_list_payload_with_no_envelope(self) -> None: + site = _make_site([{"public_key": PUBLIC_KEY_B64, "state": 1}]) + + result = await site.get_authorized_clients() - async def test_falsy_zero_field_is_preserved(self) -> None: - site = _make_site(_verified_clients_response()) + self.assertEqual(len(result.clients), 1) + self.assertEqual(result.clients[0].state, AuthorizedClientState.PENDING) + + async def test_camel_case_entry_fields_are_recognized(self) -> None: + site = _make_site( + { + "response": { + "authorized_clients": [ + { + "publicKey": PUBLIC_KEY_B64, + "authorized_client_state": 3, + } + ] + } + } + ) result = await site.get_authorized_clients() - assert result.clients is not None - matched = result.clients[1] - # authorized_client_type=0 (AuthorizedClientType.INVALID) is a legal - # value, not a stand-in for "field absent". - self.assertEqual(matched.authorized_client_type, 0) - self.assertIsNotNone(matched.authorized_client_type) + self.assertEqual(len(result.clients), 1) + matched = result.clients[0] + self.assertEqual(matched.public_key, PUBLIC_KEY_B64) + self.assertEqual(matched.state, AuthorizedClientState.VERIFIED) + + async def test_state_as_string_is_typed_via_enum(self) -> None: + site = _make_site( + { + "response": { + "authorized_clients": [ + {"public_key": PUBLIC_KEY_B64, "state": "verified"} + ] + } + } + ) + + result = await site.get_authorized_clients() - async def test_explicitly_empty_list_is_authoritative(self) -> None: + self.assertEqual(result.clients[0].state, AuthorizedClientState.VERIFIED) + + async def test_unrecognized_present_state_is_preserved_not_dropped(self) -> None: + site = _make_site( + { + "response": { + "authorized_clients": [{"public_key": PUBLIC_KEY_B64, "state": 0}] + } + } + ) + + result = await site.get_authorized_clients() + + # 0 is not a member of AuthorizedClientState, but it is a present + # value - it must not be coerced to None (which means "absent"). + self.assertEqual(result.clients[0].state, 0) + self.assertIsNotNone(result.clients[0].state) + + async def test_explicitly_empty_list_returns_typed_empty_list(self) -> None: site = _make_site({"response": {"authorized_clients": []}}) result = await site.get_authorized_clients() self.assertEqual(result.clients, []) - self.assertIsNotNone(result.clients) - async def test_absent_field_is_distinct_from_empty_list(self) -> None: + async def test_absent_field_returns_typed_empty_list(self) -> None: site = _make_site({"response": {"foo": "bar"}}) result = await site.get_authorized_clients() - self.assertIsNone(result.clients) + self.assertEqual(result.clients, []) - async def test_null_body_is_handled_without_raising(self) -> None: + async def test_null_body_returns_typed_empty_list_without_raising(self) -> None: site = _make_site(None) result = await site.get_authorized_clients() - self.assertIsNone(result.clients) + self.assertEqual(result.clients, []) self.assertIsNone(result.raw) async def test_non_dict_entries_are_skipped(self) -> None: - site = _make_site(_verified_clients_response()) + site = _make_site( + { + "response": { + "authorized_clients": [ + "not-a-dict", + {"public_key": PUBLIC_KEY_B64, "state": 3}, + ] + } + } + ) result = await site.get_authorized_clients() - assert result.clients is not None - # The "not-a-dict" entry in the fixture is dropped, not raised on. - self.assertTrue(all(hasattr(c, "public_key") for c in result.clients)) + self.assertEqual(len(result.clients), 1) From 8d1226ca868467b9f2acc9e2d84c93ced27d1c10 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 07:52:31 +1000 Subject: [PATCH 4/6] docs(teslemetry): update authorized-clients example for tightened accessor The auto-generated docs section described the pre-amendment API (clients is None for unknown vs [] for empty, a description field). Updates it to match: clients is always a list, and only public_key/state are modeled. --- docs/teslemetry.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/teslemetry.md b/docs/teslemetry.md index 2fe2dc5..4a04f70 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -544,10 +544,14 @@ asyncio.run(main()) Teslemetry energy sites support the same raw `list_authorized_clients` command as Fleet API energy sites, plus a typed `get_authorized_clients` helper for -consumers that need to inspect the client list. The typed helper keeps the raw -response available on `raw`, parses each client into an `AuthorizedClient`, and -preserves the distinction between an unknown response (`clients is None`) and -an explicitly empty client list (`clients == []`). +consumers that need to inspect the client list. 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 `AuthorizedClientState`. The raw response is still available on +`raw` for anything not modeled. ```python async def main(): @@ -560,11 +564,8 @@ async def main(): energy_site = teslemetry.energySites.create(12345) result = await energy_site.get_authorized_clients() - if result.clients is None: - print("Authorized clients are not available in this response") - else: - for client in result.clients: - print(client.description, client.state, client.public_key) + for client in result.clients: + print(client.public_key, client.state) # The untyped response is still available when callers need the exact # Teslemetry payload. From 0b95020b4bd83ccae87d6d5a4504eddae1514774 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 07:56:45 +1000 Subject: [PATCH 5/6] refactor(teslemetry): rename get_authorized_clients to find_authorized_clients Per PR review: this isn't a straight getter, it normalizes/parses the raw response into the typed model, so it should use the find_ prefix already established for lookup/discovery methods in this library (find_server, find_vehicle). --- AGENTS.md | 2 +- docs/teslemetry.md | 4 ++-- tesla_fleet_api/teslemetry/energysite.py | 4 ++-- tests/test_teslemetry_authorized_clients.py | 20 ++++++++++---------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ace744..6fbceca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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= transport= 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= 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.get_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, 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`. ## Maintaining this file diff --git a/docs/teslemetry.md b/docs/teslemetry.md index 4a04f70..c3e905e 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -543,7 +543,7 @@ asyncio.run(main()) ## Energy Site Authorized Clients Teslemetry energy sites support the same raw `list_authorized_clients` command -as Fleet API energy sites, plus a typed `get_authorized_clients` helper for +as Fleet API energy sites, plus a typed `find_authorized_clients` helper for consumers that need to inspect the client list. 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`) @@ -563,7 +563,7 @@ async def main(): energy_site = teslemetry.energySites.create(12345) - result = await energy_site.get_authorized_clients() + result = await energy_site.find_authorized_clients() for client in result.clients: print(client.public_key, client.state) diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index 264c08b..263b24c 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -82,7 +82,7 @@ class AuthorizedClient: @dataclass(frozen=True, slots=True) class AuthorizedClients: - """Parsed result of :meth:`TeslemetryEnergySite.get_authorized_clients`. + """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 @@ -196,7 +196,7 @@ async def list_authorized_clients(self) -> dict[str, Any]: f"api/1/energy_sites/{self.energy_site_id}/command/authorized_clients", ) - async def get_authorized_clients(self) -> AuthorizedClients: + async def find_authorized_clients(self) -> AuthorizedClients: """List authorized clients on the energy gateway, parsed into a typed result. Prefer this over :meth:`list_authorized_clients` for consumers that diff --git a/tests/test_teslemetry_authorized_clients.py b/tests/test_teslemetry_authorized_clients.py index eef4f86..2121382 100644 --- a/tests/test_teslemetry_authorized_clients.py +++ b/tests/test_teslemetry_authorized_clients.py @@ -1,4 +1,4 @@ -"""Tests for TeslemetryEnergySite.get_authorized_clients(), the typed accessor +"""Tests for TeslemetryEnergySite.find_authorized_clients(), the typed accessor over the Teslemetry ``command/authorized_clients`` endpoint. This endpoint's schema is undocumented; the wire-shape variants covered here @@ -71,7 +71,7 @@ async def test_normal_payload_round_trips(self) -> None: } ) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() self.assertEqual(len(result.clients), 1) matched = result.clients[0] @@ -81,7 +81,7 @@ async def test_normal_payload_round_trips(self) -> None: async def test_bare_list_payload_with_no_envelope(self) -> None: site = _make_site([{"public_key": PUBLIC_KEY_B64, "state": 1}]) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() self.assertEqual(len(result.clients), 1) self.assertEqual(result.clients[0].state, AuthorizedClientState.PENDING) @@ -100,7 +100,7 @@ async def test_camel_case_entry_fields_are_recognized(self) -> None: } ) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() self.assertEqual(len(result.clients), 1) matched = result.clients[0] @@ -118,7 +118,7 @@ async def test_state_as_string_is_typed_via_enum(self) -> None: } ) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() self.assertEqual(result.clients[0].state, AuthorizedClientState.VERIFIED) @@ -131,7 +131,7 @@ async def test_unrecognized_present_state_is_preserved_not_dropped(self) -> None } ) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() # 0 is not a member of AuthorizedClientState, but it is a present # value - it must not be coerced to None (which means "absent"). @@ -141,21 +141,21 @@ async def test_unrecognized_present_state_is_preserved_not_dropped(self) -> None async def test_explicitly_empty_list_returns_typed_empty_list(self) -> None: site = _make_site({"response": {"authorized_clients": []}}) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() self.assertEqual(result.clients, []) async def test_absent_field_returns_typed_empty_list(self) -> None: site = _make_site({"response": {"foo": "bar"}}) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() self.assertEqual(result.clients, []) async def test_null_body_returns_typed_empty_list_without_raising(self) -> None: site = _make_site(None) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() self.assertEqual(result.clients, []) self.assertIsNone(result.raw) @@ -172,6 +172,6 @@ async def test_non_dict_entries_are_skipped(self) -> None: } ) - result = await site.get_authorized_clients() + result = await site.find_authorized_clients() self.assertEqual(len(result.clients), 1) From 0c11ed112885083f77163fcc5ef3559696426fd6 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 08:01:54 +1000 Subject: [PATCH 6/6] no-mistakes(document): Sync authorized-client docs --- docs/teslemetry.md | 17 +++++++++-------- tesla_fleet_api/teslemetry/energysite.py | 4 ++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/teslemetry.md b/docs/teslemetry.md index c3e905e..e641920 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -544,14 +544,15 @@ asyncio.run(main()) Teslemetry energy sites support the same raw `list_authorized_clients` command as Fleet API energy sites, plus a typed `find_authorized_clients` helper for -consumers that need to inspect the client list. 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 `AuthorizedClientState`. The raw response is still available on -`raw` for anything not modeled. +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 +`AuthorizedClientState`. The raw response is still available on `raw` for +anything not modeled. ```python async def main(): diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index 263b24c..46d7475 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -103,8 +103,8 @@ class AuthorizedClients: def _authorized_clients_list(payload: Any) -> list[Any]: """Return the raw authorized-clients list from a command response, or []. - A precise, single-path unwrap of the one documented envelope - not a - search across candidate wrapper keys. + A precise, single-path unwrap of the one confirmed envelope - not a + search across candidate wrapper keys for this undocumented endpoint. """ if payload is None: return []