Skip to content

Latest commit

 

History

History
156 lines (115 loc) · 46.1 KB

File metadata and controls

156 lines (115 loc) · 46.1 KB

Project Overview

Python library (tesla_fleet_api) providing async interfaces for Tesla Fleet API, Teslemetry, and Tessie services, plus BLE communication. Published to PyPI as tesla-fleet-api.

Development Commands

# Install dependencies
uv sync

# Type checking (strict mode)
uv run pyright tesla_fleet_api

# Linting
uv run ruff check tesla_fleet_api
uv run ruff format tesla_fleet_api

# Tests
uv run pytest tests

Tests live in tests/ and use unittest.IsolatedAsyncioTestCase (collected and run natively by pytest — pytest-asyncio is not required).

BLE command tests over a mocked transport build on tests/ble_mocked_transport.py (MockedBleTransportTestCase): it patches VehicleBluetooth._send and pre-marks both signed-command sessions ready, so a test drives any inherited Commands method with no real BLE/GATT connection and asserts on the signed RoutableMessage built (decrypt_sent_command) and on canned replies (vcsec_ok_reply/infotainment_action_ok_reply/infotainment_vehicle_data_reply). See tests/test_ble_mocked_commands.py for worked examples.

API References

Architecture

Class Hierarchy

Three API client classes all inherit from TeslaFleetApi:

Tesla (base - tesla/tesla.py)
  └── TeslaFleetApi (tesla/fleet.py) - core HTTP client with _request(), access_token handling
        ├── TeslaFleetOAuth (tesla/oauth.py) - adds OAuth flow (login URL, token refresh)
        ├── Teslemetry (teslemetry/teslemetry.py) - fixed server, Teslemetry-specific endpoints
        └── Tessie (tessie/tessie.py) - fixed server, Tessie-specific endpoints

Tesla base holds EC key management for signed commands. TeslaFleetApi provides the _request() method used by all submodules.

Vehicle Command Layers

Vehicle commands have three implementations sharing the same method signatures, selected by how you create the vehicle:

Vehicle (vehicle/vehicle.py) - base with VIN and model detection
  └── VehicleFleet (vehicle/fleet.py) - REST API commands (unsigned)
        └── VehicleSigned (vehicle/signed.py) - signed command protocol via Fleet API
Commands (vehicle/commands.py) - protobuf-based signed command implementation (ABC)
  └── VehicleSigned - multiple inheritance: Commands + VehicleFleet
  └── VehicleBluetooth (vehicle/bluetooth.py) - BLE transport for signed commands

VehicleSigned uses multiple inheritance: Commands for signed command logic, VehicleFleet for data endpoints and fallback.

Router (router.py) is an entity-agnostic composition wrapper (not part of the inheritance chain) that chains an ordered list of two-or-more backends sharing a common method surface and dispatches each method call down the chain with automatic per-command failover: it tries the first backend that has the method and, on any exception except BluetoothUnconfirmedCommand, retries the same call on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails, AttributeError only if none has the method). Non-callable attributes resolve to the first backend that has them. The constructor is Router(primary, secondary, *more_backends, health=None) — the two-argument form is fully backward compatible. The health check (bool | sync callable | async callable returning bool; omitted = attempt primary, fail over on exception with no probe) gates only the primary (the first backend); the rest of the chain is reached purely through per-command failover — there is deliberately no per-backend health matrix. Note the double-execution caveat: a non-idempotent command that fails mid-flight can be re-run on the next backend, except for BluetoothUnconfirmedCommand, which propagates without replay.

