From 54e0e41ed0317ecb02d682e8583179a470f217cb Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 21:00:29 +1000 Subject: [PATCH 1/4] test(vehicle): add mocked BLE coverage for media commands Regression tests for adjust_volume, media_volume_up/down, media_toggle_playback, media_next/prev_track, media_next/prev_fav, and remote_boombox (captain-present-only, mocked transport only). Live-verify against the test car is blocked tonight by BLE transport reliability (GATT write-response timeouts / error 133 on the proxy rig) - see the status file for evidence. Document the discovered media state-observability gap (Spotify leaves MediaState/MediaDetailState track fields empty) in AGENTS.md for the next live-verify attempt. --- AGENTS.md | 1 + tests/test_ble_mocked_media_commands.py | 136 ++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 tests/test_ble_mocked_media_commands.py diff --git a/AGENTS.md b/AGENTS.md index 332cfef..1fa5e4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,7 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te - **`scheduled_charging_mode` is tri-state and shared**: `set_scheduled_charging` and `set_scheduled_departure` (`commands.py`) both write the same `ChargeState.scheduled_charging_mode` (Off/StartAt/DepartBy) - live-verified. Disabling one when the other is active turns the whole feature Off rather than leaving the other's config intact; a caller toggling one must read `charge_state()` first and restore the exact prior mode (including the other command's fields) rather than assuming independence. - **`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. ## Maintaining this file diff --git a/tests/test_ble_mocked_media_commands.py b/tests/test_ble_mocked_media_commands.py new file mode 100644 index 0000000..00b1e2d --- /dev/null +++ b/tests/test_ble_mocked_media_commands.py @@ -0,0 +1,136 @@ +"""Regression tests for the media command group over the mocked BLE transport. + +Covers ``adjust_volume``, ``media_volume_up/down``, ``media_toggle_playback``, +``media_next_track``/``media_prev_track``/``media_next_fav``/``media_prev_fav`` +(all INFO, inherited from ``Commands``). All eight are live-verified against +the test car - see AGENTS.md for the live-verify evidence summary. + +``remote_boombox`` is CAPTAIN-PRESENT-ONLY (plays sound through the external +speaker) - mocked-transport coverage only here, never actuated live. +""" + +from tesla_fleet_api.tesla.vehicle.proto.car_server_pb2 import Action +from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import Domain + +from ble_mocked_transport import ( + MockedBleTransportTestCase, + decrypt_sent_command, + infotainment_action_ok_reply, +) + + +def _sent_vehicle_action(vehicle, send): + sent_msg = send.await_args.args[0] + plaintext = decrypt_sent_command(vehicle, sent_msg) + return sent_msg, Action.FromString(plaintext).vehicleAction + + +class AdjustVolumeTests(MockedBleTransportTestCase): + async def test_sends_absolute_volume_and_decodes_ok_reply(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.adjust_volume(5.0) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertEqual(sent_msg.to_destination.domain, Domain.DOMAIN_INFOTAINMENT) + self.assertAlmostEqual(vehicle_action.mediaUpdateVolume.volume_absolute_float, 5.0) + + +class MediaVolumeUpTests(MockedBleTransportTestCase): + async def test_sends_volume_delta_plus_one(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.media_volume_up() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + _sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertEqual(vehicle_action.mediaUpdateVolume.volume_delta, 1) + + +class MediaVolumeDownTests(MockedBleTransportTestCase): + async def test_sends_volume_delta_minus_one(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.media_volume_down() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + _sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertEqual(vehicle_action.mediaUpdateVolume.volume_delta, -1) + + +class MediaTogglePlaybackTests(MockedBleTransportTestCase): + async def test_sends_media_play_action(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.media_toggle_playback() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + _sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertTrue(vehicle_action.HasField("mediaPlayAction")) + + +class MediaNextTrackTests(MockedBleTransportTestCase): + async def test_sends_media_next_track(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.media_next_track() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + _sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertTrue(vehicle_action.HasField("mediaNextTrack")) + + +class MediaPrevTrackTests(MockedBleTransportTestCase): + async def test_sends_media_previous_track(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.media_prev_track() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + _sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertTrue(vehicle_action.HasField("mediaPreviousTrack")) + + +class MediaNextFavTests(MockedBleTransportTestCase): + async def test_sends_media_next_favorite(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.media_next_fav() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + _sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertTrue(vehicle_action.HasField("mediaNextFavorite")) + + +class MediaPrevFavTests(MockedBleTransportTestCase): + async def test_sends_media_previous_favorite(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.media_prev_fav() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + _sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertTrue(vehicle_action.HasField("mediaPreviousFavorite")) + + +class RemoteBoomboxTests(MockedBleTransportTestCase): + """CAPTAIN-PRESENT-ONLY (external speaker) - proto construction only, never live.""" + + async def test_sends_boombox_action_with_sound_index(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.remote_boombox(2) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + _sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) + self.assertEqual(vehicle_action.boomboxAction.sound, 2) From 79c17a293a47bdf0aa42c832313a8c5f7519812c Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 21:06:29 +1000 Subject: [PATCH 2/4] docs(vehicle): mark media BLE live-verify deferred, record transport finding Two further fresh-connection retries (per firstmate guidance) reproduced the same GATT write-response timeout on the first signed-command write; a same-session plain read succeeded immediately after. Car state reconfirmed unmutated. Not forcing further live attempts tonight - shipping the green mocked-transport suite and deferring live-verify pending a stable connection. --- AGENTS.md | 1 + tests/test_ble_mocked_media_commands.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1fa5e4b..62bae66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,7 @@ 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 write-vs-read reliability asymmetry**: on the test rig, plain GATT reads (state readers, `wake_up()`) succeeded consistently, but every signed-command *write* (e.g. `adjust_volume`) reliably hit `aioesphomeapi.core.TimeoutAPIError` waiting for `BluetoothGATTWriteResponse` (or, once, GATT error 133) across 5 separate fresh connections in one session. Reads never showed the same failure. A client-side write timeout did not leave the car mutated (re-read after each failure showed the pre-command value unchanged), so treat it as the write never landing rather than a lost response to an applied change - but always re-read to confirm before assuming so. Root cause not identified; worth a dedicated transport probe before the next live-actuation attempt on this rig. ## Maintaining this file diff --git a/tests/test_ble_mocked_media_commands.py b/tests/test_ble_mocked_media_commands.py index 00b1e2d..0cfec0b 100644 --- a/tests/test_ble_mocked_media_commands.py +++ b/tests/test_ble_mocked_media_commands.py @@ -2,8 +2,11 @@ Covers ``adjust_volume``, ``media_volume_up/down``, ``media_toggle_playback``, ``media_next_track``/``media_prev_track``/``media_next_fav``/``media_prev_fav`` -(all INFO, inherited from ``Commands``). All eight are live-verified against -the test car - see AGENTS.md for the live-verify evidence summary. +(all INFO, inherited from ``Commands``). Live-verify against the test car is +DEFERRED - the BLE proxy rig was unreliable during the attempt (GATT +write-response timeouts on every signed-command write; plain reads succeeded), +so no snapshot->act->verify->restore cycle could complete. See AGENTS.md for +the transport-instability finding. ``remote_boombox`` is CAPTAIN-PRESENT-ONLY (plays sound through the external speaker) - mocked-transport coverage only here, never actuated live. From bb05bd2391da1217d0ea792a139f1764fe4813fd Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 21:11:52 +1000 Subject: [PATCH 3/4] no-mistakes(document): Document BLE media caveats --- docs/bluetooth_vehicles.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/bluetooth_vehicles.md b/docs/bluetooth_vehicles.md index ebbb558..f9e6242 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -227,3 +227,35 @@ 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`. + +## Media Commands + +`VehicleBluetooth` inherits the signed media commands from `Commands`, so media +control methods use the same BLE signed-command transport as other INFO-domain +commands: + +- `adjust_volume(volume)` +- `media_volume_up()` +- `media_volume_down()` +- `media_toggle_playback()` +- `media_next_track()` +- `media_prev_track()` +- `media_next_fav()` +- `media_prev_fav()` + +These commands return the signed-command acknowledgement, not a media-state +diff. For verification, prefer `media_state().audio_volume` and +`media_state().media_playback_status` where they apply. Track identity fields +such as `now_playing_artist`, `now_playing_title`, and the +`media_detail_state()` now-playing fields can be empty for some media sources, +so track/favorite navigation is best verified by the command acknowledgement and +paired with the inverse command when an exact state fingerprint is unavailable. + +If a BLE write times out, re-read the relevant state before assuming the command +applied. A timeout while waiting for the GATT write response may mean the +command never reached the vehicle, even when plain BLE reads on the same +connection are succeeding. + +`remote_boombox(sound)` also uses the INFO-domain signed-command transport and +plays through the vehicle external speaker. Use it only when someone is present +to confirm the sound is appropriate for the vehicle's surroundings. From e22c2305d808f53a9ad786875b0ccf22442dc4b3 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 21:13:34 +1000 Subject: [PATCH 4/4] no-mistakes(lint): Fix targeted lint issues --- tests/test_ble_mocked_media_commands.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/test_ble_mocked_media_commands.py b/tests/test_ble_mocked_media_commands.py index 0cfec0b..0419e99 100644 --- a/tests/test_ble_mocked_media_commands.py +++ b/tests/test_ble_mocked_media_commands.py @@ -12,8 +12,15 @@ speaker) - mocked-transport coverage only here, never actuated live. """ -from tesla_fleet_api.tesla.vehicle.proto.car_server_pb2 import Action -from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import Domain +from typing import Any, cast +from unittest.mock import AsyncMock + +from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth +from tesla_fleet_api.tesla.vehicle.proto.car_server_pb2 import Action, VehicleAction +from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import ( + Domain, + RoutableMessage, +) from ble_mocked_transport import ( MockedBleTransportTestCase, @@ -22,8 +29,11 @@ ) -def _sent_vehicle_action(vehicle, send): - sent_msg = send.await_args.args[0] +def _sent_vehicle_action( + vehicle: VehicleBluetooth[Any], send: AsyncMock +) -> tuple[RoutableMessage, VehicleAction]: + assert send.await_args is not None + sent_msg = cast("RoutableMessage", send.await_args.args[0]) plaintext = decrypt_sent_command(vehicle, sent_msg) return sent_msg, Action.FromString(plaintext).vehicleAction @@ -38,7 +48,9 @@ async def test_sends_absolute_volume_and_decodes_ok_reply(self) -> None: self.assertEqual(result, {"response": {"result": True, "reason": ""}}) sent_msg, vehicle_action = _sent_vehicle_action(vehicle, send) self.assertEqual(sent_msg.to_destination.domain, Domain.DOMAIN_INFOTAINMENT) - self.assertAlmostEqual(vehicle_action.mediaUpdateVolume.volume_absolute_float, 5.0) + self.assertAlmostEqual( + vehicle_action.mediaUpdateVolume.volume_absolute_float, 5.0 + ) class MediaVolumeUpTests(MockedBleTransportTestCase):