From a367eb72f2f7e8f9b1a591cddf71c5212608cf42 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Tue, 14 Jul 2026 13:32:10 +1000 Subject: [PATCH 1/2] fix(teslemetry): accept `clients` key in authorized-clients response Tesla's live endpoint (Release 953) carries the authorized-client list under `clients`, not `authorized_clients` as originally documented - confirmed via a live capture of a Powerwall 3 site with five populated entries. `_authorized_clients_list()` now checks both key variants, symmetric to the existing public_key/publicKey handling, so a perfectly valid response no longer parses to an empty/failed result. --- AGENTS.md | 2 +- tesla_fleet_api/teslemetry/energysite.py | 34 ++--- tests/test_teslemetry_authorized_clients.py | 134 +++++++++++++++++++- 3 files changed, 147 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f56a46c..85be2ae 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.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`. +- **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. `_authorized_clients_list()` does one precise, non-recursive envelope unwrap (`{"response": {"authorized_clients": [...]}}` or `{"response": {"clients": [...]}}`, or a bare list with no envelope) rather than a multi-key/depth-first search - the `clients` key variant was added after a live capture (Tesla Release 953) showed the endpoint's real key differs from the originally-documented `authorized_clients`, confirmed against a populated 5-entry sample. `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 neither accepted list key) 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 list under either accepted key 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; widen `AuthorizedClient`'s modeled fields only against a further live sample, not speculatively. 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 diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index a665bda..7799975 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -87,18 +87,17 @@ class AuthorizedClients: ``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 + match the 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 - 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. A genuinely empty list under either accepted key is not + malformed and parses to ``[]``. + + The envelope this unwraps (``{"response": {"authorized_clients": [...]}}`` + or ``{"response": {"clients": [...]}}``) is pinned from the pairing + flow's own defensive handling of this undocumented endpoint - see + :class:`AuthorizedClient`. """ clients: list[AuthorizedClient] @@ -108,13 +107,14 @@ class AuthorizedClients: def _authorized_clients_list(payload: Any) -> list[Any]: """Return the raw authorized-clients list from a command response. - 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 ``[]``. + A precise, single-path unwrap of the confirmed envelope (a bare list, + or ``{"response": {"authorized_clients": [...]}}`` / + ``{"response": {"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 either expected key is + malformed, not "zero clients". A genuinely empty list under either key + is not malformed and returns ``[]``. """ if payload is None: raise InvalidResponse("authorized_clients response body was null") @@ -126,7 +126,7 @@ def _authorized_clients_list(payload: Any) -> list[Any]: response = body.get("response") if isinstance(response, dict): body = cast("dict[str, Any]", response) - value = body.get("authorized_clients") + value = _field(body, "authorized_clients", "clients") if not isinstance(value, list): raise InvalidResponse(cast("dict[str, Any]", payload)) return cast("list[Any]", value) diff --git a/tests/test_teslemetry_authorized_clients.py b/tests/test_teslemetry_authorized_clients.py index 23bd953..3aedfed 100644 --- a/tests/test_teslemetry_authorized_clients.py +++ b/tests/test_teslemetry_authorized_clients.py @@ -2,11 +2,11 @@ over the Teslemetry ``command/authorized_clients`` endpoint. 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``. +(null body, bare list, wrapper envelope, key-name casing, and the +``authorized_clients``/``clients`` list-key variants) are pinned from the +Home Assistant Teslemetry integration's own defensive parsing of it plus a +real captured response (see ``ClientsKeyVariantTests`` below) - 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 @@ -187,3 +187,127 @@ async def test_non_dict_entries_are_skipped(self) -> None: result = await site.find_authorized_clients() self.assertEqual(len(result.clients), 1) + + +class ClientsKeyVariantTests(IsolatedAsyncioTestCase): + """Tesla's live endpoint (Release 953) carries the list under ``clients``, + not ``authorized_clients`` - captured 2026-07-14 from a Powerwall 3 site + with five real, fully-populated entries (see the ``clients`` key in the + ``ListAuthorizedClientsResponse`` payload). ``_authorized_clients_list`` + must accept both key names, symmetric to the ``public_key``/``publicKey`` + handling in ``_parse_client``. + """ + + REAL_CAPTURE_ENTRIES = [ + { + "type": 1, + "description": "Teslemetry.com", + "key_type": 1, + "public_key": "SYNTHETIC_PUBLIC_KEY_1", + "roles": [1], + "state": 3, + "verification": 1, + "added_time": {"seconds": 1777328846}, + }, + { + "type": 1, + "description": "PowerSync Cloud", + "key_type": 1, + "public_key": "SYNTHETIC_PUBLIC_KEY_2", + "roles": [1], + "state": 2, + "verification": 1, + "added_time": {"seconds": 1777288515}, + }, + { + "type": 1, + "description": "Powerwall V1R", + "key_type": 1, + "public_key": "SYNTHETIC_PUBLIC_KEY_3", + "roles": [1], + "state": 2, + "verification": 1, + "added_time": {"seconds": 1778476550}, + }, + { + "type": 1, + "description": "Pixel 10 Pro", + "key_type": 1, + "public_key": "SYNTHETIC_PUBLIC_KEY_4", + "roles": [1], + "state": 3, + "verification": 1, + "added_time": {"seconds": 1780101174}, + }, + { + "type": 1, + "description": "Home Assistant", + "key_type": 1, + "public_key": "SYNTHETIC_PUBLIC_KEY_5", + "roles": [1], + "state": 2, + "verification": 1, + "added_time": {"seconds": 1783984580}, + }, + ] + + async def test_real_captured_sample_parses_to_five_clients(self) -> None: + site = _make_site({"response": {"clients": self.REAL_CAPTURE_ENTRIES}}) + + result = await site.find_authorized_clients() + + self.assertEqual(len(result.clients), 5) + self.assertEqual( + [c.public_key for c in result.clients], + [e["public_key"] for e in self.REAL_CAPTURE_ENTRIES], + ) + self.assertEqual(result.clients[0].state, AuthorizedClientState.VERIFIED) + self.assertEqual( + result.clients[1].state, AuthorizedClientState.PENDING_VERIFICATION + ) + + async def test_clients_key_variant_is_recognized(self) -> None: + site = _make_site( + {"response": {"clients": [{"public_key": PUBLIC_KEY_B64, "state": 3}]}} + ) + + result = await site.find_authorized_clients() + + self.assertEqual(len(result.clients), 1) + self.assertEqual(result.clients[0].public_key, PUBLIC_KEY_B64) + self.assertEqual(result.clients[0].state, AuthorizedClientState.VERIFIED) + + async def test_authorized_clients_key_variant_still_recognized(self) -> None: + site = _make_site( + { + "response": { + "authorized_clients": [{"public_key": PUBLIC_KEY_B64, "state": 3}] + } + } + ) + + result = await site.find_authorized_clients() + + self.assertEqual(len(result.clients), 1) + self.assertEqual(result.clients[0].public_key, PUBLIC_KEY_B64) + + async def test_explicitly_empty_list_under_clients_key_returns_empty( + self, + ) -> None: + site = _make_site({"response": {"clients": []}}) + + result = await site.find_authorized_clients() + + self.assertEqual(result.clients, []) + + async def test_neither_key_present_still_raises_invalid_response(self) -> None: + site = _make_site({"response": {"foo": "bar"}}) + + with self.assertRaises(InvalidResponse): + await site.find_authorized_clients() + + async def test_null_body_still_raises_invalid_response(self) -> None: + site = _make_site(None) + + with self.assertRaises(InvalidResponse): + await site.find_authorized_clients() From efbd4f6077ffbab6cef6cecae62d476b7311f7e1 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Tue, 14 Jul 2026 13:36:00 +1000 Subject: [PATCH 2/2] no-mistakes(review): document both accepted authorized-clients response keys --- docs/teslemetry.md | 8 +++++--- tesla_fleet_api.egg-info/PKG-INFO | 14 +++++++++++++- tesla_fleet_api.egg-info/SOURCES.txt | 3 +++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/teslemetry.md b/docs/teslemetry.md index 167409b..8fac5d6 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -546,10 +546,12 @@ 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. 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 +endpoint, so the typed helper only unwraps the confirmed envelope shape - the +client list may arrive under either the `authorized_clients` key or the +`clients` key (the latter observed live from Tesla Release 953) - and models the two client fields (`public_key`, `state`) confirmed by the endpoint's own -known consumer; `clients` is always a list. Only an explicitly empty -`authorized_clients` list parses to `clients == []`; a null response body or +known consumer; `clients` is always a list. Only an explicitly empty list +under either accepted key 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 diff --git a/tesla_fleet_api.egg-info/PKG-INFO b/tesla_fleet_api.egg-info/PKG-INFO index 3475866..85c8143 100644 --- a/tesla_fleet_api.egg-info/PKG-INFO +++ b/tesla_fleet_api.egg-info/PKG-INFO @@ -137,6 +137,11 @@ asyncio.run(main()) For more detailed examples, see [Fleet API for Energy Sites](docs/fleet_api_energy_sites.md). +To pair an energy gateway's RSA key over the cloud and compose the resulting +signed local LAN control (via the sibling `aiopowerwall` library) with a +cloud fallback through `EnergySiteRouter`, see [Energy: Local +Control](docs/energy_local_control.md). + ### Fleet API with Signed Vehicle Commands The `VehicleSigned` class provides methods to interact with the Fleet API using signed vehicle commands. Here's a basic example: @@ -216,6 +221,11 @@ failures (including `bleak.exc.BleakError` and builtin `TimeoutError` from ESPHome proxies) and response-wait timeouts through the same library error hierarchy. +`VehicleBluetooth` can also register persistent BLE broadcast listeners for +unsolicited VCSEC `VehicleStatus` updates. Use typed `listen_*` helpers for the +decoded vehicle-status fields, or `listen_broadcast(domain, callback)` for raw +per-domain broadcast messages. + ### Routing and Failover The `Router` class composes an ordered list of two-or-more backends that share a common method surface and dispatches each method call down the chain, automatically failing over on most errors. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise: @@ -258,7 +268,7 @@ By default the router attempts the primary and fails over on any error, with no from tesla_fleet_api.router import EnergySiteRouter router = EnergySiteRouter(local_energysite, teslemetry_energysite) -await router.set_operation(...) # local first, cloud on failure +await router.operation(...) # local first, cloud on failure ``` `Router`, `VehicleRouter`, and `EnergySiteRouter` are all importable from `tesla_fleet_api.router` (and, for backward compatibility, from `tesla_fleet_api.tesla`). @@ -286,6 +296,8 @@ Command log lines use `transport=bluetooth`, `fleet`, `teslemetry`, or `tessie`. Routers also emit `backend=` lines for each backend tried. See [Bluetooth for Vehicles](docs/bluetooth_vehicles.md#troubleshooting-enable-debug-logging) for examples and the signed-command naming details. +REST responses that are valid JSON but not objects, such as `null`, lists, or +scalars, are returned unchanged and log as `result=success`. ### Teslemetry diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index 112db0f..ec90220 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -26,6 +26,7 @@ tesla_fleet_api/tesla/tesla.py tesla_fleet_api/tesla/user.py tesla_fleet_api/tesla/vehicle/__init__.py tesla_fleet_api/tesla/vehicle/bluetooth.py +tesla_fleet_api/tesla/vehicle/broadcast.py tesla_fleet_api/tesla/vehicle/commands.py tesla_fleet_api/tesla/vehicle/fleet.py tesla_fleet_api/tesla/vehicle/signed.py @@ -62,6 +63,7 @@ tesla_fleet_api/tessie/tessie.py tesla_fleet_api/tessie/vehicle.py tests/test_auto_seat_climate.py tests/test_ble_broadcast_confirmation.py +tests/test_ble_broadcast_listeners.py tests/test_ble_charging_commands.py tests/test_ble_climate_commands.py tests/test_ble_command_verification.py @@ -89,4 +91,5 @@ tests/test_fleet_auth_refresh.py tests/test_power_mode_commands.py tests/test_router.py tests/test_tesla_private_key.py +tests/test_teslemetry_authorized_clients.py tests/test_tessie_vehicle_params.py \ No newline at end of file