diff --git a/AGENTS.md b/AGENTS.md index 332cfef..62bae66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,8 @@ 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. +- **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/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. diff --git a/tests/test_ble_mocked_media_commands.py b/tests/test_ble_mocked_media_commands.py new file mode 100644 index 0000000..0419e99 --- /dev/null +++ b/tests/test_ble_mocked_media_commands.py @@ -0,0 +1,151 @@ +"""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``). 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 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, + decrypt_sent_command, + infotainment_action_ok_reply, +) + + +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 + + +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)