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 @@ -146,7 +146,7 @@ Keep the `tesla-protocol` floor at `>=0.5.0`; earlier releases have generated `.
- **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), `media_volume_up` (no Tesla REST endpoint — BLE-only; cloud raises volume via `adjust_volume`), and `clear_pin_to_drive_admin`'s `pin` param (no proto field on `VehicleControlResetPinToDriveAdminAction` — cloud still sends it in the REST body, BLE ignores it). Both transports default `navigation_gps_request`'s `order` to `0` (`REMOTE_NAV_TRIP_ORDER_UNKNOWN`) when the caller omits it.
- **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 — `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 `_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`. `VehicleBluetooth`'s `verify_commands` resolution logs a second, separate line (`verify_commands=resolved`/`unresolved`). `Router._dispatch` (`router/base.py`) logs `command=... backend=<ClassName> result=...` per backend tried. 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) 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`.
- **Typed accessor pattern for undocumented raw-dict responses**: `TeslemetryEnergySite.find_authorized_clients()` and `find_gateway_address()` (`teslemetry/energysite.py`) are frozen-dataclass typed wrappers over raw `dict[str, Any]`/`None`/`list` REST responses, so API-parsing logic (envelope unwrap, field lookup, shape validation, enum typing) lives in the library instead of each consumer reimplementing it. Any future typed accessor over an undocumented response shape should keep two rules: (1) field lookup must check key presence (`key in payload`), never `payload.get(key) or default` — a legal falsy value is not "missing"; (2) a `None` body and an unrecognized response shape are malformed data, not "empty" — raise `InvalidResponse` (`exceptions.py`) rather than collapsing to an empty/default result; only a genuinely well-formed-but-empty response should parse to an empty result without raising. `find_authorized_clients()`'s envelope unwrap accepts `{"response": {"authorized_clients": [...]}}` or `{"response": {"clients": [...]}}`, or a bare list. `find_gateway_address()` decodes `networking_status.ipv4_config.address` as a raw big-endian uint32 (`struct.pack(">I", ...)`, not little-endian), considers only `eth`/`wifi` (never `gsm`), preferring whichever has `active_route` set and a decodable address; `0`/`0xFFFFFFFF` are treated as undecodable. Tesla has not published an OpenAPI schema for these endpoints, so `const.py`'s enums are the schema of record; widen modeled fields only against a further live sample, not speculatively. Untyped escape-hatch methods (e.g. `list_authorized_clients()`) remain available alongside. Tests: `tests/test_teslemetry_authorized_clients.py`, `tests/test_teslemetry_gateway_address.py`.
- **Typed accessor pattern for undocumented raw-dict responses**: `TeslemetryEnergySite.find_authorized_clients()` and `find_gateway_address()` (`teslemetry/energysite.py`) are frozen-dataclass typed wrappers over raw `dict[str, Any]`/`None`/`list` REST responses, so API-parsing logic (envelope unwrap, field lookup, shape validation, enum typing) lives in the library instead of each consumer reimplementing it. Any future typed accessor over an undocumented response shape should keep two rules: (1) field lookup must check key presence (`key in payload`), never `payload.get(key) or default` — a legal falsy value is not "missing"; (2) a `None` body and an unrecognized response shape are malformed data, not "empty" — raise `InvalidResponse` (`exceptions.py`) rather than collapsing to an empty/default result; only a genuinely well-formed-but-empty response should parse to an empty result without raising. `find_authorized_clients()`'s envelope unwrap accepts `{"response": {"authorized_clients": [...]}}` or `{"response": {"clients": [...]}}`, or a bare list. `find_gateway_address()` decodes `networking_status.ipv4_config.address` as either a raw big-endian uint32 (`struct.pack(">I", ...)`, not little-endian) or a dotted-quad string — the API has been observed serving both forms — considers only `eth`/`wifi` (never `gsm`), preferring whichever has `active_route` set and a decodable address; `0`/`0xFFFFFFFF` (and their string equivalents) are treated as undecodable. Tesla has not published an OpenAPI schema for these endpoints, so `const.py`'s enums are the schema of record; widen modeled fields only against a further live sample, not speculatively. Untyped escape-hatch methods (e.g. `list_authorized_clients()`) remain available alongside. Tests: `tests/test_teslemetry_authorized_clients.py`, `tests/test_teslemetry_gateway_address.py`.
- **`_stream_sinks` peels subscription pushes off the command-reply queue before routing**: a `vehicleDataSubscription`'s pushes arrive addressed to us on the same domain queue (`_queues`) an ordinary command's reply uses, correlated by the subscribe request's own `request_uuid`. `_on_message` (`bluetooth.py`) checks `self._stream_sinks.get(msg.request_uuid)` before touching `_queues` — a match routes into that subscription's own bounded, drop-oldest `_StreamSink` instead, so `_send`'s pre-send drain can never discard a push and `_await_response` can never return one as an unrelated command's reply. `_register_stream_sink`/`_unregister_stream_sink` are the only entry points into the registry; there is no public subscription API yet. Tests: `tests/test_ble_stream_sink.py`.
- **`VehicleAction`/`GetVehicleData` proto coverage is locked by test, not just by convention**: `tests/test_proto_coverage_lock.py` walks both descriptors and fails if any field has no wrapper (`commands.py`) or reader (`bluetooth.py`) and isn't on one of its two small, reasoned allowlists — keep that test in sync with any future `tesla-protocol` bump rather than special-casing new fields elsewhere. The only fields deliberately left unwrapped today are the 7-field push-style subscription/streaming family (`createStreamSession`/`streamMessage`/`vehicleDataSubscription`/`vehicleDataAck`/`vitalsSubscription`/`vitalsAck`/`cancelVehicleDataSubscription`, which need a public lifecycle/iterator API atop the private `_stream_sinks` routing above) and `getVehicleImageState` (needs chunked binary-transfer paging). CarServer's `GetVehicleState` sub-state is exposed as `legacy_vehicle_state()` (`bluetooth.py`), matching the `VehicleData.legacy_vehicle_state` reply field name, to avoid confusion with `vehicle_state()` (VCSEC `VehicleStatus`, a different message/domain). `set_rate_tariff`/`add_managed_charging_site` (`commands.py`) take `tesla_protocol` message types directly for their deeply-nested arguments rather than a parallel flattened dataclass API.
- **Energy-gateway authorized-client pairing has security- and protocol-specific constraints**: use RSA for LAN TEDapi v1r, treat `PENDING_VERIFICATION_TIMEOUT` as terminal, and account for presence-free key removal. The authoritative pairing, retry, encoding, and removal guidance is in `docs/energy_local_control.md`; enum values and API contracts live in `const.py` and the relevant method docstrings.
Expand Down
7 changes: 4 additions & 3 deletions docs/teslemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,9 +596,10 @@ Teslemetry energy sites expose the raw `get_networking_status` command, plus
a typed `find_gateway_address` helper that discovers the gateway's LAN IPv4
address - for example to pre-fill the host for the signed local control path
shown in [Energy: Local Control](energy_local_control.md). The `ipv4_config`
fields in a `networking_status` response are raw big-endian uint32 integers,
not dotted-quad strings; the helper decodes them and selects an interface for
you. Only the `eth` and `wifi` interfaces are considered (never `gsm` -
fields in a `networking_status` response have been observed as either raw
big-endian uint32 integers or dotted-quad strings; the helper decodes either
form and selects an interface for you. Only the `eth` and `wifi` interfaces
are considered (never `gsm` -
cellular is not a LAN path): the helper prefers whichever has `active_route`
set and a decodable address, then falls back to the first of the two (in
`eth`, `wifi` order) with any decodable address. A null response body or an
Expand Down
7 changes: 5 additions & 2 deletions tesla_fleet_api.egg-info/PKG-INFO
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: tesla_fleet_api
Version: 1.7.7
Version: 1.10.0
Summary: Tesla Fleet API library for Python
Author-email: Brett Adams <hello@teslemetry.com>
License-Expression: Apache-2.0
Expand Down Expand Up @@ -225,7 +225,10 @@ 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.
per-domain broadcast messages. Use `listen_connection_status(callback)` for
`True`/`False` BLE session transition notifications. See
[Bluetooth for Vehicles](docs/bluetooth_vehicles.md#connection-status-events)
for the connection-event contract.

### Routing and Failover

Expand Down
4 changes: 4 additions & 0 deletions tesla_fleet_api.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ tests/test_ble_broadcast_confirmation.py
tests/test_ble_broadcast_listeners.py
tests/test_ble_charging_commands.py
tests/test_ble_charging_utility_commands.py
tests/test_ble_client_binding.py
tests/test_ble_climate_commands.py
tests/test_ble_command_verification.py
tests/test_ble_confirmation_mode.py
tests/test_ble_connect_retry_budget.py
tests/test_ble_connection_status.py
tests/test_ble_connectivity_diagnostics_commands.py
tests/test_ble_destructive_commands.py
Expand Down Expand Up @@ -98,6 +100,8 @@ tests/test_tariff.py
tests/test_tesla_private_key.py
tests/test_teslemetry_authorized_clients.py
tests/test_teslemetry_gateway_address.py
tests/test_teslemetry_register_client.py
tests/test_teslemetry_vehicle_custom_commands.py
tests/test_teslemetry_wait_until_paired.py
tests/test_tessie_vehicle_params.py
tests/test_vehicle_image_state.py
Expand Down
52 changes: 36 additions & 16 deletions tesla_fleet_api/teslemetry/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import base64
import re
import socket
import struct
from collections.abc import Awaitable, Callable
Expand Down Expand Up @@ -207,26 +208,45 @@ def _parse_authorized_clients(payload: Any) -> AuthorizedClients:

_GATEWAY_INTERFACES = ("eth", "wifi")

_DOTTED_QUAD_OCTET = r"(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
_DOTTED_QUAD_RE = re.compile(
rf"^{_DOTTED_QUAD_OCTET}\.{_DOTTED_QUAD_OCTET}\.{_DOTTED_QUAD_OCTET}\.{_DOTTED_QUAD_OCTET}$"
)


def _decode_ipv4(value: Any) -> str | None:
"""Decode a raw big-endian uint32 into dotted-quad form.

``ipv4_config.address``/``subnet_mask``/``gateway`` in a
``networking_status`` response are network-byte-order uint32 integers,
not strings - confirmed against a live Powerwall 3 capture where
``3232235914`` decodes to ``192.168.1.138``. ``bool`` is excluded since
it subclasses ``int``; an out-of-range or non-int value returns
``None`` rather than raising, since a single bad address shouldn't
fail the whole lookup. ``0`` and ``0xFFFFFFFF`` are also rejected -
``0.0.0.0``/``255.255.255.255`` are never a usable host address, and an
unconfigured interface reporting ``address: 0`` must not shadow a real
address on another interface in the fallback selection.
"""Decode a ``networking_status`` ipv4 field into dotted-quad form.

The live API serves ``ipv4_config.address``/``subnet_mask``/``gateway``
as a dotted-quad string directly (confirmed live 2026-08-22) - the
uint32 network-byte-order int form (e.g. ``3232235914`` decodes to
``192.168.1.138``) is also accepted, defensively/for backward
compatibility, not because it was ever the primary wire format. ``bool``
is excluded since it subclasses ``int``; a malformed string, out-of-range
int, or unsupported type returns ``None`` rather than raising, since a
single bad address shouldn't fail the whole lookup. The string form must
be a strict dotted-quad (exactly four 0-255 decimal octets, no leading
zeros beyond a bare ``0``, no surrounding whitespace, no hex/octal/bare-
decimal forms) - anything looser is treated as malformed and returns
``None``. ``0.0.0.0`` and ``255.255.255.255`` are rejected in either
representation - never a usable host address, and an unconfigured
interface reporting the all-zero form must not shadow a real address on
another interface in the fallback selection.
"""
if not isinstance(value, int) or isinstance(value, bool):
return None
if not 0 < value < 0xFFFFFFFF:
address: str | None = None
if isinstance(value, int) and not isinstance(value, bool):
if not 0 < value < 0xFFFFFFFF:
return None
address = socket.inet_ntoa(struct.pack(">I", value))
elif isinstance(value, str):
if not _DOTTED_QUAD_RE.fullmatch(value):
return None
address = value
if address in ("0.0.0.0", "255.255.255.255"):
return None
else:
return None
return socket.inet_ntoa(struct.pack(">I", value))
return address


def _interface_address(interface: Any) -> str | None:
Expand Down
Loading
Loading