From 44ebeef5bb010069f9b1ba550bfeaefec32542cf Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 18:57:51 +1000 Subject: [PATCH 1/3] test(ble): add regression tests for all 14 state readers, default vehicle_data() to all endpoints Covers the PR-2 read-path gap: every BLE state reader now has a mocked-transport regression test, and vehicle_data(endpoints=None) defaults to all BLE endpoints (matching the cloud vehicle_data() ergonomics) with the proto-vs-REST divergence and the live-verified response-size cap documented in the docstring. --- AGENTS.md | 2 + tesla_fleet_api/tesla/vehicle/bluetooth.py | 35 ++- tests/ble_mocked_transport.py | 10 + tests/test_ble_mocked_state_readers.py | 239 +++++++++++++++++++++ 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 tests/test_ble_mocked_state_readers.py diff --git a/AGENTS.md b/AGENTS.md index b6fdd71..c30c499 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,8 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te - **Naming**: camelCase for class instance attributes that mirror API structure (`energySites`, `createFleet`). Snake_case for method names that are API endpoints. - **BLE discovery gotcha**: a Tesla vehicle advertises no 128-bit service UUID pre-connect — only its VIN-derived local name (`^S[a-f0-9]{16}[CDRP]$`), and only in the scan response, not the `ADV_IND`. `SERVICE_UUID` (`tesla_fleet_api/tesla/vehicle/bluetooth.py`) exists only as a GATT service after connecting. Never pass `service_uuids=[SERVICE_UUID]` as a `BleakScanner` discovery-time filter — it hides the vehicle on a direct BlueZ adapter (an ESPHome proxy doesn't enforce that filter the same way, which can mask the bug in testing). Scan unfiltered with active scanning and match by name; keep `SERVICE_UUID` for post-connect GATT use only. - **BLE domain-routing gotcha**: `Domain` (`proto/universal_message.proto`) has more values (`DOMAIN_BROADCAST`, `DOMAIN_AUTHD`, ...) than `VehicleBluetooth._queues` has keys (only `DOMAIN_VEHICLE_SECURITY`/`DOMAIN_INFOTAINMENT`). `_on_message` (`tesla_fleet_api/tesla/vehicle/bluetooth.py`) must look up `_queues` with `.get()` and drop unrecognized domains rather than indexing directly — indexing raises `KeyError` inside the `ReassemblingBuffer` callback, aborting reassembly of any further already-buffered messages in that notification. +- **BLE infotainment boot-delay gotcha**: `wake_up()` (VCSEC) returns as soon as the vehicle-security computer acks it, well before the infotainment computer is ready to complete a signed-command handshake. An INFO-domain read/command issued immediately after `wake_up()` can raise `BluetoothTimeout` on the handshake through no fault of the command itself. Live-verified: waiting ~10s after `wake_up()` before the first INFO read is sufficient on the test rig; callers doing INFO work right after waking should retry-with-backoff rather than treat one timeout as failure. +- **BLE `vehicle_data()` response-size cap**: the vehicle's signed-command implementation enforces its own response-size limit independent of the BLE transport's packet reassembly. Live-verified: a single-endpoint `vehicle_data()` call (or any of the dedicated per-substate readers like `charge_state()`) succeeds, but requesting as few as two `BluetoothVehicleData` endpoints together reliably raises `TeslaFleetMessageFaultResponseSizeExceedsMTU` (`exceptions.py`) — this includes the `endpoints=None` "all" default. Prefer the per-substate readers, or a single-endpoint `vehicle_data()` call, over the multi-endpoint composite. ## Maintaining this file diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index 2f96b7a..edb1f7d 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -545,8 +545,39 @@ async def wake_up(self): UnsignedMessage(RKEAction=RKEAction_E.RKE_ACTION_WAKE_VEHICLE) ) - async def vehicle_data(self, endpoints: list[BluetoothVehicleData]) -> VehicleData: - """Get vehicle data.""" + async def vehicle_data( + self, endpoints: list[BluetoothVehicleData] | None = None + ) -> VehicleData: + """Get vehicle data over the BLE infotainment channel. + + ``endpoints`` defaults to every ``BluetoothVehicleData`` member (all + BLE-readable sub-states), mirroring the cloud ``vehicle_data()`` + "no argument = everything" ergonomics. + + This is a BLE-specific method, not a re-implementation of the cloud + one, and it diverges from ``VehicleFleet.vehicle_data()`` in two ways + that a cloud user porting to BLE should not be surprised by: + + - ``endpoints`` takes ``BluetoothVehicleData`` (proto request names + like ``"GetChargeState"``), not the cloud ``VehicleDataEndpoint`` + (REST names like ``"charge_state"``) - the two enums are not + interchangeable. + - The return type is the ``VehicleData`` protobuf message built from + the signed-command reply, not a REST JSON ``dict``. Use + ``tesla_fleet_api.tesla.bluetooth.toDict``/``toJson`` to convert it + if a dict is needed. + + Requesting more than one sub-state in a single call risks exceeding + the vehicle's signed-command response-size cap, raised as + ``TeslaFleetMessageFaultResponseSizeExceedsMTU`` - observed live with + as few as two endpoints, and reliably with the ``None`` default's + full set. The individual state readers (``charge_state()``, + ``climate_state()``, etc.) each issue their own single-endpoint + request and are not subject to this cap; prefer them, or a + narrow explicit ``endpoints`` subset, over the ``None`` default. + """ + if endpoints is None: + endpoints = list(BluetoothVehicleData) return await self._getInfotainment( Action( vehicleAction=VehicleAction( diff --git a/tests/ble_mocked_transport.py b/tests/ble_mocked_transport.py index 8287565..c3e0646 100644 --- a/tests/ble_mocked_transport.py +++ b/tests/ble_mocked_transport.py @@ -35,6 +35,7 @@ from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( CommandStatus, FromVCSECMessage, + VehicleStatus, ) from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( OperationStatus_E as VcsecOperationStatus_E, @@ -57,6 +58,15 @@ def vcsec_ok_reply() -> RoutableMessage: ) +def vcsec_vehicle_status_reply(vehicle_status: VehicleStatus) -> RoutableMessage: + """A canned VCSEC reply carrying a ``vehicleStatus`` (as returned by an InformationRequest).""" + body = FromVCSECMessage(vehicleStatus=vehicle_status) + return RoutableMessage( + from_destination=Destination(domain=Domain.DOMAIN_VEHICLE_SECURITY), + protobuf_message_as_bytes=body.SerializeToString(), + ) + + def infotainment_action_ok_reply() -> RoutableMessage: """A canned infotainment reply as returned by a signed action that succeeded.""" body = Response( diff --git a/tests/test_ble_mocked_state_readers.py b/tests/test_ble_mocked_state_readers.py new file mode 100644 index 0000000..efc1f80 --- /dev/null +++ b/tests/test_ble_mocked_state_readers.py @@ -0,0 +1,239 @@ +"""Regression tests for all 14 BLE state readers over the mocked transport. + +Each reader is defined on ``VehicleBluetooth`` (not inherited from +``Commands``) and composes a ``GetVehicleData`` request; these tests feed a +canned reply and assert the reader decodes it to the expected typed proto. +""" + +from tesla_fleet_api.const import BluetoothVehicleData +from tesla_fleet_api.tesla.vehicle.proto.car_server_pb2 import Action +from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( + VehicleLockState_E, + VehicleStatus, +) +from tesla_fleet_api.tesla.vehicle.proto.vehicle_pb2 import ( + ChargeScheduleState, + ChargeState, + ClimateState, + ClosuresState, + DriveState, + LocationState, + MediaDetailState, + MediaState, + ParentalControlsState, + PreconditioningScheduleState, + SoftwareUpdateState, + TirePressureState, + VehicleData, +) + +from ble_mocked_transport import ( + MockedBleTransportTestCase, + decrypt_sent_command, + infotainment_vehicle_data_reply, + vcsec_vehicle_status_reply, +) + + +class ChargeStateTests(MockedBleTransportTestCase): + async def test_charge_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(charge_state=ChargeState(battery_level=42)) + ) + result = await vehicle.charge_state() + self.assertIsInstance(result, ChargeState) + self.assertEqual(result.battery_level, 42) + + +class ClimateStateTests(MockedBleTransportTestCase): + async def test_climate_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(climate_state=ClimateState(inside_temp_celsius=21.5)) + ) + result = await vehicle.climate_state() + self.assertIsInstance(result, ClimateState) + self.assertAlmostEqual(result.inside_temp_celsius, 21.5) + + +class DriveStateTests(MockedBleTransportTestCase): + async def test_drive_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(drive_state=DriveState(speed=55)) + ) + result = await vehicle.drive_state() + self.assertIsInstance(result, DriveState) + self.assertEqual(result.speed, 55) + + +class LocationStateTests(MockedBleTransportTestCase): + async def test_location_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(location_state=LocationState(latitude=37.4, longitude=-122.1)) + ) + result = await vehicle.location_state() + self.assertIsInstance(result, LocationState) + self.assertAlmostEqual(result.latitude, 37.4, places=5) + self.assertAlmostEqual(result.longitude, -122.1, places=5) + + +class ClosuresStateTests(MockedBleTransportTestCase): + async def test_closures_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(closures_state=ClosuresState(door_open_driver_front=True)) + ) + result = await vehicle.closures_state() + self.assertIsInstance(result, ClosuresState) + self.assertTrue(result.door_open_driver_front) + + +class ChargeScheduleStateTests(MockedBleTransportTestCase): + async def test_charge_schedule_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(charge_schedule_state=ChargeScheduleState(charge_buffer=10)) + ) + result = await vehicle.charge_schedule_state() + self.assertIsInstance(result, ChargeScheduleState) + self.assertEqual(result.charge_buffer, 10) + + +class PreconditioningScheduleStateTests(MockedBleTransportTestCase): + async def test_preconditioning_schedule_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData( + preconditioning_schedule_state=PreconditioningScheduleState( + max_num_precondition_schedules=5 + ) + ) + ) + result = await vehicle.preconditioning_schedule_state() + self.assertIsInstance(result, PreconditioningScheduleState) + self.assertEqual(result.max_num_precondition_schedules, 5) + + +class TirePressureStateTests(MockedBleTransportTestCase): + async def test_tire_pressure_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(tire_pressure_state=TirePressureState(tpms_pressure_fl=2.9)) + ) + result = await vehicle.tire_pressure_state() + self.assertIsInstance(result, TirePressureState) + self.assertAlmostEqual(result.tpms_pressure_fl, 2.9, places=5) + + +class MediaStateTests(MockedBleTransportTestCase): + async def test_media_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData( + media_state=MediaState(audio_volume=7.0, now_playing_title="Song") + ) + ) + result = await vehicle.media_state() + self.assertIsInstance(result, MediaState) + self.assertAlmostEqual(result.audio_volume, 7.0) + self.assertEqual(result.now_playing_title, "Song") + + +class MediaDetailStateTests(MockedBleTransportTestCase): + async def test_media_detail_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData( + media_detail_state=MediaDetailState(now_playing_duration=180000) + ) + ) + result = await vehicle.media_detail_state() + self.assertIsInstance(result, MediaDetailState) + self.assertEqual(result.now_playing_duration, 180000) + + +class SoftwareUpdateStateTests(MockedBleTransportTestCase): + async def test_software_update_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(software_update_state=SoftwareUpdateState(version="2025.14.3")) + ) + result = await vehicle.software_update_state() + self.assertIsInstance(result, SoftwareUpdateState) + self.assertEqual(result.version, "2025.14.3") + + +class ParentalControlsStateTests(MockedBleTransportTestCase): + async def test_parental_controls_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData( + parental_controls_state=ParentalControlsState( + parental_controls_active=True + ) + ) + ) + result = await vehicle.parental_controls_state() + self.assertIsInstance(result, ParentalControlsState) + self.assertTrue(result.parental_controls_active) + + +class VehicleStateTests(MockedBleTransportTestCase): + """``vehicle_state`` is the VCSEC ``VehicleStatus`` reader (not the infotainment ``VehicleState``, see §2.3).""" + + async def test_vehicle_state(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = vcsec_vehicle_status_reply( + VehicleStatus(vehicleLockState=VehicleLockState_E.VEHICLELOCKSTATE_LOCKED) + ) + result = await vehicle.vehicle_state() + self.assertIsInstance(result, VehicleStatus) + self.assertEqual( + result.vehicleLockState, VehicleLockState_E.VEHICLELOCKSTATE_LOCKED + ) + + +class VehicleDataTests(MockedBleTransportTestCase): + """``vehicle_data`` composite reader, including the ``endpoints=None`` default-to-all behavior.""" + + async def test_explicit_endpoints_requests_only_those_substates(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply( + VehicleData(charge_state=ChargeState(battery_level=77)) + ) + + result = await vehicle.vehicle_data([BluetoothVehicleData.CHARGE_STATE]) + + self.assertIsInstance(result, VehicleData) + self.assertEqual(result.charge_state.battery_level, 77) + + send.assert_awaited_once() + sent_msg = send.await_args.args[0] + + plaintext = decrypt_sent_command(vehicle, sent_msg) + action = Action.FromString(plaintext) + get_vehicle_data = action.vehicleAction.getVehicleData + self.assertTrue(get_vehicle_data.HasField("getChargeState")) + self.assertFalse(get_vehicle_data.HasField("getClimateState")) + + async def test_no_endpoints_defaults_to_requesting_all_substates(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_vehicle_data_reply(VehicleData()) + + await vehicle.vehicle_data() + + send.assert_awaited_once() + sent_msg = send.await_args.args[0] + + plaintext = decrypt_sent_command(vehicle, sent_msg) + action = Action.FromString(plaintext) + get_vehicle_data = action.vehicleAction.getVehicleData + for member in BluetoothVehicleData: + field_name = member.value[0].lower() + member.value[1:] + self.assertTrue( + get_vehicle_data.HasField(field_name), + f"expected {field_name} to be requested by default", + ) From d10ca630acb7bf1581cf4b0dfd27c1c32a3976e3 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 19:01:43 +1000 Subject: [PATCH 2/3] fix(ble): keep vehicle_data() endpoints required, drop all-endpoints default A no-arg vehicle_data() defaulting to every endpoint reliably raises TeslaFleetMessageFaultResponseSizeExceedsMTU on real hardware (a multi-endpoint composite call overflows the vehicle's own signed-command response-size cap) - a footgun for a public method. endpoints stays required; the cap and the per-substate-reader/narrow- subset workaround remain documented, with auto-chunking noted as a possible future enhancement rather than implemented here. --- AGENTS.md | 2 +- tesla_fleet_api/tesla/vehicle/bluetooth.py | 34 +++++++++------------- tests/test_ble_mocked_state_readers.py | 22 ++------------ 3 files changed, 17 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c30c499..2812445 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te - **BLE discovery gotcha**: a Tesla vehicle advertises no 128-bit service UUID pre-connect — only its VIN-derived local name (`^S[a-f0-9]{16}[CDRP]$`), and only in the scan response, not the `ADV_IND`. `SERVICE_UUID` (`tesla_fleet_api/tesla/vehicle/bluetooth.py`) exists only as a GATT service after connecting. Never pass `service_uuids=[SERVICE_UUID]` as a `BleakScanner` discovery-time filter — it hides the vehicle on a direct BlueZ adapter (an ESPHome proxy doesn't enforce that filter the same way, which can mask the bug in testing). Scan unfiltered with active scanning and match by name; keep `SERVICE_UUID` for post-connect GATT use only. - **BLE domain-routing gotcha**: `Domain` (`proto/universal_message.proto`) has more values (`DOMAIN_BROADCAST`, `DOMAIN_AUTHD`, ...) than `VehicleBluetooth._queues` has keys (only `DOMAIN_VEHICLE_SECURITY`/`DOMAIN_INFOTAINMENT`). `_on_message` (`tesla_fleet_api/tesla/vehicle/bluetooth.py`) must look up `_queues` with `.get()` and drop unrecognized domains rather than indexing directly — indexing raises `KeyError` inside the `ReassemblingBuffer` callback, aborting reassembly of any further already-buffered messages in that notification. - **BLE infotainment boot-delay gotcha**: `wake_up()` (VCSEC) returns as soon as the vehicle-security computer acks it, well before the infotainment computer is ready to complete a signed-command handshake. An INFO-domain read/command issued immediately after `wake_up()` can raise `BluetoothTimeout` on the handshake through no fault of the command itself. Live-verified: waiting ~10s after `wake_up()` before the first INFO read is sufficient on the test rig; callers doing INFO work right after waking should retry-with-backoff rather than treat one timeout as failure. -- **BLE `vehicle_data()` response-size cap**: the vehicle's signed-command implementation enforces its own response-size limit independent of the BLE transport's packet reassembly. Live-verified: a single-endpoint `vehicle_data()` call (or any of the dedicated per-substate readers like `charge_state()`) succeeds, but requesting as few as two `BluetoothVehicleData` endpoints together reliably raises `TeslaFleetMessageFaultResponseSizeExceedsMTU` (`exceptions.py`) — this includes the `endpoints=None` "all" default. Prefer the per-substate readers, or a single-endpoint `vehicle_data()` call, over the multi-endpoint composite. +- **BLE `vehicle_data()` response-size cap**: the vehicle's signed-command implementation enforces its own response-size limit independent of the BLE transport's packet reassembly. Live-verified: a single-endpoint `vehicle_data()` call (or any of the dedicated per-substate readers like `charge_state()`) succeeds, but requesting as few as two `BluetoothVehicleData` endpoints together reliably raises `TeslaFleetMessageFaultResponseSizeExceedsMTU` (`exceptions.py`). This is why `vehicle_data()`'s `endpoints` arg has no all-endpoints default (unlike the cloud method) - prefer the per-substate readers, or a single-endpoint `vehicle_data()` call, over a multi-endpoint composite. Auto-chunking (split under the cap, merge replies) would fix this properly but is not implemented. ## Maintaining this file diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index edb1f7d..2d58845 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -545,18 +545,12 @@ async def wake_up(self): UnsignedMessage(RKEAction=RKEAction_E.RKE_ACTION_WAKE_VEHICLE) ) - async def vehicle_data( - self, endpoints: list[BluetoothVehicleData] | None = None - ) -> VehicleData: + async def vehicle_data(self, endpoints: list[BluetoothVehicleData]) -> VehicleData: """Get vehicle data over the BLE infotainment channel. - ``endpoints`` defaults to every ``BluetoothVehicleData`` member (all - BLE-readable sub-states), mirroring the cloud ``vehicle_data()`` - "no argument = everything" ergonomics. - This is a BLE-specific method, not a re-implementation of the cloud - one, and it diverges from ``VehicleFleet.vehicle_data()`` in two ways - that a cloud user porting to BLE should not be surprised by: + one, and it diverges from ``VehicleFleet.vehicle_data()`` in ways a + cloud user porting to BLE should not be surprised by: - ``endpoints`` takes ``BluetoothVehicleData`` (proto request names like ``"GetChargeState"``), not the cloud ``VehicleDataEndpoint`` @@ -566,18 +560,18 @@ async def vehicle_data( the signed-command reply, not a REST JSON ``dict``. Use ``tesla_fleet_api.tesla.bluetooth.toDict``/``toJson`` to convert it if a dict is needed. - - Requesting more than one sub-state in a single call risks exceeding - the vehicle's signed-command response-size cap, raised as - ``TeslaFleetMessageFaultResponseSizeExceedsMTU`` - observed live with - as few as two endpoints, and reliably with the ``None`` default's - full set. The individual state readers (``charge_state()``, - ``climate_state()``, etc.) each issue their own single-endpoint - request and are not subject to this cap; prefer them, or a - narrow explicit ``endpoints`` subset, over the ``None`` default. + - Unlike the cloud method, ``endpoints`` has no "all endpoints" + default: requesting more than one sub-state in a single call risks + exceeding the vehicle's signed-command response-size cap, raised as + ``TeslaFleetMessageFaultResponseSizeExceedsMTU`` - observed live + with as few as two endpoints requested together. The individual + state readers (``charge_state()``, ``climate_state()``, etc.) each + issue their own single-endpoint request and are not subject to + this cap; prefer them, or a narrow explicit ``endpoints`` subset, + over a large composite call. A future enhancement could split a + multi-endpoint request into multiple under-the-cap round trips and + merge the replies, but that auto-chunking is not implemented here. """ - if endpoints is None: - endpoints = list(BluetoothVehicleData) return await self._getInfotainment( Action( vehicleAction=VehicleAction( diff --git a/tests/test_ble_mocked_state_readers.py b/tests/test_ble_mocked_state_readers.py index efc1f80..716eee3 100644 --- a/tests/test_ble_mocked_state_readers.py +++ b/tests/test_ble_mocked_state_readers.py @@ -197,7 +197,8 @@ async def test_vehicle_state(self) -> None: class VehicleDataTests(MockedBleTransportTestCase): - """``vehicle_data`` composite reader, including the ``endpoints=None`` default-to-all behavior.""" + """``vehicle_data`` composite reader; ``endpoints`` is required (no all-endpoints default, + see the response-size-cap note in its docstring).""" async def test_explicit_endpoints_requests_only_those_substates(self) -> None: vehicle, send = self.make_vehicle() @@ -218,22 +219,3 @@ async def test_explicit_endpoints_requests_only_those_substates(self) -> None: get_vehicle_data = action.vehicleAction.getVehicleData self.assertTrue(get_vehicle_data.HasField("getChargeState")) self.assertFalse(get_vehicle_data.HasField("getClimateState")) - - async def test_no_endpoints_defaults_to_requesting_all_substates(self) -> None: - vehicle, send = self.make_vehicle() - send.return_value = infotainment_vehicle_data_reply(VehicleData()) - - await vehicle.vehicle_data() - - send.assert_awaited_once() - sent_msg = send.await_args.args[0] - - plaintext = decrypt_sent_command(vehicle, sent_msg) - action = Action.FromString(plaintext) - get_vehicle_data = action.vehicleAction.getVehicleData - for member in BluetoothVehicleData: - field_name = member.value[0].lower() + member.value[1:] - self.assertTrue( - get_vehicle_data.HasField(field_name), - f"expected {field_name} to be requested by default", - ) From 11dd6790fb9fd73953ca9b48f0ef62ba30b83add Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 19:10:24 +1000 Subject: [PATCH 3/3] no-mistakes(document): Sync Bluetooth read docs --- docs/bluetooth_vehicles.md | 48 +++++++++++++++++++--- tesla_fleet_api/tesla/vehicle/bluetooth.py | 8 +++- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/bluetooth_vehicles.md b/docs/bluetooth_vehicles.md index ca52720..65b3a64 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -77,6 +77,11 @@ async def main(): asyncio.run(main()) ``` +`wake_up()` returns when the vehicle-security computer acknowledges the wake +request. The infotainment computer can take longer to become ready, so +INFO-domain reads immediately after waking, such as `charge_state()` or +`vehicle_data()`, should retry `BluetoothTimeout` with backoff. + ## Open/Close Individual Doors (Bluetooth Only) The individual door closure commands are Bluetooth-only and are not available via Fleet API signed commands. @@ -112,20 +117,53 @@ asyncio.run(main()) ## Get Vehicle Data -You can get data from a `VehicleBluetooth` instance using the `vehicle_data` method. Here's a basic example to get data from a `VehicleBluetooth` instance: +You can get data from a `VehicleBluetooth` instance using the individual state +reader methods. Each reader sends one BLE vehicle-data request and returns the +typed protobuf state from the signed-command reply. ```python import asyncio -from tesla_fleet_api import TeslaBluetooth, BluetoothVehicleData +from tesla_fleet_api import TeslaBluetooth async def main(): tesla_bluetooth = TeslaBluetooth() device = await tesla_bluetooth.find_vehicle() private_key = await tesla_bluetooth.get_private_key("path/to/private_key.pem") vehicle = tesla_bluetooth.vehicles.create("") - data = await vehicle.vehicle_data([BluetoothVehicleData.CHARGE_STATE, BluetoothVehicleData.CLIMATE_STATE]) - print(f"Vehicle data for VIN: {vehicle.vin}") - print(data) + charge_state = await vehicle.charge_state() + print(f"Battery level for VIN {vehicle.vin}: {charge_state.battery_level}") asyncio.run(main()) ``` + +Available BLE state readers: + +- `vehicle_state()` +- `charge_state()` +- `climate_state()` +- `drive_state()` +- `location_state()` +- `closures_state()` +- `charge_schedule_state()` +- `preconditioning_schedule_state()` +- `tire_pressure_state()` +- `media_state()` +- `media_detail_state()` +- `software_update_state()` +- `parental_controls_state()` + +For explicit composite requests, use `vehicle_data(endpoints)` with +`BluetoothVehicleData` values: + +```python +from tesla_fleet_api import BluetoothVehicleData + +data = await vehicle.vehicle_data([BluetoothVehicleData.CHARGE_STATE]) +``` + +Unlike `VehicleFleet.vehicle_data()`, the BLE `vehicle_data()` method requires +an explicit `endpoints` list and returns a `VehicleData` protobuf message, not a +REST JSON `dict`. Prefer the individual state readers, or a single explicit +endpoint, because requesting multiple endpoints together can exceed the +vehicle's signed-command response-size cap and raise +`TeslaFleetMessageFaultResponseSizeExceedsMTU`. diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index 2d58845..2df19e0 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -540,7 +540,13 @@ async def pair( return async def wake_up(self): - """Wake up the vehicle.""" + """Wake up the vehicle security computer. + + The infotainment computer may still need a short delay before it can + complete signed-command handshakes, so callers that issue INFO-domain + reads immediately after waking should retry ``BluetoothTimeout`` with + backoff. + """ return await self._sendVehicleSecurity( UnsignedMessage(RKEAction=RKEAction_E.RKE_ACTION_WAKE_VEHICLE) )