VehicleRouter and EnergySiteRouter are thin entity-specific subclasses of Router. VehicleRouter(bluetooth_primary, teslemetry_secondary) pairs a VehicleBluetooth primary with a cloud (TeslemetryVehicle) secondary; EnergySiteRouter(local_energysite, teslemetry_energysite) pairs a duck-typed local EnergySite-shaped object (e.g. aiopowerwall's PowerwallEnergySite, no dependency added) with a cloud TeslemetryEnergySite fallback. All three live in the top-level router/ package — Router (and shared helpers) in router/base.py, VehicleRouter in router/vehicle.py, EnergySiteRouter in router/energysite.py, re-exported from router/__init__.py (importable as tesla_fleet_api.router.Router etc.) and, for backward compatibility, also re-exported from tesla/__init__.py (tesla_fleet_api.tesla.Router). They have no factory on the Vehicles/EnergySites collections. This repo owns the RSA keypair lifecycle and cloud registration (Tesla.get_rsa_private_key, EnergySite.add_authorized_client) that aiopowerwall's local signed transport depends on but does not implement itself; see docs/energy_local_control.md for the end-to-end pairing + EnergySiteRouter composition flow. The cloud-only set_island_mode/go_off_grid/reconnect_grid (tesla/energysite.py) can only send an unsigned grpc_command, which gateways can acknowledge without actuating the contactor - rather than ship that as a silent no-op, they unconditionally raise SignedCommandRequired (exceptions.py); only the signed local path via add_authorized_client + EnergySiteRouter actually actuates, and a success response from that transport still doesn't prove the contactor moved - verify state after the call.

Vehicle Collections

Vehicles (vehicle/vehicles.py) is a dict[str, Vehicle] with factory methods:

  • createFleet(vin)VehicleFleet
  • createSigned(vin)VehicleSigned
  • createBluetooth(vin, confirmation="ack", keepalive_interval=..., raise_unconfirmed=False, *, verify_commands=None, optimistic=None)VehicleBluetooth

Teslemetry/Tessie override Vehicles with their own vehicle classes (TeslemetryVehicle, TessieVehicle) extending VehicleFleet with service-specific commands (e.g., closure(), seat_heater() for Teslemetry; wake(), lock() for Tessie).

Submodule Pattern

Each API client lazily attaches submodules in __init__ via class attributes on Tesla:

  • charging, energySites, user, partner, vehicles

Scope flags on TeslaFleetApi.__init__ control which submodules are instantiated.

Shared Utilities

util.py holds small, dependency-free helpers shared across the library, re-exported from the top-level package. firmware_compare(a, b) -> int compares dotted, numeric, week-based Tesla firmware version strings (e.g. 2025.14.3) correctly — plain string comparison misorders them ("2025.10" < "2025.9"). It returns 1/-1/0, right-pads shorter versions with zeros before comparing, and treats unparseable strings (e.g. "Unknown") as sorting behind any parseable version. firmware_at_least(firmware, minimum) -> bool is a thin wrapper (firmware_compare(firmware, minimum) >= 0) for the common "does this vehicle's firmware support feature X" gate — ported from Home Assistant core PR #175745, which fixed the same lexicographic bug in the teslemetry integration. Deliberately implemented as native tuple comparison rather than taking on an AwesomeVersion dependency, matching this library's narrow, purpose-built dependency list (no general-purpose version-parsing lib elsewhere).

Release Process

No release-please or version-bump automation. To ship: bump version in pyproject.toml and __version__ in tesla_fleet_api/__init__.py in a Bump version to X.Y.Z commit on main, then push a matching vX.Y.Z tag. .github/workflows/python-publish.yml runs on every push but only builds+publishes to PyPI (and creates a GitHub Release) when github.ref starts with refs/tags/ — pushing the tag is what actually ships the release; merging to main alone does not.

Error Handling

exceptions.py maps HTTP status codes and error keys to specific exception classes. raise_for_status() parses responses and raises the appropriate exception. Signed command faults have separate hierarchies: TeslaFleetInformationFault, TeslaFleetMessageFault, SignedMessageInformationFault, WhitelistOperationStatus.

All exceptions inherit from TeslaFleetError(BaseException), deliberately not Exception — a bare except Exception (e.g. in retry/backoff loops around BLE reads) silently fails to catch BluetoothTimeout and every other library error. Catch TeslaFleetError (or BaseException) explicitly. VehicleBluetooth wraps transport-layer failures (connect/connect_if_needed, notification setup) in BluetoothTransportError, a TeslaFleetError subclass chaining the original transport exception as its cause — so except TeslaFleetError alone now catches BLE transport failures too, not just response-wait BluetoothTimeout/BluetoothUnconfirmedCommand. The GATT write in _send is not unconditionally wrapped into BluetoothTransportError — see the write-delivery-certainty entry below for the split. These catch sites deliberately catch both bleak.exc.BleakError and builtin TimeoutError: bleak-esphome converts an aioesphomeapi GATT/connect/notify timeout into a bare TimeoutError (not a BleakError), which would otherwise escape the wrap as a non-TeslaFleetError.

Protobuf

Tesla protobuf bindings come from the published tesla-protocol PyPI package (Teslemetry/tesla-protocol, generated Python package tesla_protocol), not from vendored .proto/*_pb2 files in this repo — this library previously mirrored proto sources under proto/ and generated tesla_fleet_api/tesla/vehicle/proto/, but that duplication was retired once the upstream package became the shared source of truth (the API client adopted the npm sibling @teslemetry/tesla-protocol first). Import from tesla_protocol.command.<module>_pb2 (e.g. tesla_protocol.command.universal_message_pb2); the package also ships telemetry, energy_device, energy_command, and teslapower groups this library doesn't currently use. To pick up new/changed message definitions, bump the tesla-protocol version floor in pyproject.toml — there is no local regeneration step.

Runtime-version pin (Home Assistant compatibility). protobuf refuses to load gencode stamped newer than the installed runtime (gencode X > runtimeVersionError). Home Assistant core pins protobuf==6.32.0, so any tesla-protocol version this library depends on must stamp gencode ≤ 6.32.0 and declare a protobuf requirement compatible with ==6.32.0 — check both before bumping the floor. The protobuf>=6.32.0 floor in pyproject.toml must stay in sync with whatever tesla-protocol actually requires.

Keep the tesla-protocol floor at >=0.5.0; earlier releases have generated .pyi imports that are incompatible with this repository's strict pyright checks.

Code Style

  • Type checking: pyright strict mode. Use TYPE_CHECKING guards for circular imports.
  • Linting: ruff.
  • Async: All API methods are async. Uses aiohttp for HTTP, aiofiles for file I/O, bleak for BLE.
  • Enums: Custom StrEnum/IntEnum in const.py (not stdlib). Region is a Literal["na", "eu", "cn"], not an enum.
  • Seat indexing gotcha: two distinct seat enums with different conventions. Seat is 0-indexed (FRONT_LEFT=0) and is for the manual seat heater/cooler paths (remote_seat_heater_request, remote_seat_cooler_request). AutoSeat is 1-indexed (FRONT_LEFT=1, FRONT_RIGHT=2) and is the correct type for remote_auto_seat_climate_request on both backends — its values equal Tesla's REST wire values and the proto AutoSeatPosition_* enum. Don't mix them; passing a Seat to the auto-climate command is off-by-one (issue #11).
  • Naming: camelCase for class instance attributes that mirror API structure (energySites, createFleet). Snake_case for method names that are API endpoints.
  • BLE discovery gotcha: a Tesla vehicle advertises no 128-bit service UUID pre-connect — only its VIN-derived local name (^S[a-f0-9]{16}[CDRP]$), and only in the scan response, not the ADV_IND. SERVICE_UUID (tesla_fleet_api/tesla/vehicle/bluetooth.py) exists only as a GATT service after connecting. Never pass service_uuids=[SERVICE_UUID] as a BleakScanner discovery-time filter — it hides the vehicle on a direct BlueZ adapter (an ESPHome proxy doesn't enforce that filter the same way, which can mask the bug in testing). Scan unfiltered with active scanning and match by name; keep SERVICE_UUID for post-connect GATT use only.
  • BLE domain-routing gotcha: Domain (tesla_protocol.command.universal_message_pb2) has more values (DOMAIN_BROADCAST, DOMAIN_AUTHD, ...) than VehicleBluetooth._queues has keys (only DOMAIN_VEHICLE_SECURITY/DOMAIN_INFOTAINMENT). _on_message (tesla_fleet_api/tesla/vehicle/bluetooth.py) must look up _queues with .get() and drop unrecognized domains rather than indexing directly — indexing raises KeyError inside the ReassemblingBuffer callback, aborting reassembly of any further already-buffered messages in that notification.
  • BLE infotainment boot-delay gotcha: wake_up() (VCSEC) returns as soon as the vehicle-security computer acks it, well before the infotainment computer is ready to complete a signed-command handshake. An INFO-domain read/command issued immediately after wake_up() can raise BluetoothTimeout on the handshake through no fault of the command itself. Live-verified: waiting ~10s after wake_up() before the first INFO read is sufficient on the test rig; callers doing INFO work right after waking should retry-with-backoff rather than treat one timeout as failure.
  • BLE vehicle_data() response-size cap: the vehicle's signed-command implementation enforces its own response-size limit independent of the BLE transport's packet reassembly. Live-verified: a single-endpoint vehicle_data() call (or any of the dedicated per-substate readers like charge_state()) succeeds, but requesting as few as two BluetoothVehicleData endpoints together reliably raises TeslaFleetMessageFaultResponseSizeExceedsMTU (exceptions.py). This is why vehicle_data()'s endpoints arg has no all-endpoints default (unlike the cloud method) - prefer the per-substate readers, or a single-endpoint vehicle_data() call, over a multi-endpoint composite. Auto-chunking (split under the cap, merge replies) would fix this properly but is not implemented.
  • BLE individual-door powered-close gotcha: open_*_door() unlatches a door over VCSEC; on a Model 3 there is no reliable powered close. Live-verified: close_rear_passenger_door() returned an OK ack ({"result": True}) but closures_state().door_open_passenger_rear stayed True - the ack only means the car accepted the command, not that the door physically re-latched (a human has to push it shut). Never chain an automated snapshot→act→verify→restore cycle across an individual door-open command; treat the 8 door commands as ack-verified only, or require a human to confirm the physical re-close before trusting closures_state() again.
  • remote_heater_control_enabled gate: climate_state().remote_heater_control_enabled is a read-only vehicle-side setting (no command exists to flip it) that gates every "remote comfort" action - live-verified on commands.py: remote_seat_heater_request, remote_auto_seat_climate_request, remote_steering_wheel_heater_request, remote_steering_wheel_heat_level_request, remote_auto_steering_wheel_heat_climate_request. With it false, the vehicle ACKs {"result": false, "reason": "cabin comfort remote settings not enabled"} and leaves state untouched (not a library bug, not a partial mutation) - this is presumably the touchscreen/app "Remote Climate" or comfort-access toggle, outside this library's command surface. Check this field before treating a comfort-command rejection as a regression.
  • Protobuf oneof-by-string-kwargs bypasses pyright: remote_seat_heater_request/remote_seat_cooler_request (commands.py) build their HvacSeatHeaterAction/HvacSeatCoolerAction via a dict of literal field-name strings expanded as **kwargs into the message constructor (with a # pyright: ignore[reportUnknownArgumentType] already on the call) - a typo in one of those strings (e.g. SEAT_HEATER_MEDIUM vs. the proto's actual SEAT_HEATER_MED, fixed live during PR-4) raises at call time, not at type-check time. Cross-check any new field-name string against tesla_protocol.command.car_server_pb2 rather than trusting pyright to catch it.
  • scheduled_charging_mode is tri-state and shared: set_scheduled_charging and set_scheduled_departure (commands.py) both write the same ChargeState.scheduled_charging_mode (Off/StartAt/DepartBy) - live-verified. Disabling one when the other is active turns the whole feature Off rather than leaving the other's config intact; a caller toggling one must read charge_state() first and restore the exact prior mode (including the other command's fields) rather than assuming independence.
  • set_scheduled_departure's preconditioning_enabled/off_peak_charging_enabled args are dead: live-verified - ScheduledDepartureAction (tesla_protocol.command.car_server_pb2) has no fields for them, only preconditioning_times/off_peak_charging_times (weekday-recurrence only, no on/off). Passing preconditioning_enabled=False has no effect on the vehicle's observed state. Not a library bug to "fix" without a wider protocol capability; document, don't rely on these args to gate the feature.
  • charge_standard() rejects already_standard: live-verified - calling it while charge_state().charge_limit_soc already equals charge_limit_soc_std gets {"result": False, "reason": "already_standard"} rather than a no-op success. Not a library bug; callers/tests exercising this command need the limit to actually differ from the std preset first (e.g. via charge_max_range() or set_charge_limit()).
  • BLE media state-observability gotcha: MediaState.now_playing_artist/title and all of MediaDetailState (now_playing_album/station/source_string/elapsed/duration) were observed empty/zero on the test car while Spotify was actively Playing at nonzero volume - these legacy fields are apparently only populated for certain sources (e.g. USB/Bluetooth), not Spotify. Don't assume media_next_track/media_prev_track/media_next_fav/media_prev_fav are state-observable via these readers; verify by ACK ({"result": True}) and pair with the inverse command when the fingerprint doesn't change. audio_volume/media_playback_status (for adjust_volume/media_volume_up/media_volume_down/media_toggle_playback) were populated correctly and are reliable provers.
  • BLE mutating-command timeout is inconclusive - never assume "the write didn't land": this corrects an earlier version of this note, which observed adjust_volume write timeouts on one rig leaving the car unmutated and concluded a write timeout means the write never landed. Later live testing disproved that as a general rule: door_unlock and door_lock each raised a timeout yet both physically executed - a VCSEC state read after each confirmed the lock state had flipped. Reads (state readers, wake_up()'s effect) came back reliably all night; only mutating VCSEC/RKE actions showed this false-negative pattern, most likely because the vehicle doesn't reliably return an ack _send() observes within the timeout, not because the write failed. Treat BluetoothUnconfirmedCommand (a BluetoothTimeout subclass) from any mutating BLE command as inconclusive, not failure: snapshot state before acting, then verify the outcome with a follow-up state read whenever a mutation times out. Never blind-retry a non-idempotent command (toggles like media_toggle_playback, volume steps, schedule add/remove) on timeout alone - see the retry-double-execution entry below for why the library's own retry has the same exposure. The adjust_volume/TimeoutAPIError root cause from the original observation is still unexplained; it just isn't evidence that timed-out writes never land. VCSEC actuations now use the shorter _actuation_timeout when their terminal ack is lost.
  • The BLE mutating-command confirmation ladder is one confirmation enum + one raise_unconfirmed bool: VehicleBluetooth.confirmation ("optimistic" | "ack" | "verify", default "ack"; threaded through Vehicles/VehiclesBluetooth.create*) picks how many of write → ack-or-broadcast wait → state-read confirmation run; raise_unconfirmed (default False) picks what happens when the ladder still can't tell. "optimistic" short-circuits _sendVehicleSecurity/_sendInfotainment (bluetooth.py) to _send_optimistic(), which signs and writes but never waits for any reply - a provably pre-submission write failure still raises BluetoothTransportError unconditionally, but a submitted-then-ambiguous write (see the write-delivery-certainty entry below) follows raise_unconfirmed like every other rung instead of being consulted as "nothing else." "verify" adds a post-timeout state-read rung: on an unresolved ack/broadcast wait, _resolve_timeout() reads the mapped prover state (_vcsec_verify_plan/_INFOTAINMENT_VERIFY_PLANS in bluetooth.py; only clearly-derivable absolute commands are covered - lock/unlock, set_charge_limit, set_charging_amps, adjust_volume absolute, set_temps, auto_conditioning_start/stop) and returns success on a match, raises BluetoothCommandFailed on a proven mismatch, or returns None (still unresolved) if the read itself couldn't complete - None falls through to raise_unconfirmed. Commands with no plan (true toggles, relative steps, ack-only actions) always fall through regardless of confirmation. The legacy optimistic/verify_commands boolean surface is deprecated: both warn (DeprecationWarning) and map onto confirmation (a positional bool in the confirmation slot is treated as old verify_commands; dominance order preserved - optimistic=True wins if both are set), and remain as read-only properties (confirmation == "optimistic"/"verify") for existing readers. See docs/bluetooth_vehicles.md for the user-facing table and defaults.
  • Broadcast-as-confirmation races the ack wait for lock/unlock: the vehicle keeps emitting unsolicited VCSEC status broadcasts on the same notification subscription even when it emits no addressed ack for a lock/unlock actuation - live-verified at scale (see the timeout-rate study referenced from docs/bluetooth_vehicles.md). _send's confirm_broadcast param (threaded through Commands._command/_sendVehicleSecurity, ignored by the Fleet-signed transport) arms a per-domain watcher in _on_message (_broadcast_watchers, bluetooth.py) that decodes broadcast frames via _decode_vcsec_status and races them against the addressed-reply wait in _await_response_or_broadcast; first to satisfy the plan's predicate wins, and only the addressed-reply path can raise a car-side rejection. A mismatching broadcast doesn't fail fast (it's appended to mismatches, not resolved) since a later broadcast in the same window could still confirm success - but if the whole window elapses with a mismatch as the last word and nothing else confirming, _await_response_or_broadcast raises BluetoothCommandFailed instead of the ambiguous timeout. This reuses the exact same _vcsec_verify_plan predicate as the "verify" rung above (one source of truth), applied to a broadcast's decoded VehicleStatus instead of a follow-up read; it currently covers only lock/unlock, the one VCSEC actuation with an observed status broadcast. Low-level race tests drive the real (unmocked) _send state machine directly - see tests/test_ble_broadcast_confirmation.py.
  • Persistent broadcast listeners (tesla_fleet_api/tesla/vehicle/broadcast.py): VehicleBluetooth fans the same VCSEC status broadcasts out to long-lived per-field listeners, not just the one-shot confirmation ladder above - one source of broadcast truth, dispatched from the same _on_message. Each modeled VehicleStatus leaf field gets a typed listen_<field> method (listen_vehicle_lock_state, listen_vehicle_sleep_status, listen_user_presence, the 8 door/trunk/charge-port/tonneau closure listeners, listen_tonneau_percent_open); uiDesire/gear (added to VehicleStatus by the app-recovered proto sync) have no typed listener yet, and anything not decoded into VehicleStatus (other VCSEC payloads like CommandStatus/whitelist/faults, and any future infotainment-domain broadcast - none observed today) also has none - all of these are covered by the untyped listen_broadcast(domain, callback), which receives every raw unsolicited broadcast for that domain including VehicleStatus. Closure/tonneau-percent listeners gate on HasField since those are submessages with real proto3 presence tracking; the three scalar enum fields (vehicleLockState/vehicleSleepStatus/userPresence) have none, so they fire on every status broadcast rather than only on change. Each listen_* returns an unsubscribe() closure; registries live for the VehicleBluetooth instance's lifetime and are unaffected by reconnects, matching _queues. Listener callback exceptions are logged and isolated from later listeners/message routing, except KeyboardInterrupt/SystemExit. Not consumed by Home Assistant yet - HA gets state via Teslemetry streaming already. See docs/bluetooth_vehicles.md and tests/test_ble_broadcast_listeners.py.
  • BluetoothUnconfirmedCommand vs BluetoothCommandFailed (exceptions.py), and how Router treats each: _sendVehicleSecurity/_sendInfotainment (bluetooth.py, the mutating-command seam, unconditionally) wrap a caught BluetoothTimeout into BluetoothUnconfirmedCommand when the ladder is genuinely unresolved - either the write succeeded but the ack/broadcast was lost, or the write entered backend I/O and failed with delivery unprovable, so the vehicle may have executed the command. With default raise_unconfirmed=False that unresolved outcome returns best-effort success; with raise_unconfirmed=True the BluetoothUnconfirmedCommand reaches the caller. BluetoothCommandFailed is the other, distinct outcome: a state check (the "verify" rung's read, or a mismatching broadcast still standing at window-end) actively proved the command did not apply - it deliberately does not subclass BluetoothTimeout/BluetoothUnconfirmedCommand. Router._dispatch (router/base.py) special-cases only BluetoothUnconfirmedCommand to skip its normal per-command failover and re-raise immediately - replaying an already-possibly-executed command risks double-execution. BluetoothCommandFailed carries no such risk (the command is proven not to have applied), so it falls through Router's ordinary except (Exception, TeslaFleetError) clause and fails over like any other error - no Router code change was needed for this, only the exception type's placement outside the BluetoothTimeout hierarchy. A plain read (_getVehicleSecurity/_getInfotainment) still raises unadorned BluetoothTimeout on the same kind of wait timeout, since a read has no side effect to be unconfirmed about, and a provably pre-submission write failure still raises the unrelated BluetoothTransportError (see the write-delivery-certainty entry below - a submitted-then-ambiguous write is not this case).
  • Write-delivery certainty splits BluetoothTransportError from BluetoothTimeout at the GATT write in _send: write_gatt_char failures are not uniformly BluetoothTransportError. BleakCharacteristicNotFoundError (bleak resolves WRITE_UUID synchronously, before any backend I/O) is the only case provably pre-submission, so it alone stays BluetoothTransportError and is safe for Router to retry. Every other BleakError/TimeoutError from that call happens inside backend I/O (D-Bus/CoreBluetooth/an ESPHome proxy) where delivery can't be proven either way - field data measured 2 of 3 such write failures had already executed on the vehicle - so _send instead races any already-armed broadcast watcher for the rest of the window (a matching broadcast can still confirm success despite the failed write) and, failing that, raises plain BluetoothTimeout. Because BluetoothUnconfirmedCommand subclasses BluetoothTimeout, this lands in the exact same except BluetoothTimeout ladder in _sendVehicleSecurity/_sendInfotainment as a lost post-write ack, with no separate exception type needed; _send_optimistic gets the equivalent treatment explicitly since it bypasses that ladder (see above). A read is unaffected - _getVehicleSecurity/_getInfotainment don't override the ladder, so a write-ambiguous read propagates plain BluetoothTimeout and Router fails over on it normally, which is safe since a read has no double-execution risk. Tests: tests/test_ble_send_transport.py (SendTransportErrorTests), tests/test_ble_broadcast_confirmation.py (WriteFailureBroadcastRaceTests), tests/test_ble_write_timeout_router.py.
  • wake_up() is best-effort; confirm readiness with an INFO read: wake_up() is a VCSEC actuation, so a terminal ack now returns promptly when observed, but an unresolved wake remains only an inconclusive wake signal, not command failure (BluetoothUnconfirmedCommand when raise_unconfirmed=True, best-effort success by default). Confirm readiness by retrying a cheap INFO read instead (see the boot-delay gotcha above for why the first INFO read still needs its own retry/backoff). Hold one connection across a whole batch of related commands rather than reconnecting between each - reconnecting costs ~123% more per operation with no demonstrated wake-preservation benefit from the connection alone.
  • The signed-command retry in Commands._command can double-execute a mutating command: on an OPERATIONSTATUS_WAIT reply or an INCORRECT_EPOCH/INVALID_TOKEN fault, _command (commands.py) re-signs and re-sends the identical command, bounded at 3 attempts then a clean {"result": False, "reason": "Too many retries"} - the cap itself is safe and doesn't loop. Live-verified deterministically: a WAIT-then-OK sequence produces 2 physical sends of the same command on the wire. Combined with the mutating-timeout-is-inconclusive gotcha above (a command can execute despite a WAIT/fault reply), this retry is a latent double-apply window. Harmless for a naturally idempotent command (lock/unlock), a real correctness risk for toggles and step commands (media_toggle_playback, media_volume_up/down, schedule add/remove) - verify those by absolute state after the call, never by counting invocations or trusting the retry to be safe.
  • expects_data splits BLE reply-waiting: VCSEC actuations return on the terminal ack: a VCSEC read replies with a bare ACK then a data frame, but a VCSEC actuation (RKE/closure/wake via _sendVehicleSecurity) replies with a single bare ACK only - _send cannot tell the two apart at transport level, so the caller declares it. _sendVehicleSecurity passes expects_data=False down through _command into _send (commands.py/bluetooth.py); everything else (VCSEC reads via _getVehicleSecurity, all infotainment via _send/_getInfotainment, _handshake, pair) keeps the default expects_data=True. With expects_data=False, _send returns immediately on the matching ACK instead of waiting out _ack_followup_timeout (was a flat ~2s tax on every VCSEC actuation), and on a lost ack the public mutating-command wrapper reaches the unresolved raise_unconfirmed outcome after the shorter _actuation_timeout (2s) rather than _default_timeout (5s) - the verify-by-state contract makes a longer wait pointless. Infotainment actuations never paid the tax (their actionStatus rides in protobuf_message_as_bytes, so _send returns on that data frame). The WAIT/fault retry threads expects_data through unchanged, so the double-execute exposure noted above is unaffected.
  • navigation_gps_request's order param is a raw int, not a callable enum: commands.py used to build it as NavigationGpsRequest.RemoteNavTripOrder(order), treating the protobuf nested-enum wrapper (EnumTypeWrapper) as if it were a callable Python enum.IntEnum class - it isn't, so every call raised TypeError before any message was sent (found live during PR-8; this method had never been exercised over BLE before). Fixed to pass order=order directly (matching the working sibling navigation_gps_destination_request), which protobuf accepts as a bare int for an enum field at runtime.
  • ReassemblingBuffer resets on a >1s inter-chunk gap, not just on decode failure: bluetooth.py's ReassemblingBuffer.receive_data discards any in-progress partial frame if the next chunk arrives more than STALE_CHUNK_TIMEOUT (1s) after the previous one, mirroring Tesla's official Go SDK (teslamotors/vehicle-command, pkg/connector/ble/ble.go's rxTimeout). Without this, a chunk dropped mid-message left a stale partial in the buffer that got prepended to the next message, corrupting it until a lucky decode failure resynced. This is a frame-integrity hardening, not a fix for the separate ack-loss behavior documented above (that's a stalled/silent link, which no buffer-side reset can recover).
  • pair() confirms whitelisting two ways: one-shot reply OR verify-by-state poll: the whitelist-op success is a single VCSEC frame, and it lands on a dead session (lost forever) if the BLE link cycles while the user walks to the car to approve - live-observed on HA/macOS CoreBluetooth, where tesla_fleet_api logged "Reconnecting to S..." every ~100s during a pending pair, and the plain one-shot wait hung despite car-side completion. pair() (bluetooth.py) keeps the reply as the fast path (waits one poll_interval for it) but, on a lost reply, polls _pair_probe() every poll_interval until an overall timeout (default 300s) elapses. The probe is a VCSEC _handshake with our own public key: it succeeds only once the key is whitelisted and faults NotOnWhitelistFault until then - _pair_probe maps any TeslaFleetError (incl. transport failures from a mid-wait reconnect) to "not yet", so polling survives reconnects. The whitelist op is written exactly once - never re-sent - because a re-send re-prompts the user (and the retry/double-execute hazard documented above applies). Deadline with neither path confirming raises a typed BluetoothTimeout. Default behavior, no new knob (poll_interval is a defaulted param).
  • Idle BLE keepalive (keepalive_interval): an idle held BLE link to the vehicle drops at ~42s mean lifetime (link supervision timeout ~720ms underneath, bluetoothd-verified on macOS CoreBluetooth); a single trivial passive GATT read every ~20s extends lifetime ~10x (~400s observed). VehicleBluetooth.__init__'s keepalive_interval (default DEFAULT_KEEPALIVE_INTERVAL = 20.0, None/0 disables; threaded through Vehicles/VehiclesBluetooth.create*) starts one asyncio task per connection (_keepalive_loop, bluetooth.py) that reads VERSION_UUID only after keepalive_interval seconds of genuine GATT idleness. Idle-triggered, not periodic: _last_activity is bumped on every _send write and every _on_notify frame, so an active session never gets extra traffic; the loop recomputes the wait each pass and fires only when idle. The read is bounded (_keepalive_timeout, 2s) and best-effort - a prior un-timed RSSI read hung indefinitely against a sleeping car, so every attempt carries a timeout and swallows all failures (_keepalive_read catches Exception, never CancelledError); a failed keepalive never raises into user code, never triggers reconnect (the existing connect_if_needed machinery owns recovery), and never wakes the car. Task lifecycle is tied to the connection: started at the end of connect() (after start_notify), cancelled-and-awaited in disconnect() and restarted cleanly on reconnect (_start_keepalive/_stop_keepalive). Sleep tradeoff: these reads keep an awake car awake and defer vehicle sleep - consumers wanting the car to sleep should disable keepalive or disconnect when idle. Tests in tests/test_ble_keepalive.py.
  • 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), and (newly accepted) clear_pin_to_drive_admin's pin param (no proto field on VehicleControlResetPinToDriveAdminAction - cloud still sends it in the REST body, BLE ignores it). clear_pin_to_drive_admin's prior mismapping is fixed and live-verified: it now builds VehicleControlResetPinToDriveAdminAction (delegating to reset_pin_to_drive_admin), not the wrong DrivingClearSpeedLimitPinAction (speed-limit PIN, a different feature) it built before - the vehicle itself rejected a live call to the old build with reason speed_limit_mode_active, meaningful only to Speed Limit Mode, confirming the mismapping. navigation_gps_request's prior order signature mismatch is resolved: both transports now default order to 0 (REMOTE_NAV_TRIP_ORDER_UNKNOWN, a defined proto enum value) when the caller omits it, so cloud always sends an explicit order rather than null.
  • 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. _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.
  • networking_status's ipv4_config fields are raw big-endian uint32 ints, not strings: the wire format applies to all of ipv4_config.address/subnet_mask/gateway, but TeslemetryEnergySite.find_gateway_address() (teslemetry/energysite.py, second typed accessor after find_authorized_clients(), same rules) decodes only address - confirmed against a live Powerwall 3 capture where 3232235914 decodes network-byte-order (struct.pack(">I", ...)) to 192.168.1.138, not little-endian. It considers only eth/wifi (never gsm - cellular isn't a LAN path), preferring whichever has active_route set and a decodable address, else falling back to the first of the two (in that order) with any decodable address; 0/0xFFFFFFFF are treated as undecodable so an unconfigured interface never shadows a real one with 0.0.0.0. A {"response": null} envelope raises InvalidResponse (the endpoint's known intermittent malformed mode), while a well-formed response with no usable interface returns None (not a raise). Tests: tests/test_teslemetry_gateway_address.py.
  • _stream_sinks peels subscription pushes off the command-reply queue before routing: a live 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 today; there is no public subscription API yet (VehicleDataSubscription/createStreamSession/cancelVehicleDataSubscription remain unwrapped, see the proto-coverage entry below) - this is dispatch-layer plumbing for that future work. 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 still need a public lifecycle/iterator API atop the private _stream_sinks routing described above) and getVehicleImageState (needs chunked binary-transfer paging) - both are separate, unscoped design work, not oversights. CarServer's GetVehicleState sub-state is exposed as legacy_vehicle_state() (bluetooth.py), matching the VehicleData.legacy_vehicle_state reply field name, specifically to avoid confusion with the pre-existing 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. pii_key_request/pseudonym_sync_request/tesla_auth_response/setup_cloud_profile_with_local_profile_uuid/get_local_profiles_for_vault_uuid are wrapped with no known third-party consumer use case, purely for full-proto-coverage completeness.

Maintaining this file

Keep this file for knowledge useful to almost every future agent session in this project. Do not repeat what the codebase already shows; point to the authoritative file or command instead. Prefer rewriting or pruning existing entries over appending new ones. When updating this file, preserve this bar for all agents and keep entries concise.