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.
# 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 testsTests 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.
- Tesla Fleet: https://developer.tesla.com/docs/fleet-api/endpoints/vehicle-endpoints
- Tessie: https://developer.tessie.com/llms.txt
- Teslemetry: http://api.teslemetry.com/openapi.yaml
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 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.
Vehicles (vehicle/vehicles.py) is a dict[str, Vehicle] with factory methods:
createFleet(vin)→VehicleFleetcreateSigned(vin)→VehicleSignedcreateBluetooth(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).
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.
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).
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.
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.
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 > runtime → VersionError). 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.
- Type checking: pyright strict mode. Use
TYPE_CHECKINGguards for circular imports. - Linting: ruff.
- Async: All API methods are
async. Usesaiohttpfor HTTP,aiofilesfor file I/O,bleakfor BLE. - Enums: Custom
StrEnum/IntEnuminconst.py(not stdlib).Regionis aLiteral["na", "eu", "cn"], not an enum. - Seat indexing gotcha: two distinct seat enums with different conventions.
Seatis 0-indexed (FRONT_LEFT=0) and is for the manual seat heater/cooler paths (remote_seat_heater_request,remote_seat_cooler_request).AutoSeatis 1-indexed (FRONT_LEFT=1,FRONT_RIGHT=2) and is the correct type forremote_auto_seat_climate_requeston both backends — its values equal Tesla's REST wire values and the protoAutoSeatPosition_*enum. Don't mix them; passing aSeatto 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 theADV_IND.SERVICE_UUID(tesla_fleet_api/tesla/vehicle/bluetooth.py) exists only as a GATT service after connecting. Never passservice_uuids=[SERVICE_UUID]as aBleakScannerdiscovery-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; keepSERVICE_UUIDfor post-connect GATT use only. - BLE domain-routing gotcha:
Domain(tesla_protocol.command.universal_message_pb2) has more values (DOMAIN_BROADCAST,DOMAIN_AUTHD, ...) thanVehicleBluetooth._queueshas keys (onlyDOMAIN_VEHICLE_SECURITY/DOMAIN_INFOTAINMENT)._on_message(tesla_fleet_api/tesla/vehicle/bluetooth.py) must look up_queueswith.get()and drop unrecognized domains rather than indexing directly — indexing raisesKeyErrorinside theReassemblingBuffercallback, 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 afterwake_up()can raiseBluetoothTimeouton the handshake through no fault of the command itself. Live-verified: waiting ~10s afterwake_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-endpointvehicle_data()call (or any of the dedicated per-substate readers likecharge_state()) succeeds, but requesting as few as twoBluetoothVehicleDataendpoints together reliably raisesTeslaFleetMessageFaultResponseSizeExceedsMTU(exceptions.py). This is whyvehicle_data()'sendpointsarg has no all-endpoints default (unlike the cloud method) - prefer the per-substate readers, or a single-endpointvehicle_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}) butclosures_state().door_open_passenger_rearstayedTrue- 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 trustingclosures_state()again. remote_heater_control_enabledgate:climate_state().remote_heater_control_enabledis a read-only vehicle-side setting (no command exists to flip it) that gates every "remote comfort" action - live-verified oncommands.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 itfalse, 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 theirHvacSeatHeaterAction/HvacSeatCoolerActionvia adictof literal field-name strings expanded as**kwargsinto the message constructor (with a# pyright: ignore[reportUnknownArgumentType]already on the call) - a typo in one of those strings (e.g.SEAT_HEATER_MEDIUMvs. the proto's actualSEAT_HEATER_MED, fixed live during PR-4) raises at call time, not at type-check time. Cross-check any new field-name string againsttesla_protocol.command.car_server_pb2rather than trusting pyright to catch it. scheduled_charging_modeis tri-state and shared:set_scheduled_chargingandset_scheduled_departure(commands.py) both write the sameChargeState.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 readcharge_state()first and restore the exact prior mode (including the other command's fields) rather than assuming independence.set_scheduled_departure'spreconditioning_enabled/off_peak_charging_enabledargs are dead: live-verified -ScheduledDepartureAction(tesla_protocol.command.car_server_pb2) has no fields for them, onlypreconditioning_times/off_peak_charging_times(weekday-recurrence only, no on/off). Passingpreconditioning_enabled=Falsehas 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()rejectsalready_standard: live-verified - calling it whilecharge_state().charge_limit_socalready equalscharge_limit_soc_stdgets{"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. viacharge_max_range()orset_charge_limit()).- BLE media state-observability gotcha:
MediaState.now_playing_artist/titleand all ofMediaDetailState(now_playing_album/station/source_string/elapsed/duration) were observed empty/zero on the test car while Spotify was activelyPlayingat nonzero volume - these legacy fields are apparently only populated for certain sources (e.g. USB/Bluetooth), not Spotify. Don't assumemedia_next_track/media_prev_track/media_next_fav/media_prev_favare 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(foradjust_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_volumewrite 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_unlockanddoor_lockeach 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. TreatBluetoothUnconfirmedCommand(aBluetoothTimeoutsubclass) 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 likemedia_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. Theadjust_volume/TimeoutAPIErrorroot 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_timeoutwhen their terminal ack is lost. - The BLE mutating-command confirmation ladder is one
confirmationenum + oneraise_unconfirmedbool:VehicleBluetooth.confirmation("optimistic" | "ack" | "verify", default"ack"; threaded throughVehicles/VehiclesBluetooth.create*) picks how many of write → ack-or-broadcast wait → state-read confirmation run;raise_unconfirmed(defaultFalse) 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 raisesBluetoothTransportErrorunconditionally, but a submitted-then-ambiguous write (see the write-delivery-certainty entry below) followsraise_unconfirmedlike 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_PLANSinbluetooth.py; only clearly-derivable absolute commands are covered - lock/unlock,set_charge_limit,set_charging_amps,adjust_volumeabsolute,set_temps,auto_conditioning_start/stop) and returns success on a match, raisesBluetoothCommandFailedon a proven mismatch, or returnsNone(still unresolved) if the read itself couldn't complete -Nonefalls through toraise_unconfirmed. Commands with no plan (true toggles, relative steps, ack-only actions) always fall through regardless ofconfirmation. The legacyoptimistic/verify_commandsboolean surface is deprecated: both warn (DeprecationWarning) and map ontoconfirmation(a positional bool in theconfirmationslot is treated as oldverify_commands; dominance order preserved -optimistic=Truewins if both are set), and remain as read-only properties (confirmation == "optimistic"/"verify") for existing readers. Seedocs/bluetooth_vehicles.mdfor 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'sconfirm_broadcastparam (threaded throughCommands._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_statusand 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 tomismatches, 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_broadcastraisesBluetoothCommandFailedinstead of the ambiguous timeout. This reuses the exact same_vcsec_verify_planpredicate as the"verify"rung above (one source of truth), applied to a broadcast's decodedVehicleStatusinstead 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)_sendstate machine directly - seetests/test_ble_broadcast_confirmation.py. - Persistent broadcast listeners (
tesla_fleet_api/tesla/vehicle/broadcast.py):VehicleBluetoothfans 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 modeledVehicleStatusleaf field gets a typedlisten_<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 toVehicleStatusby the app-recovered proto sync) have no typed listener yet, and anything not decoded intoVehicleStatus(other VCSEC payloads likeCommandStatus/whitelist/faults, and any future infotainment-domain broadcast - none observed today) also has none - all of these are covered by the untypedlisten_broadcast(domain, callback), which receives every raw unsolicited broadcast for that domain includingVehicleStatus. Closure/tonneau-percent listeners gate onHasFieldsince 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. Eachlisten_*returns anunsubscribe()closure; registries live for theVehicleBluetoothinstance's lifetime and are unaffected by reconnects, matching_queues. Listener callback exceptions are logged and isolated from later listeners/message routing, exceptKeyboardInterrupt/SystemExit. Not consumed by Home Assistant yet - HA gets state via Teslemetry streaming already. Seedocs/bluetooth_vehicles.mdandtests/test_ble_broadcast_listeners.py. BluetoothUnconfirmedCommandvsBluetoothCommandFailed(exceptions.py), and howRoutertreats each:_sendVehicleSecurity/_sendInfotainment(bluetooth.py, the mutating-command seam, unconditionally) wrap a caughtBluetoothTimeoutintoBluetoothUnconfirmedCommandwhen 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 defaultraise_unconfirmed=Falsethat unresolved outcome returns best-effort success; withraise_unconfirmed=TruetheBluetoothUnconfirmedCommandreaches the caller.BluetoothCommandFailedis 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 subclassBluetoothTimeout/BluetoothUnconfirmedCommand.Router._dispatch(router/base.py) special-cases onlyBluetoothUnconfirmedCommandto skip its normal per-command failover and re-raise immediately - replaying an already-possibly-executed command risks double-execution.BluetoothCommandFailedcarries no such risk (the command is proven not to have applied), so it falls throughRouter's ordinaryexcept (Exception, TeslaFleetError)clause and fails over like any other error - noRoutercode change was needed for this, only the exception type's placement outside theBluetoothTimeouthierarchy. A plain read (_getVehicleSecurity/_getInfotainment) still raises unadornedBluetoothTimeouton 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 unrelatedBluetoothTransportError(see the write-delivery-certainty entry below - a submitted-then-ambiguous write is not this case).- Write-delivery certainty splits
BluetoothTransportErrorfromBluetoothTimeoutat the GATT write in_send:write_gatt_charfailures are not uniformlyBluetoothTransportError.BleakCharacteristicNotFoundError(bleak resolvesWRITE_UUIDsynchronously, before any backend I/O) is the only case provably pre-submission, so it alone staysBluetoothTransportErrorand is safe forRouterto retry. Every otherBleakError/TimeoutErrorfrom 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_sendinstead 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 plainBluetoothTimeout. BecauseBluetoothUnconfirmedCommandsubclassesBluetoothTimeout, this lands in the exact sameexcept BluetoothTimeoutladder in_sendVehicleSecurity/_sendInfotainmentas a lost post-write ack, with no separate exception type needed;_send_optimisticgets the equivalent treatment explicitly since it bypasses that ladder (see above). A read is unaffected -_getVehicleSecurity/_getInfotainmentdon't override the ladder, so a write-ambiguous read propagates plainBluetoothTimeoutandRouterfails 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 (BluetoothUnconfirmedCommandwhenraise_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._commandcan double-execute a mutating command: on anOPERATIONSTATUS_WAITreply or anINCORRECT_EPOCH/INVALID_TOKENfault,_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_datasplits 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 -_sendcannot tell the two apart at transport level, so the caller declares it._sendVehicleSecuritypassesexpects_data=Falsedown through_commandinto_send(commands.py/bluetooth.py); everything else (VCSEC reads via_getVehicleSecurity, all infotainment via_send/_getInfotainment,_handshake,pair) keeps the defaultexpects_data=True. Withexpects_data=False,_sendreturns 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 unresolvedraise_unconfirmedoutcome 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 (theiractionStatusrides inprotobuf_message_as_bytes, so_sendreturns on that data frame). The WAIT/fault retry threadsexpects_datathrough unchanged, so the double-execute exposure noted above is unaffected.navigation_gps_request'sorderparam is a raw int, not a callable enum:commands.pyused to build it asNavigationGpsRequest.RemoteNavTripOrder(order), treating the protobuf nested-enum wrapper (EnumTypeWrapper) as if it were a callable Pythonenum.IntEnumclass - it isn't, so every call raisedTypeErrorbefore any message was sent (found live during PR-8; this method had never been exercised over BLE before). Fixed to passorder=orderdirectly (matching the working siblingnavigation_gps_destination_request), which protobuf accepts as a bare int for an enum field at runtime.ReassemblingBufferresets on a >1s inter-chunk gap, not just on decode failure:bluetooth.py'sReassemblingBuffer.receive_datadiscards any in-progress partial frame if the next chunk arrives more thanSTALE_CHUNK_TIMEOUT(1s) after the previous one, mirroring Tesla's official Go SDK (teslamotors/vehicle-command,pkg/connector/ble/ble.go'srxTimeout). 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, wheretesla_fleet_apilogged "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 onepoll_intervalfor it) but, on a lost reply, polls_pair_probe()everypoll_intervaluntil an overalltimeout(default 300s) elapses. The probe is a VCSEC_handshakewith our own public key: it succeeds only once the key is whitelisted and faultsNotOnWhitelistFaultuntil then -_pair_probemaps anyTeslaFleetError(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 typedBluetoothTimeout. Default behavior, no new knob (poll_intervalis 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__'skeepalive_interval(defaultDEFAULT_KEEPALIVE_INTERVAL= 20.0,None/0disables; threaded throughVehicles/VehiclesBluetooth.create*) starts one asyncio task per connection (_keepalive_loop,bluetooth.py) that readsVERSION_UUIDonly afterkeepalive_intervalseconds of genuine GATT idleness. Idle-triggered, not periodic:_last_activityis bumped on every_sendwrite and every_on_notifyframe, 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_readcatchesException, neverCancelledError); a failed keepalive never raises into user code, never triggers reconnect (the existingconnect_if_neededmachinery owns recovery), and never wakes the car. Task lifecycle is tied to the connection: started at the end ofconnect()(afterstart_notify), cancelled-and-awaited indisconnect()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 intests/test_ble_keepalive.py. - Cross-transport parity (cloud REST
VehicleFleetvs BLECommands): 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.pylocks the equivalence in with mocked-both-transports tests. Known non-bug FORM differences (do not "fix"):set_scheduled_departure'spreconditioning_enabled/off_peak_charging_enabled(no proto fields),window_controllat/lon andnavigation_sc_requestid(no proto fields),navigation_request'stype/locale/timestamp_ms(REST share-intent framing), andmedia_volume_up(no Tesla REST endpoint - BLE-only; cloud raises volume viaadjust_volume), and (newly accepted)clear_pin_to_drive_admin'spinparam (no proto field onVehicleControlResetPinToDriveAdminAction- 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 buildsVehicleControlResetPinToDriveAdminAction(delegating toreset_pin_to_drive_admin), not the wrongDrivingClearSpeedLimitPinAction(speed-limit PIN, a different feature) it built before - the vehicle itself rejected a live call to the old build with reasonspeed_limit_mode_active, meaningful only to Speed Limit Mode, confirming the mismapping.navigation_gps_request's priorordersignature mismatch is resolved: both transports now defaultorderto0(REMOTE_NAV_TRIP_ORDER_UNKNOWN, a defined proto enum value) when the caller omits it, so cloud always sends an explicitorderrather thannull. - Per-command debug logging chokepoints and the
command=name it derives:LOGGER.debuglines of the formcommand=<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) andTeslaFleetApi._request(fleet.py, covers Fleet/Teslemetry/Tessie REST).transportcomes from a_transport_nameClassVarset per concrete class ("bluetooth"/"fleet"/"teslemetry"/"tessie"), mirroring the existing_auth_methodpattern - add that ClassVar to any newCommands/TeslaFleetApisubclass. For BLE/Fleet-signed,commandis not the Python method name; it's derived from the populated protobuf oneof field (vcsec_command_name/infotainment_command_nameincommands.py), e.g.door_lock()logs asRKE_ACTION_LOCKandset_charge_limit()aschargingSetLimitAction- deliberately robust to call-site changes since it reads the message being sent, not the call stack.VehicleBluetooth'sverify_commandsresolution logs a second, separate line (verify_commands=resolved/unresolved) rather than duplicating the base class's raw-attempt line.Router._dispatch(router/base.py) logscommand=... backend=<ClassName> result=...per backend tried, independent of the above. Seedocs/bluetooth_vehicles.md's "Troubleshooting: Enable Debug Logging" section for the user-facing format;tests/test_command_logging.pylocks 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'slist_authorized_clientsreturningnull) must never raise there. It guards withisinstance(data, dict)before calling.get(), loggingresult=successand returning for anything else. Regression tests intests/test_command_logging.py(test_null_json_body_returns_none_without_raisingetc.) 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 rawdict[str, Any]/None/listREST 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 - theclientskey variant was added after a live capture (Tesla Release 953) showed the endpoint's real key differs from the originally-documentedauthorized_clients, confirmed against a populated 5-entry sample.AuthorizedClientmodels 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 thoughAuthorizedClientState(const.py) has no falsy member: (1) field lookup must check key presence (key in payload), neverpayload.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 toNone; (2) aNonebody 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()raisesInvalidResponse(exceptions.py) for both rather than collapsing them to[]; only a genuinely empty list under either accepted key parses toAuthorizedClients.clients == []without raising. Tesla has not published an OpenAPI schema for pairing endpoints, soconst.py's enums are the schema of record; widenAuthorizedClient's modeled fields only against a further live sample, not speculatively. The rawlist_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'sipv4_configfields are raw big-endian uint32 ints, not strings: the wire format applies to all ofipv4_config.address/subnet_mask/gateway, butTeslemetryEnergySite.find_gateway_address()(teslemetry/energysite.py, second typed accessor afterfind_authorized_clients(), same rules) decodes onlyaddress- confirmed against a live Powerwall 3 capture where3232235914decodes network-byte-order (struct.pack(">I", ...)) to192.168.1.138, not little-endian. It considers onlyeth/wifi(nevergsm- cellular isn't a LAN path), preferring whichever hasactive_routeset and a decodable address, else falling back to the first of the two (in that order) with any decodable address;0/0xFFFFFFFFare treated as undecodable so an unconfigured interface never shadows a real one with0.0.0.0. A{"response": null}envelope raisesInvalidResponse(the endpoint's known intermittent malformed mode), while a well-formed response with no usable interface returnsNone(not a raise). Tests:tests/test_teslemetry_gateway_address.py._stream_sinkspeels subscription pushes off the command-reply queue before routing: a livevehicleDataSubscription's pushes arrive addressed to us on the same domain queue (_queues) an ordinary command's reply uses, correlated by the subscribe request's ownrequest_uuid._on_message(bluetooth.py) checksself._stream_sinks.get(msg.request_uuid)before touching_queues- a match routes into that subscription's own bounded, drop-oldest_StreamSinkinstead, so_send's pre-send drain can never discard a push and_await_responsecan never return one as an unrelated command's reply._register_stream_sink/_unregister_stream_sinkare the only entry points into the registry today; there is no public subscription API yet (VehicleDataSubscription/createStreamSession/cancelVehicleDataSubscriptionremain unwrapped, see the proto-coverage entry below) - this is dispatch-layer plumbing for that future work. Tests:tests/test_ble_stream_sink.py.VehicleAction/GetVehicleDataproto coverage is locked by test, not just by convention:tests/test_proto_coverage_lock.pywalks 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 futuretesla-protocolbump 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_sinksrouting described above) andgetVehicleImageState(needs chunked binary-transfer paging) - both are separate, unscoped design work, not oversights. CarServer'sGetVehicleStatesub-state is exposed aslegacy_vehicle_state()(bluetooth.py), matching theVehicleData.legacy_vehicle_statereply field name, specifically to avoid confusion with the pre-existingvehicle_state()(VCSECVehicleStatus, a different message/domain).set_rate_tariff/add_managed_charging_site(commands.py) taketesla_protocolmessage 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_uuidare wrapped with no known third-party consumer use case, purely for full-proto-coverage completeness.
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.