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.
uv sync # install
uv run pyright tesla_fleet_api # type check (strict)
uv run ruff check tesla_fleet_api # lint
uv run ruff format tesla_fleet_api
uv run pytest testsTests use unittest.IsolatedAsyncioTestCase, collected natively by pytest (no
pytest-asyncio). BLE command tests build on MockedBleTransportTestCase
(tests/ble_mocked_transport.py), which patches VehicleBluetooth._send and
pre-marks both signed-command sessions ready, so a test can drive any Commands
method with no real BLE/GATT connection; see tests/test_ble_mocked_commands.py.
- 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
- Library docs:
docs/(bluetooth_vehicles.md,energy_local_control.md,teslemetry.md,tessie.md,fleet_api_*.md)
Tesla (tesla/tesla.py) - EC key management for signed commands
└── TeslaFleetApi (tesla/fleet.py) - core HTTP client, _request(), access_token
├── TeslaFleetOAuth (tesla/oauth.py)
├── Teslemetry (teslemetry/teslemetry.py)
└── Tessie (tessie/tessie.py)
Three implementations share the same method signatures, selected by how you create the vehicle:
Vehicle (vehicle/vehicle.py) - VIN and model detection
└── VehicleFleet (vehicle/fleet.py) - REST commands (unsigned)
└── VehicleSigned (vehicle/signed.py) - Commands + VehicleFleet
Commands (vehicle/commands.py) - protobuf signed-command implementation (ABC)
└── VehicleSigned (signed commands over Fleet API)
└── VehicleBluetooth (vehicle/bluetooth.py) - BLE transport
Vehicles (vehicle/vehicles.py) is a dict[str, Vehicle] with
createFleet/createSigned/createBluetooth factories; see the
createBluetooth docstring for its confirmation/keepalive/key arguments.
Teslemetry/Tessie override Vehicles with TeslemetryVehicle/TessieVehicle,
adding service-specific commands.
Tesla lazily attaches charging, energySites, user, partner, vehicles
in __init__; scope flags on TeslaFleetApi.__init__ control which are built.
Router (router/base.py) is an entity-agnostic composition wrapper, not part
of the inheritance chain: Router(primary, secondary, *more, health=None, on_error=None) chains backends sharing a method surface and dispatches each
call down the chain with per-command failover — first backend that has the
method, retried on the next on any exception, returning the first success
(last error if all fail, AttributeError if none has the method).
Non-callable attributes resolve to the first backend that has them.
- The health check gates only the primary; the rest of the chain is reached purely through per-command failover. There is deliberately no per-backend health matrix.
on_error(exception, backend, method_name)(sync or async) is called after every dispatched call, success (exception=None, return ignored — the only success hook) and failure alike; on failure (every per-command failover exception exceptBluetoothUnconfirmedCommand) returningFalsestops failover and re-raises immediately instead of trying the next backend, letting a caller (e.g. Home Assistant reacting to a BLE key rejection viaexceptions.is_key_rejected) veto a retry it already knows will fail and run its own side effect at the point of failure. Never fires for plain attribute access, only for a dispatched callable.- Failover can double-execute a non-idempotent command that failed mid-flight.
BluetoothUnconfirmedCommandis the one exception: it propagates without replay.
VehicleRouter and EnergySiteRouter (router/vehicle.py, router/energysite.py)
are thin subclasses pairing a local/BLE primary with a Teslemetry cloud fallback.
EnergySiteRouter's local backend is duck-typed (e.g. aiopowerwall's
PowerwallEnergySite) so no dependency is added. Both re-export from
router/__init__.py and tesla/__init__.py. 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;
docs/energy_local_control.md has the end-to-end pairing flow and the
security/protocol constraints of gateway pairing (RSA for LAN TEDapi v1r,
PENDING_VERIFICATION_TIMEOUT is terminal, presence-free key removal).
set_island_mode/go_off_grid/reconnect_grid (tesla/energysite.py)
unconditionally raise SignedCommandRequired. They can only send an unsigned
grpc_command, which gateways acknowledge without actuating the contactor —
shipping that as a silent no-op would be worse. Only the signed local path
(add_authorized_client + EnergySiteRouter) actuates, and even its success
response doesn't prove the contactor moved: verify state after the call.
ObservationFunnel (funnel.py) is separate from the command Router. It is a
funnel, not a selector: every attached publisher feeds the same per-field
listeners, so a field bound to one source survives that source dropping.
- There is deliberately no source health, availability, grace window, failback delay, priority, stickiness, per-field selection, or Bluetooth-vs-stream ranking. Unavailability is a value a source reports (a null/SNA reading), never something the funnel infers from a link dropping.
- The only arbitration:
publish()ignores an observation older than the last one for that field, and does not re-dispatch an unchanged value. Both are hard-coded, not configurable. - The module is entirely synchronous and can never originate a request — no
async def/await, no polling loop, no request callable, no scheduling task.tests/test_funnel.py::TestFunnelCannotOriginateWorkasserts this against the module's own AST; keep the module synchronous rather than adding a fetch path. Polling belongs to an external consumer, which may gate its schedule onlisten_demand(paths, cb)and feed results back viaVehicleDataResultPublisher.publish_result(dict). - Publishers push via
publish(Observation);observed_atvalues must come from one monotonic clock shared by every publisher on a funnel.value(path)returns the last observed value; itsNonemeans never observed or reported unavailable. - Fields are deliberately three (
Locked,ChargePortDoorOpen,DoorState.TrunkFront). Translations are positive allowlists: an unmapped VCSEC enum or absent JSON leaf emits no observation rather than a guess, while an explicit JSON null emits an unavailable value. Any unlocked VCSEC lock state (includingSELECTIVE_UNLOCKED) maps to unlocked; closureUNKNOWN/FAILED_UNLATCHstay unmapped pending live-frame validation. BleBroadcastPublisherreusesVehicleBluetooth'slisten_*seams and never connects, reads, or commands.VEHICLELOCKSTATE_UNLOCKEDis 0 with no proto3 presence, so every VCSEC status broadcast reports a lock state and the funnel deduplicates the repeats.TeslemetryStreamPublisheris the intended primary source (Bluetooth is opportunistic) and takes a caller-supplied payload rather than depending onteslemetry-stream:publish_update(data)takes one push'sdatamapping keyed by signal name — the same strings that package'sSignalStrEnumequals — and coerces the"true"/"false"wire strings some vehicles stream.
util.py holds dependency-free helpers re-exported from the top-level package.
firmware_compare/firmware_at_least compare dotted week-based Tesla firmware
strings (2025.14.3), which plain string comparison misorders
("2025.10" < "2025.9"); unparseable strings sort behind any parseable one.
Deliberately native tuple comparison, not a version-parsing dependency.
No release automation. To ship: bump version in pyproject.toml and
__version__ in tesla_fleet_api/__init__.py, run uv lock, commit on main,
push a matching vX.Y.Z tag. CI and the release gate run uv sync --locked, so
a bump that skips uv lock fails before merge. .github/workflows/release.yml
triggers on the tag: reruns the full gate, then publishes via PyPA OIDC trusted
publishing (PEP 740 attestations) in the pypi environment and cuts the Release.
That environment has no required reviewers — merging the version-bump PR is
the effective publish approval. Keep release.yml a plain top-level workflow,
never a reusable workflow_call one: the PyPI trusted publisher is configured as
workflow release.yml + environment pypi, and a caller's signing identity
would not match. Sibling repos each carry their own copy rather than calling it.
exceptions.py maps HTTP status codes and error keys to exception classes;
raise_for_status() raises the right one. Signed-command faults have separate
hierarchies (TeslaFleetInformationFault, TeslaFleetMessageFault,
SignedMessageInformationFault, WhitelistOperationStatus).
exceptions.is_key_rejected(exc) is the positive allowlist of faults across
those hierarchies that specifically mean the vehicle didn't recognize/accept
our signing key (verified against each fault's proto enum meaning, not its
name) — e.g. excludes TeslaFleetMessageFaultKeychainIsFull, whose proto
meaning is "no room for another key", not "this key was rejected".
All exceptions inherit from TeslaFleetError(BaseException), deliberately not
Exception. A bare except Exception (e.g. a retry loop around BLE reads)
silently fails to catch BluetoothTimeout and every other library error — catch
TeslaFleetError or BaseException explicitly. VehicleBluetooth wraps
transport failures (connect/connect_if_needed, notification setup) in
BluetoothTransportError, chaining the original cause. Those catch sites must
catch both bleak.exc.BleakError and builtin TimeoutError: bleak-esphome
converts an aioesphomeapi GATT/connect/notify timeout into a bare TimeoutError.
The GATT write in _send is not unconditionally wrapped — see
"Write-delivery certainty" below.
Bindings come from the published tesla-protocol PyPI package
(Teslemetry/tesla-protocol); import from tesla_protocol.command.<module>_pb2.
tesla_fleet_api/tesla/vehicle/proto/ holds only backwards-compatible re-export
shims, not generated code. To pick up new message definitions, bump the 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. Check Home Assistant core's
current protobuf pin (homeassistant/package_constraints.txt) before bumping
tesla-protocol — any version depended on must stamp gencode at or below that
pin and declare a protobuf requirement compatible with it. Keep
pyproject.toml's protobuf floor in sync with what tesla-protocol requires.
Command coverage is locked by tests/test_proto_coverage_lock.py, which fails if
any VehicleAction/GetVehicleData field has no wrapper (commands.py) or
reader (bluetooth.py) and is not allowlisted with a reason — keep that test in
sync with a tesla-protocol bump rather than special-casing new fields. Naming:
legacy_vehicle_state() (bluetooth.py) reads CarServer's GetVehicleState
sub-state; vehicle_state() is the VCSEC VehicleStatus, a different
message/domain. set_rate_tariff takes tesla_protocol message types directly
rather than a parallel flattened API. add_managed_charging_site takes a
flattened (public_key, din, lat, lon) API; public_key must already be
converted from DER to a raw EC point (the Fleet API route does that
conversion server-side, this one does not), and din is the gateway's DIN
the vehicle uses to match the registration to the site controller.
- Type checking: pyright strict. Use
TYPE_CHECKINGguards for circular imports. - Linting: ruff.
- Async: all API methods are
async;aiohttp,aiofiles,bleak. - Enums: custom
StrEnum/IntEnuminconst.py(not stdlib).Regionis aLiteral["na", "eu", "cn"], not an enum. - Naming: camelCase for instance attributes mirroring API structure
(
energySites,createFleet); snake_case for endpoint method names. - Seat indexing gotcha:
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 and is the correct type forremote_auto_seat_climate_requeston both backends — its values equal Tesla's REST wire values and the protoAutoSeatPosition_*enum. Passing aSeatto the auto-climate command is off-by-one. - Protobuf oneof-by-string-kwargs bypasses pyright:
remote_seat_heater_request/remote_seat_cooler_request(commands.py) build their action message from adictof literal field-name strings expanded as**kwargs; a typo raises at call time, not at type-check time. Cross-check new field-name strings againsttesla_protocol.command.car_server_pb2. navigation_gps_request'sorderis a raw int, not a callable enum: the protobufEnumTypeWrapperis not anIntEnumclass; passorder=order, which protobuf accepts as a bare int for an enum field.- Typed accessors over undocumented raw-dict responses (e.g.
TeslemetryEnergySite.find_authorized_clients/find_gateway_address) keep API-parsing logic in the library. Two rules for any new one: (1) field lookup must check key presence (key in payload), neverpayload.get(key) or default— a legal falsy value is not "missing"; (2) aNonebody or an unrecognized shape is malformed data — raiseInvalidResponse, never collapse it to an empty result. Only a well-formed-but-empty response parses to empty. Tesla publishes no schema for these endpoints, soconst.py's enums are the schema of record: widen modeled fields only against a further live sample. Untyped escape hatches (list_authorized_clients()) stay available alongside. register_client()(teslemetry/teslemetry.py) is Teslemetry-only OAuth Dynamic Client Registration (RFC 7591) — a module-level function, not aTeslemetrymethod, since registration precedes having aclient_idor token. It always registers a new client (no dedup) and raisesTeslemetryRegistrationErroron transport failure, non-2xx, non-JSON, non-dict, or a body with no usableclient_id. Fleet API and Tessie have no equivalent — don't add one speculatively.
Command logging happens at exactly five chokepoints: Commands'
_sendVehicleSecurity/_getVehicleSecurity/_sendInfotainment/_getInfotainment
(BLE and Fleet-signed) and TeslaFleetApi._request (REST), all emitting
command=<name> transport=<t> result=.... transport comes from a
_transport_name ClassVar per concrete class — add that ClassVar to any new
Commands/TeslaFleetApi subclass. For signed transports command is not
the Python method name but the populated protobuf oneof field (door_lock() logs
as RKE_ACTION_LOCK). Router._dispatch logs its own per-backend line. Exact
line shapes are locked by tests/test_command_logging.py.
_log_request_result (fleet.py) runs after a successful request and must never
raise on any JSON-legal body — it guards with isinstance(data, dict) before
.get().
Cross-transport parity: the same-named command on REST VehicleFleet and BLE
Commands must build a semantically equivalent instruction from identical args;
a divergence is a bug. Response bodies legitimately differ (REST JSON vs decoded
protobuf). tests/test_cross_transport_parity.py locks this in and documents the
known non-bug form differences — check it before "fixing" one.
Vehicle-side behaviours that look like library bugs but are not:
remote_heater_control_enabled(climate_state()) is a read-only vehicle-side setting with no command to flip it, and gates every remote comfort action (seat heater/cooler, steering wheel heat, auto seat climate). With itfalsethe vehicle ACKs{"result": false, "reason": "cabin comfort remote settings not enabled"}and changes nothing.scheduled_charging_modeis tri-state and shared:set_scheduled_chargingandset_scheduled_departureboth write it (Off/StartAt/DepartBy). Disabling one while the other is active turns the whole feature Off. A caller toggling one must readcharge_state()first and restore the exact prior mode.set_scheduled_departure'spreconditioning_enabled/off_peak_charging_enabledargs are dead:ScheduledDepartureActionhas onlypreconditioning_times/off_peak_charging_times(weekday recurrence, no on/off).charge_standard()rejectsalready_standard: calling it whencharge_limit_socalready equalscharge_limit_soc_stdreturns{"result": False, "reason": "already_standard"}, not a no-op success.
User-facing behaviour, examples and the confirmation-ladder table live in
docs/bluetooth_vehicles.md. The invariants below are what code changes must not
break.
- Discovery: a Tesla 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. Never passservice_uuids=[SERVICE_UUID]as aBleakScannerfilter — it hides the vehicle on a direct BlueZ adapter (an ESPHome proxy doesn't enforce the filter the same way, which masks the bug in testing). Scan unfiltered with active scanning and match by name;SERVICE_UUIDis for post-connect GATT only. bleakclient/scanner must be resolved dynamically: both BLE modules (tesla/vehicle/bluetooth.py,tesla/bluetooth.py)import bleakand referencebleak.BleakClient/bleak.BleakScannerat call time, neverfrom bleak import BleakClient. Home Assistant's habluetooth replaces those module attributes at runtime with a proxy-aware client; a name captured at import would permanently ignore that and use the local adapter. Keep type-only imports underTYPE_CHECKING; tests patch the canonicalbleak.*names.- Domain routing:
Domainhas more values than_queueshas keys (onlyDOMAIN_VEHICLE_SECURITY/DOMAIN_INFOTAINMENT)._on_messagemust look up_queueswith.get()and drop unrecognized domains — indexing raisesKeyErrorinside theReassemblingBuffercallback, aborting reassembly of every already-buffered message in that notification. ReassemblingBufferresets on a >STALE_CHUNK_TIMEOUT(1s) inter-chunk gap, not only on decode failure, mirroring Tesla's Go SDKrxTimeout. Without it a dropped chunk leaves a stale partial that corrupts the next message._stream_sinkspeels subscription pushes off the command-reply queue: avehicleDataSubscription's pushes arrive on the same domain queue a command reply uses, correlated by the subscribe request'srequest_uuid._on_messagechecks_stream_sinksbefore touching_queues, so_send's pre-send drain can't discard a push and_await_responsecan't return one as an unrelated reply._register_stream_sink/_unregister_stream_sinkare the only entry points; there is no public subscription API yet.- Mutating-command timeouts are inconclusive — never assume "the write didn't
land": a mutating VCSEC/RKE action can raise
BluetoothTimeoutyet have physically executed. Snapshot state before acting and verify with a follow-up read. Never blind-retry a non-idempotent command (toggles, volume steps, schedule add/remove) on timeout alone. Commands._commandcan double-execute: onOPERATIONSTATUS_WAITor anINCORRECT_EPOCH/INVALID_TOKENfault it re-signs and re-sends the identical command (3 attempts, then{"result": False, "reason": "Too many retries"}). Harmless for idempotent commands, a real risk for toggles and step commands — verify those by absolute state, never by counting invocations.BluetoothUnconfirmedCommandvsBluetoothCommandFailed:_sendVehicleSecurity/_sendInfotainmentwrap a caughtBluetoothTimeoutintoBluetoothUnconfirmedCommandwhen the ladder is genuinely unresolved (the vehicle may have executed).BluetoothCommandFailedis the distinct outcome where a state check proved the command did not apply; it does not subclassBluetoothTimeout.Routerspecial-cases only the former (no replay). A plain read (_getVehicleSecurity/_getInfotainment) raises unadornedBluetoothTimeout— a read has no side effect to be unconfirmed about.- Write-delivery certainty splits the two at the GATT write in
_send:BleakCharacteristicNotFoundErroris the only provably pre-submission failure (bleak resolvesWRITE_UUIDbefore any backend I/O), so it alone staysBluetoothTransportErrorand is safe forRouterto retry. Every otherBleakError/TimeoutErrorfromwrite_gatt_charhappens inside backend I/O where delivery is unprovable, so_sendraces any armed broadcast watcher for the rest of the window and otherwise raises plainBluetoothTimeout._send_optimisticgets the same treatment explicitly since it bypasses the ladder. Tests:test_ble_send_transport.py,test_ble_write_timeout_router.py. - The confirmation ladder is one
confirmationenum plus oneraise_unconfirmedbool:confirmation("optimistic" | "ack" | "verify", default"ack") picks how many of write → ack-or-broadcast wait → state-read run;raise_unconfirmed(defaultFalse) picks what happens when the ladder still can't tell."optimistic"signs and writes but never waits — a provably pre-submission write failure still raisesBluetoothTransportError, but a submitted-then-ambiguous write followsraise_unconfirmedlike every other rung."verify"adds a post-timeout state read (_resolve_timeoutagainst_vcsec_verify_plan/_INFOTAINMENT_VERIFY_PLANS, covering only clearly derivable absolute commands) returning success on a match,BluetoothCommandFailedon a proven mismatch, orNone(falls through toraise_unconfirmed) if the read itself failed. Commands with no plan (true toggles, relative steps, ack-only actions) always fall through. The legacyoptimistic/verify_commandsbooleans are deprecated: both warn and map ontoconfirmation, and survive as read-only properties. - Broadcast-as-confirmation races the ack wait for lock/unlock: the vehicle
keeps emitting unsolicited VCSEC status broadcasts even when it emits no
addressed ack.
_send'sconfirm_broadcastarms a per-domain watcher (_broadcast_watchers) that decodes broadcast frames and races them against the addressed reply; first to satisfy the plan's predicate wins, and only the addressed path can raise a car-side rejection. A mismatching broadcast does not fail fast (a later one could still confirm), but a mismatch standing at window-end raisesBluetoothCommandFailedrather than an ambiguous timeout. Reuses the"verify"rung's predicate; currently only lock/unlock has an observed status broadcast. Tests:test_ble_broadcast_confirmation.py. expects_datasplits reply-waiting: a VCSEC read replies with a bare ACK then a data frame; a VCSEC actuation replies with a single bare ACK only._sendcannot tell them apart, so the caller declares it —_sendVehicleSecuritypassesexpects_data=False(returns on the matching ACK, and on a lost ack reaches the unresolved outcome after the shorter_actuation_timeoutrather than_default_timeout); everything else keeps the defaultTrue.pair()confirms two ways and writes the whitelist op exactly once: the success frame is single-shot and lost forever if the link cycles while the user walks to the car.pair()waits onepoll_intervalfor the reply, then polls_pair_probe()(a VCSEC_handshakewith our own key, which faultsNotOnWhitelistFaultuntil whitelisted; anyTeslaFleetErrormeans "not yet", so polling survives reconnects) untiltimeout. Never re-send the whitelist op — it re-prompts the user. Deadline with neither path confirming raisesBluetoothTimeout.- Idle keepalive: an idle held link to the vehicle drops at ~42s mean; a
trivial passive GATT read on an idle cadence extends the session ~10x, so
keepalive_interval(defaultDEFAULT_KEEPALIVE_INTERVAL,None/0disables) starts one task per connection readingVERSION_UUIDafter that many seconds of genuine GATT idleness —_last_activityis bumped by every_sendwrite and every notify, so an active session gets no extra traffic. The read is bounded and best-effort: every attempt is timed out and swallows all failures exceptCancelledError; it must never raise into user code, trigger reconnect, or wake the car. Lifecycle is tied to the connection (started at the end ofconnect(), cancelled-and-awaited indisconnect()). Tradeoff: these reads keep an awake car awake and defer sleep — disable keepalive or disconnect when the car should sleep. - Broadcast listeners (
tesla/vehicle/broadcast.py):VehicleBluetoothfans VCSEC status broadcasts out to long-lived per-field listeners from the same_on_message. Each modeledVehicleStatusleaf has a typedlisten_<field>; anything not decoded is covered bylisten_broadcast(domain, callback). Closure/tonneau-percent listeners gate onHasField(real proto3 presence); the five scalar enum fields have none, so they fire on every status broadcast, not only on change. Each returns anunsubscribe(); registries live for the instance's lifetime and survive reconnects, like_queues. Callback exceptions are logged and isolated from later listeners and message routing, exceptKeyboardInterrupt/SystemExit.listen_connection_status()reports session transitions including unexpected transport loss; the contract isdocs/bluetooth_vehicles.md#connection-status-events. BleBroadcastStreamGlue(tesla/vehicle/stream_glue.py) never importsteslemetry_stream: it wires BLE broadcast listeners tosink.ingest(data, metadata)against a local structuralStreamSinkProtocol, the same duck-typed patternEnergySiteRouteruses —TeslemetryStream'singest()satisfies it with no coupling or dependency either direction. It reusesfunnel.py'sLOCK_STATES/CLOSURE_STATES(module-level, not underscore-private, precisely so this cross-module import is legal under strict pyright) and addsGEAR_STATES/TONNEAU_POSITION_STATESfor the two fields teslemetry-stream carries as<Prefix><Option>wire strings ("ShiftStateP"), verified against that package's listeners rather than guessed. Unmapped by design: tonneau OPENING/CLOSING (no in-transit state to translate to), plus sleep status (theingest()payload is always nested under the signal-topic key, with no way to produce astate-topic event), user presence and UI desire (noSignalentry upstream). Push-only — no demand gating, since VCSEC broadcasts regardless of listeners.stop()unsubscribes everything and is idempotent.False, notNone, is the "signing disabled" value forCommands.__init__'sprivate_key(and thekeyargument ofVehicleBluetooth.__init__and thecreate*factories).Nonekeeps its long-standing meaning of falling back to the parent's key and raisingValueError("No private key.")if it has none; a caller passingNoneto mean "I haven't got one" must keep getting that error, not a silently unsignable vehicle. BecauseFalseandNoneare both falsy, every branch on this argument must test identity (is False/is not None) — a truthiness check collapses the two states.self.private_key is Nonemeans signing-disabled and makes_handshakeraiseSigningDisabledup front._handshakeis not a single choke point:pair()'s fast path builds and sends its own whitelist request, so it carries its own identical guard — any new signed-session entry point that skips_handshakeneeds one too.
- Infotainment boot delay:
wake_up()is VCSEC and returns as soon as the vehicle-security computer acks, well before infotainment can complete a signed handshake. An INFO read/command issued immediately after can raiseBluetoothTimeoutthrough no fault of its own — retry with backoff.wake_up()is best-effort: an unresolved wake is an inconclusive signal, not failure. Confirm readiness with a cheap INFO read, and hold one connection across a batch of related commands rather than reconnecting between each. vehicle_data()response-size cap: the vehicle's signed-command implementation enforces its own response-size limit independent of BLE reassembly. One endpoint succeeds; as few as twoBluetoothVehicleDataendpoints together reliably raiseTeslaFleetMessageFaultResponseSizeExceedsMTU. That is why the BLEvehicle_data()has no all-endpoints default — prefer the per-substate readers.- Individual doors have no reliable powered close (Model 3):
open_*_door()unlatches over VCSEC, and an ack from a close command only means the car accepted it, not that the door re-latched — a human must push it shut. Never chain an automated snapshot→act→verify→restore cycle across an individual door-open command. - Media state observability:
MediaState.now_playing_artist/titleand all ofMediaDetailStateare only populated for some sources (USB/Bluetooth, not Spotify), somedia_next_track/media_prev_track/media_next_fav/media_prev_favare not reliably state-observable — verify by ACK and pair with the inverse command.audio_volume/media_playback_statusare reliable provers.
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.