Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,10 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te
- **`set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` args are dead**: live-verified - `ScheduledDepartureAction` (`proto/car_server.proto`) has no fields for them, only `preconditioning_times`/`off_peak_charging_times` (weekday-recurrence only, no on/off). Passing `preconditioning_enabled=False` has no effect on the vehicle's observed state. Not a library bug to "fix" without a wider protocol capability; document, don't rely on these args to gate the feature.
- **`charge_standard()` rejects `already_standard`**: live-verified - calling it while `charge_state().charge_limit_soc` already equals `charge_limit_soc_std` gets `{"result": False, "reason": "already_standard"}` rather than a no-op success. Not a library bug; callers/tests exercising this command need the limit to actually differ from the std preset first (e.g. via `charge_max_range()` or `set_charge_limit()`).
- **BLE media state-observability gotcha**: `MediaState.now_playing_artist/title` and all of `MediaDetailState` (`now_playing_album/station/source_string/elapsed/duration`) were observed empty/zero on the test car while Spotify was actively `Playing` at nonzero volume - these legacy fields are apparently only populated for certain sources (e.g. USB/Bluetooth), not Spotify. Don't assume `media_next_track`/`media_prev_track`/`media_next_fav`/`media_prev_fav` are state-observable via these readers; verify by ACK (`{"result": True}`) and pair with the inverse command when the fingerprint doesn't change. `audio_volume`/`media_playback_status` (for `adjust_volume`/`media_volume_up`/`media_volume_down`/`media_toggle_playback`) were populated correctly and are reliable provers.
- **BLE mutating-command timeout is inconclusive - never assume "the write didn't land"**: this corrects an earlier version of this note, which observed `adjust_volume` write timeouts on one rig leaving the car unmutated and concluded a write timeout means the write never landed. Later live testing disproved that as a general rule: `door_unlock` and `door_lock` each raised `BluetoothTimeout` (~5.5-5.9s) yet both physically executed - a VCSEC state read after each confirmed the lock state had flipped. Reads (state readers, `wake_up()`'s effect) came back reliably all night; only mutating VCSEC/RKE actions showed this false-negative pattern, most likely because the vehicle doesn't reliably return an ack `_send()` observes within the timeout, not because the write failed. Treat a `BluetoothTimeout` from any mutating BLE command as **inconclusive, not failure**: snapshot state before acting, then verify the outcome with a follow-up state read whenever a mutation times out. Never blind-retry a non-idempotent command (toggles like `media_toggle_playback`, volume steps, schedule add/remove) on timeout alone - see the retry-double-execution entry below for why the library's own retry has the same exposure. The `adjust_volume`/`TimeoutAPIError` root cause from the original observation is still unexplained; it just isn't evidence that timed-out writes never land.
- **`wake_up()`'s own ACK is an unreliable signal - almost always a false-negative timeout**: live-verified 9/9 across two independent sessions - `wake_up()` raises `BluetoothTimeout` regardless of whether the vehicle was asleep, already awake, freshly connected, or held open for minutes, yet the wake (or no-op) reliably took effect - an immediate follow-up state read on the same connection succeeds. Never treat a `wake_up()` `BluetoothTimeout` as command failure; call it best-effort (catch and ignore) and 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.
- **BLE mutating-command timeout is inconclusive - never assume "the write didn't land"**: this corrects an earlier version of this note, which observed `adjust_volume` write timeouts on one rig leaving the car unmutated and concluded a write timeout means the write never landed. Later live testing disproved that as a general rule: `door_unlock` and `door_lock` each raised `BluetoothTimeout` yet both physically executed - a VCSEC state read after each confirmed the lock state had flipped. Reads (state readers, `wake_up()`'s effect) came back reliably all night; only mutating VCSEC/RKE actions showed this false-negative pattern, most likely because the vehicle doesn't reliably return an ack `_send()` observes within the timeout, not because the write failed. Treat a `BluetoothTimeout` from any mutating BLE command as **inconclusive, not failure**: snapshot state before acting, then verify the outcome with a follow-up state read whenever a mutation times out. Never blind-retry a non-idempotent command (toggles like `media_toggle_playback`, volume steps, schedule add/remove) on timeout alone - see the retry-double-execution entry below for why the library's own retry has the same exposure. The `adjust_volume`/`TimeoutAPIError` root cause from the original observation is still unexplained; it just isn't evidence that timed-out writes never land. VCSEC actuations now use the shorter `_actuation_timeout` when their terminal ack is lost.
- **`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 a `BluetoothTimeout` is still only an inconclusive wake signal, not command failure. Call it best-effort (catch and ignore timeout) and confirm readiness by retrying a cheap INFO read instead (see the boot-delay gotcha above for why the first INFO read still needs its own retry/backoff). Hold one connection across a whole batch of related commands rather than reconnecting between each - reconnecting costs ~123% more per operation with no demonstrated wake-preservation benefit from the connection alone.
- **The signed-command retry in `Commands._command` can double-execute a mutating command**: on an `OPERATIONSTATUS_WAIT` reply or an `INCORRECT_EPOCH`/`INVALID_TOKEN` fault, `_command` (`commands.py`) re-signs and re-sends the identical command, bounded at 3 attempts then a clean `{"result": False, "reason": "Too many retries"}` - the cap itself is safe and doesn't loop. Live-verified deterministically: a WAIT-then-OK sequence produces 2 physical sends of the same command on the wire. Combined with the mutating-timeout-is-inconclusive gotcha above (a command can execute despite a WAIT/fault reply), this retry is a latent double-apply window. Harmless for a naturally idempotent command (lock/unlock), a real correctness risk for toggles and step commands (`media_toggle_playback`, `media_volume_up`/`down`, schedule add/remove) - verify those by absolute state after the call, never by counting invocations or trusting the retry to be safe.
- **`expects_data` splits BLE reply-waiting: VCSEC actuations return on the terminal ack**: a VCSEC read replies with a bare ACK **then** a data frame, but a VCSEC actuation (RKE/closure/wake via `_sendVehicleSecurity`) replies with a **single bare ACK only** - `_send` cannot tell the two apart at transport level, so the caller declares it. `_sendVehicleSecurity` passes `expects_data=False` down through `_command` into `_send` (`commands.py`/`bluetooth.py`); everything else (VCSEC reads via `_getVehicleSecurity`, all infotainment via `_send/_getInfotainment`, `_handshake`, `pair`) keeps the default `expects_data=True`. With `expects_data=False`, `_send` returns immediately on the matching ACK instead of waiting out `_ack_followup_timeout` (was a flat ~2s tax on every VCSEC actuation), and on a lost ack it raises `BluetoothTimeout` after the shorter `_actuation_timeout` (2s) rather than `_default_timeout` (5s) - the verify-by-state contract makes a longer wait pointless. Infotainment actuations never paid the tax (their `actionStatus` rides in `protobuf_message_as_bytes`, so `_send` returns on that data frame). The WAIT/fault retry threads `expects_data` through unchanged, so the double-execute exposure noted above is unaffected.
- **`navigation_gps_request`'s `order` param is a raw int, not a callable enum**: `commands.py` used to build it as `NavigationGpsRequest.RemoteNavTripOrder(order)`, treating the protobuf nested-enum wrapper (`EnumTypeWrapper`) as if it were a callable Python `enum.IntEnum` class - it isn't, so every call raised `TypeError` before any message was sent (found live during PR-8; this method had never been exercised over BLE before). Fixed to pass `order=order` directly (matching the working sibling `navigation_gps_destination_request`), which protobuf accepts as a bare int for an enum field at runtime.
- **`ReassemblingBuffer` resets on a >1s inter-chunk gap, not just on decode failure**: `bluetooth.py`'s `ReassemblingBuffer.receive_data` discards any in-progress partial frame if the next chunk arrives more than `STALE_CHUNK_TIMEOUT` (1s) after the previous one, mirroring Tesla's official Go SDK (`teslamotors/vehicle-command`, `pkg/connector/ble/ble.go`'s `rxTimeout`). Without this, a chunk dropped mid-message left a stale partial in the buffer that got prepended to the next message, corrupting it until a lucky decode failure resynced. This is a frame-integrity hardening, not a fix for the separate ack-loss behavior documented above (that's a stalled/silent link, which no buffer-side reset can recover).

Expand Down
6 changes: 6 additions & 0 deletions docs/bluetooth_vehicles.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ acknowledgement does not reach the client. For commands that change vehicle
state, snapshot the relevant state before acting and verify the outcome with a
follow-up state read after any timeout.

VCSEC actuations, such as RKE, closure, and wake requests, return as soon as
their terminal acknowledgement arrives because no data frame follows it. If that
acknowledgement is lost, they use a shorter response timeout than reads; the
timeout is still inconclusive and should be handled with the same verify-by-state
pattern.

Do not blind-retry non-idempotent commands, such as media toggles, volume steps,
or schedule add/remove operations, on timeout alone. The signed-command retry
inside the library can also re-send an identical command after a WAIT status or
Expand Down
37 changes: 32 additions & 5 deletions tesla_fleet_api/tesla/vehicle/bluetooth.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ class VehicleBluetooth(Commands[BluetoothParentT], Generic[BluetoothParentT]):
_buffer: ReassemblingBuffer
_auth_method = "aes"
_ack_followup_timeout: float = 2
_default_timeout: float = 5
# A lost actuation ack is inconclusive; the contract is verify-by-state, so
# a shorter wait than a data read's before raising loses nothing.
_actuation_timeout: float = 2

def __init__(
self,
Expand Down Expand Up @@ -355,9 +359,22 @@ def _on_message(self, msg: RoutableMessage) -> None:
queue.put_nowait(msg)

async def _send(
self, msg: RoutableMessage, requires: str, timeout: int = 5
self,
msg: RoutableMessage,
requires: str,
expects_data: bool = True,
*,
timeout: float | None = None,
) -> RoutableMessage:
"""Serialize a message and send to the vehicle and wait for a response."""
"""Serialize a message and send to the vehicle and wait for a response.

When ``expects_data`` is False the reply is a single terminal ack (a
VCSEC actuation, no data frame follows), so the ack is returned as soon
as it arrives and a shorter total timeout applies.
"""

if timeout is None:
timeout = self._default_timeout if expects_data else self._actuation_timeout

domain = msg.to_destination.domain
async with self._sessions[domain].lock:
Expand Down Expand Up @@ -397,6 +414,10 @@ async def _send(
# Some commands (e.g. RKE wake/lock) only return an ACK with no data.
# Wait briefly for a follow-up data response before returning the ACK.
if msg.uuid and resp.request_uuid == msg.uuid:
if not expects_data:
# A VCSEC actuation's ack is terminal; no data
# frame follows, so return without the wait.
return resp
LOGGER.debug(
"Received ACK for our request, waiting briefly for data follow-up"
)
Expand Down Expand Up @@ -549,11 +570,17 @@ async def query_version(self) -> int | None:
return None

async def _command(
self, domain: Domain, command: bytes, attempt: int = 0
self,
domain: Domain,
command: bytes,
attempt: int = 0,
expects_data: bool = True,
) -> dict[str, Any]:
"""Serialize a message and send to the signed command endpoint."""
await self.connect_if_needed()
return await super()._command(domain, command, attempt)
return await super()._command(
domain, command, attempt, expects_data=expects_data
)

async def pair(
self,
Expand All @@ -577,7 +604,7 @@ async def pair(
protobuf_message_as_bytes=request.SerializeToString(),
uuid=randbytes(16),
)
resp = await self._send(msg, "protobuf_message_as_bytes", timeout)
resp = await self._send(msg, "protobuf_message_as_bytes", timeout=timeout)
respMsg = FromVCSECMessage.FromString(resp.protobuf_message_as_bytes)
if respMsg.commandStatus.whitelistOperationStatus.whitelistOperationInformation:
if (
Expand Down
48 changes: 38 additions & 10 deletions tesla_fleet_api/tesla/vehicle/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,15 @@ def shared_key(self, vehicleKey: bytes) -> bytes:
return hashlib.sha1(exchange).digest()[:16]

@abstractmethod
async def _send(self, msg: RoutableMessage, requires: str) -> RoutableMessage:
"""Transmit the message to the vehicle."""
async def _send(
self, msg: RoutableMessage, requires: str, expects_data: bool = True
) -> RoutableMessage:
"""Transmit the message to the vehicle.

``expects_data`` is False when the reply is a single terminal ack with
no following data frame (a VCSEC actuation), letting a framing
transport return on that ack instead of waiting out a data follow-up.
"""
raise NotImplementedError

def validate_msg(self, msg: RoutableMessage) -> None:
Expand All @@ -352,14 +359,21 @@ def validate_msg(self, msg: RoutableMessage) -> None:
raise exception

async def _command(
self, domain: Domain, command: bytes, attempt: int = 0
self,
domain: Domain,
command: bytes,
attempt: int = 0,
expects_data: bool = True,
) -> dict[str, Any]:
"""Serialize a message and send to the signed command endpoint.

On a WAIT status or an epoch/token fault, this re-signs and re-sends
the identical command (bounded at 3 attempts) - for a non-idempotent
command that window can apply it twice if the first attempt actually
executed despite the WAIT/fault reply.

``expects_data`` is threaded to ``_send`` and every retry; it is False
for a VCSEC actuation, whose reply is a bare terminal ack.
"""
session = self._sessions[domain]
if not session.ready:
Expand All @@ -374,7 +388,9 @@ async def _command(
raise ValueError(f"Unknown auth method: {self._auth_method}")

try:
resp = await self._send(msg, "protobuf_message_as_bytes")
resp = await self._send(
msg, "protobuf_message_as_bytes", expects_data=expects_data
)
except (
# TeslaFleetMessageFaultInvalidSignature,
TeslaFleetMessageFaultIncorrectEpoch,
Expand All @@ -384,7 +400,9 @@ async def _command(
if attempt > 3:
# We tried 3 times, give up, raise the error
raise e
return await self._command(domain, command, attempt)
return await self._command(
domain, command, attempt, expects_data=expects_data
)

if (
resp.signedMessageStatus.operation_status
Expand All @@ -396,7 +414,9 @@ async def _command(
return {"response": {"result": False, "reason": "Too many retries"}}
async with session.lock:
await sleep(2)
return await self._command(domain, command, attempt)
return await self._command(
domain, command, attempt, expects_data=expects_data
)

if resp.HasField("protobuf_message_as_bytes"):
# decrypt
Expand Down Expand Up @@ -498,7 +518,9 @@ async def _command(
}
async with session.lock:
await sleep(2)
return await self._command(domain, command, attempt)
return await self._command(
domain, command, attempt, expects_data=expects_data
)
elif (
vcsec.commandStatus.operationStatus
== OperationStatus_E.OPERATIONSTATUS_ERROR
Expand Down Expand Up @@ -652,13 +674,19 @@ async def _commandAes(
)

async def _sendVehicleSecurity(self, command: UnsignedMessage) -> dict[str, Any]:
"""Sign and send a message to Infotainment computer."""
"""Sign and send an actuation to the Vehicle Security computer.

A VCSEC actuation replies with a single terminal ack and no data frame,
so ``expects_data=False`` lets ``_send`` return on that ack.
"""
return await self._command(
Domain.DOMAIN_VEHICLE_SECURITY, command.SerializeToString()
Domain.DOMAIN_VEHICLE_SECURITY,
command.SerializeToString(),
expects_data=False,
)

async def _getVehicleSecurity(self, command: UnsignedMessage) -> VehicleStatus:
"""Sign and send a message to Infotainment computer."""
"""Sign and send a read request to the Vehicle Security computer."""
reply = await self._command(
Domain.DOMAIN_VEHICLE_SECURITY, command.SerializeToString()
)
Expand Down
13 changes: 8 additions & 5 deletions tesla_fleet_api/tesla/vehicle/signed.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,24 @@
SignedParentT = TypeVar("SignedParentT", bound="TeslaFleetApi")


class VehicleSigned(Commands[SignedParentT], VehicleFleet[SignedParentT], Generic[SignedParentT]): # pyright: ignore[reportIncompatibleMethodOverride]
class VehicleSigned( # pyright: ignore[reportIncompatibleMethodOverride]
Commands[SignedParentT], VehicleFleet[SignedParentT], Generic[SignedParentT]
):
"""Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing."""


_auth_method = "hmac"

def __init__(self, parent: SignedParentT, vin: str):
"""Initialize the VehicleSigned class."""
super().__init__(parent, vin)
super(Commands, self).__init__(parent, vin)


async def _send(self, msg: RoutableMessage, requires: str) -> RoutableMessage:
async def _send(
self, msg: RoutableMessage, requires: str, expects_data: bool = True
) -> RoutableMessage:
"""Serialize a message and send to the signed command endpoint."""
# requires isnt used because Fleet API messages are singular
# requires and expects_data are unused: Fleet API replies are singular,
# delivered whole in one response with no separate terminal ack frame.

async with self._sessions[msg.to_destination.domain].lock:
json = await self.signed_command(
Expand Down
Loading
Loading