Skip to content

Commit 0283459

Browse files
Bre77firstmate crewmate
andauthored
feat(teslemetry): add typed authorized clients accessor (#76)
* 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. * no-mistakes(document): Document Teslemetry authorized clients * 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. * 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. * 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). * no-mistakes(document): Sync authorized-client docs --------- Co-authored-by: firstmate crewmate <crewmate@firstmate.local>
1 parent 0ad4fb8 commit 0283459

4 files changed

Lines changed: 345 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te
142142
- **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).
143143
- **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.
144144
- **`_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.
145+
- **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`.
145146

146147
## Maintaining this file
147148

docs/teslemetry.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,42 @@ async def main():
540540
asyncio.run(main())
541541
```
542542

543+
## Energy Site Authorized Clients
544+
545+
Teslemetry energy sites support the same raw `list_authorized_clients` command
546+
as Fleet API energy sites, plus a typed `find_authorized_clients` helper for
547+
consumers that need to inspect the client list. The helper returns an
548+
`AuthorizedClients` result. Tesla has not published a schema for this pairing
549+
endpoint, so the typed helper only unwraps the one envelope shape and models
550+
the two client fields (`public_key`, `state`) confirmed by the endpoint's own
551+
known consumer; `clients` is always a list - a null response body, an
552+
unrecognized response shape, and an explicitly empty client list all mean "no
553+
authorized clients" and return `[]`. `state` is typed as
554+
`AuthorizedClientState`. The raw response is still available on `raw` for
555+
anything not modeled.
556+
557+
```python
558+
async def main():
559+
async with aiohttp.ClientSession() as session:
560+
teslemetry = Teslemetry(
561+
session=session,
562+
access_token="<access_token>",
563+
)
564+
565+
energy_site = teslemetry.energySites.create(12345)
566+
567+
result = await energy_site.find_authorized_clients()
568+
for client in result.clients:
569+
print(client.public_key, client.state)
570+
571+
# The untyped response is still available when callers need the exact
572+
# Teslemetry payload.
573+
raw = await energy_site.list_authorized_clients()
574+
print(raw)
575+
576+
asyncio.run(main())
577+
```
578+
543579
## Migrate to OAuth
544580

545581
The `migrate_to_oauth` method migrates from an access token to OAuth.

tesla_fleet_api/teslemetry/energysite.py

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,135 @@
11
from __future__ import annotations
22

33
import base64
4-
from typing import Any
4+
from dataclasses import dataclass
5+
from typing import Any, cast
56

67
from tesla_fleet_api.const import (
78
AuthorizedClientKeyType,
9+
AuthorizedClientState,
810
AuthorizedClientType,
911
Method,
1012
)
1113
from tesla_fleet_api.tesla.energysite import EnergySite, EnergySites
1214

1315

16+
def _field(payload: dict[str, Any], *keys: str) -> Any:
17+
"""Return the first present key's value.
18+
19+
Checks key presence rather than truthiness, so a legal falsy value
20+
(``0``, ``False``, ``""``) is never mistaken for a missing field.
21+
"""
22+
for key in keys:
23+
if key in payload:
24+
return payload[key]
25+
return None
26+
27+
28+
def _normalize_state(value: Any) -> AuthorizedClientState | int | str | None:
29+
"""Type a raw ``state`` value against ``AuthorizedClientState``.
30+
31+
Tesla has not published an OpenAPI schema for this pairing endpoint, so
32+
``const.py``'s enum is the schema of record. A recognized int or member
33+
name (case-insensitive) becomes the enum member; a present-but-
34+
unrecognized value is returned unchanged rather than dropped to
35+
``None``, since only a genuinely absent field means ``None``. ``bool``
36+
is excluded from the int branch since it subclasses ``int`` but is
37+
never a legal state code.
38+
"""
39+
if (
40+
value is None
41+
or isinstance(value, AuthorizedClientState)
42+
or isinstance(value, bool)
43+
):
44+
return value
45+
if isinstance(value, int):
46+
try:
47+
return AuthorizedClientState(value)
48+
except ValueError:
49+
return value
50+
if isinstance(value, str):
51+
try:
52+
return AuthorizedClientState[value.strip().upper()]
53+
except KeyError:
54+
return value
55+
return value
56+
57+
58+
def _parse_client(payload: dict[str, Any]) -> AuthorizedClient:
59+
return AuthorizedClient(
60+
public_key=_field(payload, "public_key", "publicKey"),
61+
state=_normalize_state(_field(payload, "state", "authorized_client_state")),
62+
raw=payload,
63+
)
64+
65+
66+
@dataclass(frozen=True, slots=True)
67+
class AuthorizedClient:
68+
"""One entry from a Teslemetry ``list_authorized_clients`` response.
69+
70+
Only ``public_key`` and ``state`` are modeled - the two fields a
71+
pairing flow needs to confirm a registered key. Tesla has not
72+
published this response's schema, so anything else on an entry is
73+
available via ``raw`` rather than guessed at. Each field accepts the
74+
two key-name variants observed for it (``public_key``/``publicKey``,
75+
``state``/``authorized_client_state``).
76+
"""
77+
78+
public_key: str | None
79+
state: AuthorizedClientState | int | str | None
80+
raw: dict[str, Any]
81+
82+
83+
@dataclass(frozen=True, slots=True)
84+
class AuthorizedClients:
85+
"""Parsed result of :meth:`TeslemetryEnergySite.find_authorized_clients`.
86+
87+
``clients`` is always a list: a ``None`` response body, an unrecognized
88+
response shape, and an explicitly empty ``authorized_clients`` list all
89+
mean "no authorized clients to report" for this endpoint and collapse
90+
to ``[]`` rather than being distinguished.
91+
92+
The envelope this unwraps (``{"response": {"authorized_clients": [...]}}``)
93+
is pinned from the pairing flow's own defensive handling of this
94+
undocumented endpoint. No site has been observed with a populated
95+
client list yet, so that per-entry shape is unconfirmed by a live
96+
sample - see :class:`AuthorizedClient`.
97+
"""
98+
99+
clients: list[AuthorizedClient]
100+
raw: Any
101+
102+
103+
def _authorized_clients_list(payload: Any) -> list[Any]:
104+
"""Return the raw authorized-clients list from a command response, or [].
105+
106+
A precise, single-path unwrap of the one confirmed envelope - not a
107+
search across candidate wrapper keys for this undocumented endpoint.
108+
"""
109+
if payload is None:
110+
return []
111+
if isinstance(payload, list):
112+
return cast("list[Any]", payload)
113+
if not isinstance(payload, dict):
114+
return []
115+
body = cast("dict[str, Any]", payload)
116+
response = body.get("response")
117+
if isinstance(response, dict):
118+
body = cast("dict[str, Any]", response)
119+
value = body.get("authorized_clients")
120+
return cast("list[Any]", value) if isinstance(value, list) else []
121+
122+
123+
def _parse_authorized_clients(payload: Any) -> AuthorizedClients:
124+
"""Parse a raw ``list_authorized_clients()`` response into typed clients."""
125+
clients = [
126+
_parse_client(cast("dict[str, Any]", entry))
127+
for entry in _authorized_clients_list(payload)
128+
if isinstance(entry, dict)
129+
]
130+
return AuthorizedClients(clients=clients, raw=payload)
131+
132+
14133
class TeslemetryEnergySite(EnergySite):
15134
"""Teslemetry specific energy site."""
16135

@@ -77,6 +196,17 @@ async def list_authorized_clients(self) -> dict[str, Any]:
77196
f"api/1/energy_sites/{self.energy_site_id}/command/authorized_clients",
78197
)
79198

199+
async def find_authorized_clients(self) -> AuthorizedClients:
200+
"""List authorized clients on the energy gateway, parsed into a typed result.
201+
202+
Prefer this over :meth:`list_authorized_clients` for consumers that
203+
need to inspect the client list - it centralizes the response
204+
parsing (envelope unwrap, null-body handling, ``state`` typing)
205+
here instead of in the caller. See :class:`AuthorizedClients` for
206+
the exact semantics.
207+
"""
208+
return _parse_authorized_clients(await self.list_authorized_clients())
209+
80210
async def remove_authorized_client(
81211
self, params: dict[str, Any] | None = None
82212
) -> dict[str, Any]:
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Tests for TeslemetryEnergySite.find_authorized_clients(), the typed accessor
2+
over the Teslemetry ``command/authorized_clients`` endpoint.
3+
4+
This endpoint's schema is undocumented; the wire-shape variants covered here
5+
(null body, bare list, wrapper envelope, key-name casing) are pinned from the
6+
Home Assistant Teslemetry integration's own defensive parsing of it. No site
7+
has been observed with a populated client list yet, so the per-entry shape
8+
is not live-sample-confirmed - see ``AuthorizedClient`` in
9+
``tesla_fleet_api/teslemetry/energysite.py``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from contextlib import asynccontextmanager
15+
from typing import Any
16+
from unittest import IsolatedAsyncioTestCase
17+
from unittest.mock import AsyncMock, MagicMock
18+
19+
from tesla_fleet_api.const import AuthorizedClientState
20+
from tesla_fleet_api.teslemetry.teslemetry import Teslemetry
21+
22+
_UNSET = object()
23+
24+
PUBLIC_KEY_B64 = "MIIBCgKCAQEAsomeBase64EncodedRsaPublicKeyBytes=="
25+
26+
27+
def _fake_response(*, json_body: object = _UNSET) -> MagicMock:
28+
resp = MagicMock()
29+
resp.status = 200
30+
resp.ok = True
31+
resp.content_type = "application/json"
32+
resp.url = "https://example.com/x"
33+
resp.headers = {}
34+
resp.json = AsyncMock(return_value={} if json_body is _UNSET else json_body)
35+
resp.text = AsyncMock(return_value="")
36+
return resp
37+
38+
39+
def _make_session(response: object) -> MagicMock:
40+
session = MagicMock()
41+
42+
@asynccontextmanager
43+
async def _ctx(*args: Any, **kwargs: Any):
44+
yield response
45+
46+
session.request = MagicMock(side_effect=lambda *a, **k: _ctx(*a, **k))
47+
return session
48+
49+
50+
def _make_site(json_body: object):
51+
api = Teslemetry(
52+
session=_make_session(_fake_response(json_body=json_body)),
53+
access_token="token",
54+
)
55+
return api.energySites.create(12345)
56+
57+
58+
class GetAuthorizedClientsTests(IsolatedAsyncioTestCase):
59+
async def test_normal_payload_round_trips(self) -> None:
60+
site = _make_site(
61+
{
62+
"response": {
63+
"authorized_clients": [
64+
"not-a-dict",
65+
{
66+
"public_key": PUBLIC_KEY_B64,
67+
"state": 3,
68+
},
69+
]
70+
}
71+
}
72+
)
73+
74+
result = await site.find_authorized_clients()
75+
76+
self.assertEqual(len(result.clients), 1)
77+
matched = result.clients[0]
78+
self.assertEqual(matched.public_key, PUBLIC_KEY_B64)
79+
self.assertEqual(matched.state, AuthorizedClientState.VERIFIED)
80+
81+
async def test_bare_list_payload_with_no_envelope(self) -> None:
82+
site = _make_site([{"public_key": PUBLIC_KEY_B64, "state": 1}])
83+
84+
result = await site.find_authorized_clients()
85+
86+
self.assertEqual(len(result.clients), 1)
87+
self.assertEqual(result.clients[0].state, AuthorizedClientState.PENDING)
88+
89+
async def test_camel_case_entry_fields_are_recognized(self) -> None:
90+
site = _make_site(
91+
{
92+
"response": {
93+
"authorized_clients": [
94+
{
95+
"publicKey": PUBLIC_KEY_B64,
96+
"authorized_client_state": 3,
97+
}
98+
]
99+
}
100+
}
101+
)
102+
103+
result = await site.find_authorized_clients()
104+
105+
self.assertEqual(len(result.clients), 1)
106+
matched = result.clients[0]
107+
self.assertEqual(matched.public_key, PUBLIC_KEY_B64)
108+
self.assertEqual(matched.state, AuthorizedClientState.VERIFIED)
109+
110+
async def test_state_as_string_is_typed_via_enum(self) -> None:
111+
site = _make_site(
112+
{
113+
"response": {
114+
"authorized_clients": [
115+
{"public_key": PUBLIC_KEY_B64, "state": "verified"}
116+
]
117+
}
118+
}
119+
)
120+
121+
result = await site.find_authorized_clients()
122+
123+
self.assertEqual(result.clients[0].state, AuthorizedClientState.VERIFIED)
124+
125+
async def test_unrecognized_present_state_is_preserved_not_dropped(self) -> None:
126+
site = _make_site(
127+
{
128+
"response": {
129+
"authorized_clients": [{"public_key": PUBLIC_KEY_B64, "state": 0}]
130+
}
131+
}
132+
)
133+
134+
result = await site.find_authorized_clients()
135+
136+
# 0 is not a member of AuthorizedClientState, but it is a present
137+
# value - it must not be coerced to None (which means "absent").
138+
self.assertEqual(result.clients[0].state, 0)
139+
self.assertIsNotNone(result.clients[0].state)
140+
141+
async def test_explicitly_empty_list_returns_typed_empty_list(self) -> None:
142+
site = _make_site({"response": {"authorized_clients": []}})
143+
144+
result = await site.find_authorized_clients()
145+
146+
self.assertEqual(result.clients, [])
147+
148+
async def test_absent_field_returns_typed_empty_list(self) -> None:
149+
site = _make_site({"response": {"foo": "bar"}})
150+
151+
result = await site.find_authorized_clients()
152+
153+
self.assertEqual(result.clients, [])
154+
155+
async def test_null_body_returns_typed_empty_list_without_raising(self) -> None:
156+
site = _make_site(None)
157+
158+
result = await site.find_authorized_clients()
159+
160+
self.assertEqual(result.clients, [])
161+
self.assertIsNone(result.raw)
162+
163+
async def test_non_dict_entries_are_skipped(self) -> None:
164+
site = _make_site(
165+
{
166+
"response": {
167+
"authorized_clients": [
168+
"not-a-dict",
169+
{"public_key": PUBLIC_KEY_B64, "state": 3},
170+
]
171+
}
172+
}
173+
)
174+
175+
result = await site.find_authorized_clients()
176+
177+
self.assertEqual(len(result.clients), 1)

0 commit comments

Comments
 (0)