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/base.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. Constructor: Router(primary, secondary, *more_backends, health=None). 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 rest of the chain is reached purely through per-command failover — there is deliberately no per-backend health matrix. 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 (router/vehicle.py, router/energysite.py) are thin entity-specific Router subclasses. 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. Both re-export from router/__init__.py (tesla_fleet_api.router.Router etc.) and from tesla/__init__.py (tesla_fleet_api.tesla.Router) for backward compatibility. 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. Deliberately implemented as native tuple comparison rather than taking on a general-purpose version-parsing dependency, matching this library's narrow dependency list.
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/release.yml triggers directly on that tag push: it reruns the full CI gate (ruff, pyright, pytest, uv build + twine check) on the exact tagged commit, then requires approval on the pypi GitHub environment (required reviewers configured via the Environments API — there's no repo Settings UI for it) before publishing via the PyPA OIDC trusted-publishing action (with PEP 740 attestations) and cutting the GitHub Release. It is a plain top-level workflow, not a workflow_call reusable one — the PyPI trusted publisher for this project is configured as workflow release.yml + environment pypi, and a reusable-workflow caller's signing identity doesn't match that publisher/attestation identity. Sibling repos each carry their own local copy of this workflow rather than calling it cross-repo.
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 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. 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. - 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. bleakclient/scanner must be resolved dynamically, not import-bound: both BLE modules (tesla/vehicle/bluetooth.py,tesla/bluetooth.py) doimport bleakand referencebleak.BleakClient/bleak.BleakScannerat call time, neverfrom bleak import BleakClient. Home Assistant's habluetooth replaces thosebleakmodule attributes at runtime with a multi-adapter/proxy-aware client; a name captured at module import would permanently ignore that replacement and connect on the pristine local backend instead of the HA-selected adapter/ESPHome proxy. Keep the type-only imports underTYPE_CHECKING.connect()logs a per-stagestage=...debug line (establish_connection/start_notify/is_connected/keepalive) on transport failure; tests patch the canonicalbleak.BleakScanner/bleak.BleakClient(not a module-level name).- 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; 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. A single-endpointvehicle_data()call (or any dedicated per-substate reader 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. - BLE individual-door powered-close gotcha:
open_*_door()unlatches a door over VCSEC; on a Model 3 there is no reliable powered close. An ack ({"result": True}) from a close command only means the car accepted it, 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 (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). 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 — a typo in one of those strings 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). 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: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 vehicle state. Document, don't rely on these args to gate the feature.charge_standard()rejectsalready_standard: calling it whilecharge_state().charge_limit_socalready equalscharge_limit_soc_stdgets{"result": False, "reason": "already_standard"}rather than a no-op success. 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) are 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) are reliable provers. - BLE mutating-command timeout is inconclusive — never assume "the write didn't land": mutating VCSEC/RKE actions can raise
BluetoothTimeoutyet have physically executed, most likely because the vehicle doesn't reliably return an observable ack within the timeout. 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. VCSEC actuations 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 followsraise_unconfirmedlike every other rung."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;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.
_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) 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 same_vcsec_verify_planpredicate as the"verify"rung above, applied to a broadcast's decodedVehicleStatus; it currently covers only lock/unlock, the one VCSEC actuation with an observed status broadcast. 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, 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); anything not decoded intoVehicleStatusis covered by the untypedlisten_broadcast(domain, callback). 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. Seedocs/bluetooth_vehicles.mdandtests/test_ble_broadcast_listeners.py. - Connection-status listener:
VehicleBluetooth.listen_connection_status()reports BLE session transitions, including unexpected transport loss; the authoritative contract is indocs/bluetooth_vehicles.md#connection-status-events, regression coverage intests/test_ble_connection_status.py. BluetoothUnconfirmedCommandvsBluetoothCommandFailed(exceptions.py), and howRoutertreats each:_sendVehicleSecurity/_sendInfotainment(bluetooth.py) 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=Trueit reaches 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 does not subclassBluetoothTimeout/BluetoothUnconfirmedCommand.Router._dispatch(router/base.py) special-cases onlyBluetoothUnconfirmedCommandto skip its normal per-command failover and re-raise immediately, since replaying an already-possibly-executed command risks double-execution;BluetoothCommandFailedcarries no such risk and falls throughRouter's ordinary error handling like any other error. 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.- 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, so_sendinstead races any already-armed broadcast watcher for the rest of the window and, failing that, raises plainBluetoothTimeout— which, becauseBluetoothUnconfirmedCommandsubclassesBluetoothTimeout, lands in the same ladder as a lost post-write ack._send_optimisticgets the equivalent treatment explicitly since it bypasses that ladder. A read is unaffected since it has no double-execution risk. Tests:tests/test_ble_send_transport.py,tests/test_ble_broadcast_confirmation.py,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 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). Hold one connection across a whole batch of related commands rather than reconnecting between each.- 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"}. 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 viaexpects_data._sendVehicleSecuritypassesexpects_data=False; 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, 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).navigation_gps_request'sorderparam is a raw int, not a callable enum: the protobuf nested-enum wrapper (EnumTypeWrapper) is not a callable PythonIntEnumclass; passorder=orderdirectly (matching the 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 leaves a stale partial in the buffer that gets prepended to the next message, corrupting it until a lucky decode failure resyncs.pair()confirms whitelisting two ways: one-shot reply OR verify-by-state poll: the whitelist-op success is a single VCSEC frame, which is lost forever if the BLE link cycles while the user walks to the car to approve.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. Deadline with neither path confirming raises a typedBluetoothTimeout.- Idle BLE keepalive (
keepalive_interval): an idle held BLE link to the vehicle drops quickly (link supervision timeout on the order of ~1s); a trivial passive GATT read on an idle cadence extends session lifetime substantially.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 read is bounded (_keepalive_timeout, 2s) and best-effort — every attempt carries a timeout and swallows all failures exceptCancelledError; a failed keepalive never raises into user code, never triggers reconnect, 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),media_volume_up(no Tesla REST endpoint — BLE-only; cloud raises volume viaadjust_volume), andclear_pin_to_drive_admin'spinparam (no proto field onVehicleControlResetPinToDriveAdminAction— cloud still sends it in the REST body, BLE ignores it). Both transports defaultnavigation_gps_request'sorderto0(REMOTE_NAV_TRIP_ORDER_UNKNOWN) when the caller omits it. - 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 —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_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.VehicleBluetooth'sverify_commandsresolution logs a second, separate line (verify_commands=resolved/unresolved).Router._dispatch(router/base.py) logscommand=... backend=<ClassName> result=...per backend tried. 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) 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.- Typed accessor pattern for undocumented raw-dict responses:
TeslemetryEnergySite.find_authorized_clients()andfind_gateway_address()(teslemetry/energysite.py) are frozen-dataclass typed wrappers over rawdict[str, Any]/None/listREST 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), neverpayload.get(key) or default— a legal falsy value is not "missing"; (2) aNonebody and an unrecognized response shape are malformed data, not "empty" — raiseInvalidResponse(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()decodesnetworking_status.ipv4_config.addressas a raw big-endian uint32 (struct.pack(">I", ...), not little-endian), considers onlyeth/wifi(nevergsm), preferring whichever hasactive_routeset and a decodable address;0/0xFFFFFFFFare treated as undecodable. Tesla has not published an OpenAPI schema for these endpoints, soconst.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_sinkspeels subscription pushes off the command-reply queue before routing: avehicleDataSubscription'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; there is no public subscription API yet. 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 need a public lifecycle/iterator API atop the private_stream_sinksrouting above) andgetVehicleImageState(needs chunked binary-transfer paging). CarServer'sGetVehicleStatesub-state is exposed aslegacy_vehicle_state()(bluetooth.py), matching theVehicleData.legacy_vehicle_statereply field name, to avoid confusion withvehicle_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.- Energy-gateway authorized-client pairing has security- and protocol-specific constraints: use RSA for LAN TEDapi v1r, treat
PENDING_VERIFICATION_TIMEOUTas terminal, and account for presence-free key removal. The authoritative pairing, retry, encoding, and removal guidance is indocs/energy_local_control.md; enum values and API contracts live inconst.pyand the relevant method docstrings.
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.