Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te
- **Cross-transport parity (cloud REST `VehicleFleet` vs BLE `Commands`)**: the same-named command on both paths should build a semantically equivalent instruction from identical args - a divergence there is a bug, but response *bodies* legitimately differ (REST JSON dict vs decoded protobuf) and are not. `tests/test_cross_transport_parity.py` locks the equivalence in with mocked-both-transports tests. Known **non-bug FORM differences** (do not "fix"): `set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` (no proto fields), `window_control` lat/lon and `navigation_sc_request` `id` (no proto fields), `navigation_request`'s `type`/`locale`/`timestamp_ms` (REST share-intent framing), and `media_volume_up` (no Tesla REST endpoint - BLE-only; cloud raises volume via `adjust_volume`). Two **open divergences left unfixed** pending live verification: `clear_pin_to_drive_admin` builds `DrivingClearSpeedLimitPinAction` (speed-limit PIN, not PIN-to-Drive - suspected mismapping, security-sensitive), and `navigation_gps_request`'s `order` is required on BLE but optional on cloud (signature mismatch; null-order wire semantics undecided).
- **Per-command debug logging chokepoints and the `command=` name it derives**: `LOGGER.debug` lines of the form `command=<name> transport=<t> result=...` are emitted from exactly four places, not per-method - `Commands._sendVehicleSecurity`/`_getVehicleSecurity`/`_sendInfotainment`/`_getInfotainment` (`commands.py`, covers both BLE and Fleet-signed) and `TeslaFleetApi._request` (`fleet.py`, covers Fleet/Teslemetry/Tessie REST). `transport` comes from a `_transport_name` `ClassVar` set per concrete class (`"bluetooth"`/`"fleet"`/`"teslemetry"`/`"tessie"`), mirroring the existing `_auth_method` pattern - add that ClassVar to any new `Commands`/`TeslaFleetApi` subclass. For BLE/Fleet-signed, `command` is **not** the Python method name; it's derived from the populated protobuf oneof field (`vcsec_command_name`/`infotainment_command_name` in `commands.py`), e.g. `door_lock()` logs as `RKE_ACTION_LOCK` and `set_charge_limit()` as `chargingSetLimitAction` - deliberately robust to call-site changes since it reads the message being sent, not the call stack. `VehicleBluetooth`'s `verify_commands` resolution logs a second, separate line (`verify_commands=resolved`/`unresolved`) rather than duplicating the base class's raw-attempt line. `Router._dispatch` (`router/base.py`) logs `command=... backend=<ClassName> result=...` per backend tried, independent of the above. See `docs/bluetooth_vehicles.md`'s "Troubleshooting: Enable Debug Logging" section for the user-facing format; `tests/test_command_logging.py` locks in the exact line shapes.
- **`_log_request_result` (`fleet.py`) must tolerate any JSON-legal REST body, not just dicts**: it runs after the HTTP request already succeeded, so it's a logging convenience only - a non-dict body (`null`, a list, a bare scalar; live case: Teslemetry's `list_authorized_clients` returning `null`) must never raise there. It guards with `isinstance(data, dict)` before calling `.get()`, logging `result=success` and returning for anything else. Regression tests in `tests/test_command_logging.py` (`test_null_json_body_returns_none_without_raising` etc.) cover null/list/scalar bodies.
- **Typed accessor pattern for undocumented raw-dict responses**: `TeslemetryEnergySite.find_authorized_clients()` (`teslemetry/energysite.py`) is the library's first frozen-dataclass typed wrapper over a raw `dict[str, Any]`/`None`/`list` REST response, added so API-parsing logic (envelope unwrap, field lookup, 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

Expand Down
8 changes: 5 additions & 3 deletions docs/teslemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion tesla_fleet_api.egg-info/PKG-INFO
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -286,6 +296,8 @@ Command log lines use `transport=bluetooth`, `fleet`, `teslemetry`, or `tessie`.
Routers also emit `backend=<ClassName>` 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

Expand Down
3 changes: 3 additions & 0 deletions tesla_fleet_api.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
34 changes: 17 additions & 17 deletions tesla_fleet_api/teslemetry/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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")
Expand All @@ -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)
Expand Down
Loading
Loading