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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions docs/bluetooth_vehicles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
151 changes: 151 additions & 0 deletions tests/test_ble_mocked_media_commands.py
Original file line number Diff line number Diff line change
@@ -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)
Loading