rust/bt-mcs: Split server into separate mods Move the Server event, as well as helper machinery into a separate module. Move characteristic definitions and local state handling / supporting state types into a separate module. Tests remain colocated with the functionality they are covering. Bug: 540400364 Test: cargo test -p bt-mcs, ./presubmit.sh Change-Id: I66f70b6e5c1557fc7e0e925ad3d9623b3239f9fe Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3860
diff --git a/rust/bt-mcs/src/lib.rs b/rust/bt-mcs/src/lib.rs index 3d5520d..f08bd71 100644 --- a/rust/bt-mcs/src/lib.rs +++ b/rust/bt-mcs/src/lib.rs
@@ -7,11 +7,6 @@ pub mod types; pub use crate::error::Error; -pub use crate::server::{ - ControlPointWriteResponder, McsServer, McsServerBuilder, McsServerEvent, - SetPlaybackSpeedResponder, SetPlayingOrderResponder, SetTrackPositionResponder, - SetValueResponder, -}; pub use crate::types::{ ControlPointResultCode, MediaControlOpcode, MediaState, PlaybackSpeed, PlayingOrder, SeekingSpeed, SupportedOpcodes, SupportedPlayingOrders, TrackDuration, TrackPosition,
diff --git a/rust/bt-mcs/src/server.rs b/rust/bt-mcs/src/server.rs index 3f49055..ca82faa 100644 --- a/rust/bt-mcs/src/server.rs +++ b/rust/bt-mcs/src/server.rs
@@ -11,121 +11,26 @@ WriteResponder, }; use bt_gatt::types::{ - AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, Handle, - SecurityLevels, ServiceKind, + CharacteristicProperties, CharacteristicProperty, GattError, Handle, ServiceKind, }; -use bt_gatt::Characteristic; -use futures::channel::oneshot; use futures::stream::{FuturesUnordered, Stream}; -use futures::FutureExt; use pin_project::pin_project; use std::future::Future; -use std::pin::Pin; use std::task::{Context, Poll, Waker}; +pub mod event; +use event::*; +pub use event::{ + ControlPointWriteResponder, McsServerEvent, SetPlaybackSpeedResponder, + SetPlayingOrderResponder, SetTrackPositionResponder, SetValueResponder, +}; + +mod state; +use state::*; + use crate::types::*; use crate::Error; -// ============================================================================ -// Mandatory Characteristic Handle Definitions (MCS v1.0.1 Section 3) -// ============================================================================ - -/// Handle assigned to the Media Player Name characteristic. -const MEDIA_PLAYER_NAME_HANDLE: Handle = Handle(1); -/// Handle assigned to the Track Changed characteristic. -const TRACK_CHANGED_HANDLE: Handle = Handle(2); -/// Handle assigned to the Track Title characteristic. -const TRACK_TITLE_HANDLE: Handle = Handle(3); -/// Handle assigned to the Track Duration characteristic. -const TRACK_DURATION_HANDLE: Handle = Handle(4); -/// Handle assigned to the Track Position characteristic. -const TRACK_POSITION_HANDLE: Handle = Handle(5); -/// Handle assigned to the Media State characteristic. -const MEDIA_STATE_HANDLE: Handle = Handle(6); -/// Handle assigned to the Content Control ID (CCID) characteristic. -const CONTENT_CONTROL_ID_HANDLE: Handle = Handle(7); - -// ============================================================================ -// Optional Characteristic Handle Definitions (MCS v1.0.1 Section 3) -// ============================================================================ - -/// Handle assigned to the Media Player Icon URL characteristic. -const MEDIA_PLAYER_ICON_URL_HANDLE: Handle = Handle(8); -/// Handle assigned to the Playback Speed characteristic. -const PLAYBACK_SPEED_HANDLE: Handle = Handle(9); -/// Handle assigned to the Seeking Speed characteristic. -const SEEKING_SPEED_HANDLE: Handle = Handle(10); -/// Handle assigned to the Playing Order characteristic. -const PLAYING_ORDER_HANDLE: Handle = Handle(11); -/// Handle assigned to the Playing Orders Supported characteristic. -const PLAYING_ORDERS_SUPPORTED_HANDLE: Handle = Handle(12); -/// Handle assigned to the Media Control Point characteristic. -const MEDIA_CONTROL_POINT_HANDLE: Handle = Handle(13); -/// Handle assigned to the Media Control Point Opcodes Supported characteristic. -const MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE: Handle = Handle(14); - -/// The mandatory characteristics defined in MCS v1.0.1 Section 3, Table 3.1. -fn mandatory_characteristics() -> [Characteristic; 7] { - [ - build_characteristic( - MEDIA_PLAYER_NAME_HANDLE, - MEDIA_PLAYER_NAME_UUID, - CharacteristicProperties::READ_NOTIFY, - ), - build_characteristic( - TRACK_CHANGED_HANDLE, - TRACK_CHANGED_UUID, - CharacteristicProperty::Notify, - ), - build_characteristic( - TRACK_TITLE_HANDLE, - TRACK_TITLE_UUID, - CharacteristicProperties::READ_NOTIFY, - ), - build_characteristic( - TRACK_DURATION_HANDLE, - TRACK_DURATION_UUID, - CharacteristicProperties::READ_NOTIFY, - ), - build_characteristic( - TRACK_POSITION_HANDLE, - TRACK_POSITION_UUID, - CharacteristicProperties::READ_WRITE_NOTIFY, - ), - build_characteristic( - MEDIA_STATE_HANDLE, - MEDIA_STATE_UUID, - CharacteristicProperties::READ_NOTIFY, - ), - build_characteristic( - CONTENT_CONTROL_ID_HANDLE, - CONTENT_CONTROL_ID_UUID, - CharacteristicProperty::Read, - ), - ] -} - -/// Constructs a characteristic definition with the specified handle, UUID, -/// properties, and encryption-required permissions conforming to MCS v1.0.1 -/// Section 3. -fn build_characteristic( - handle: Handle, - uuid: Uuid, - properties: impl Into<CharacteristicProperties>, -) -> Characteristic { - let properties = properties.into(); - Characteristic { - handle, - uuid, - properties, - permissions: AttributePermissions::with_levels( - &properties, - &SecurityLevels::encryption_required(), - ), - descriptors: Vec::new(), - } -} - /// Internal state for the MCS server. #[pin_project(project = LocalServiceProj)] enum LocalServiceState<T: bt_gatt::ServerTypes> { @@ -206,343 +111,6 @@ } } -/// Confirms the updated track position to store in local state and notify to -/// clients. -/// It is safe to drop without responding to ignore the update or if the value -/// has not changed. -pub type SetTrackPositionResponder = SetValueResponder<TrackPosition>; - -/// Confirms the updated playback speed to store in local state and notify to -/// clients. -/// It is safe to drop without responding to ignore the update or if the value -/// has not changed. -pub type SetPlaybackSpeedResponder = SetValueResponder<PlaybackSpeed>; - -/// Confirms the updated playing order to store in local state and notify to -/// clients. -/// It is safe to drop without responding to ignore the update or if the value -/// has not changed. -pub type SetPlayingOrderResponder = SetValueResponder<PlayingOrder>; - -/// Confirms the media control command result code to notify to the client. -/// A response is expected to confirm the result. If no response is provided, -/// then it is assumed that the Control Point request has failed and -/// `ControlPointResultCode::CommandCannotBeCompleted` will be sent to the peer. -pub type ControlPointWriteResponder = SetValueResponder<ControlPointResultCode>; - -/// Responder for an asynchronous characteristic write or control point command. -/// -/// Calling [`send`](Self::send) confirms the new value or command result to -/// update local state and notify clients. It is safe to drop the responder -/// without responding to cancel the update (or report command failure). -pub struct SetValueResponder<T> { - response_tx: oneshot::Sender<T>, -} - -impl<T: std::fmt::Debug> SetValueResponder<T> { - /// Confirms the value or result code determined by the media player - /// application and dispatches the corresponding GATT notification. - pub fn send(self, value: T) { - if let Err(result) = self.response_tx.send(value) { - log::warn!("Failed to send response: server dropped or closed: {result:?}"); - } - } -} - -impl<T> std::fmt::Debug for SetValueResponder<T> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SetValueResponder").finish_non_exhaustive() - } -} - -/// An in-flight handler for a single-value asynchronous write response. -struct SetValueResponseFut<T> { - rx: oneshot::Receiver<T>, -} - -impl<T> SetValueResponseFut<T> { - fn create() -> (Self, SetValueResponder<T>) { - let (tx, rx) = oneshot::channel(); - (Self { rx }, SetValueResponder { response_tx: tx }) - } -} - -impl<T> Future for SetValueResponseFut<T> { - type Output = Option<T>; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { - self.rx.poll_unpin(cx).map(Result::ok) - } -} - -/// Responses produced when an in-flight asynchronous write response completes. -enum PendingWriteResponse { - ControlPoint { - peer_id: PeerId, - opcode: MediaControlOpcode, - result_code: ControlPointResultCode, - }, - TrackPosition(TrackPosition), - PlaybackSpeed(PlaybackSpeed), - PlayingOrder(PlayingOrder), -} - -/// An in-flight asynchronous write response future. -enum PendingWriteResponseFut { - ControlPoint { - peer_id: PeerId, - opcode: MediaControlOpcode, - fut: SetValueResponseFut<ControlPointResultCode>, - }, - TrackPosition(SetValueResponseFut<TrackPosition>), - PlaybackSpeed(SetValueResponseFut<PlaybackSpeed>), - PlayingOrder(SetValueResponseFut<PlayingOrder>), -} - -impl Future for PendingWriteResponseFut { - type Output = Option<PendingWriteResponse>; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { - match self.as_mut().get_mut() { - Self::ControlPoint { peer_id, opcode, fut } => fut.poll_unpin(cx).map(|res| { - let code = res.unwrap_or(ControlPointResultCode::CommandCannotBeCompleted); - Some(PendingWriteResponse::ControlPoint { - peer_id: *peer_id, - opcode: opcode.clone(), - result_code: code, - }) - }), - Self::TrackPosition(fut) => { - fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::TrackPosition)) - } - Self::PlaybackSpeed(fut) => { - fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::PlaybackSpeed)) - } - Self::PlayingOrder(fut) => { - fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::PlayingOrder)) - } - } - } -} - -/// Events produced by an [`McsServer`] stream representing client actions that -/// require additional upper-layer application processing. -#[derive(Debug)] -pub enum McsServerEvent { - /// Request to set the track playback position. - SetTrackPosition { - peer_id: PeerId, - position: TrackPosition, - responder: SetValueResponder<TrackPosition>, - }, - /// Request to set the playback speed. - SetPlaybackSpeed { - peer_id: PeerId, - speed: PlaybackSpeed, - responder: SetValueResponder<PlaybackSpeed>, - }, - /// Request to set the playing order. - SetPlayingOrder { - peer_id: PeerId, - order: PlayingOrder, - responder: SetValueResponder<PlayingOrder>, - }, - /// Request to execute a media control command. - ControlPointCommand { - opcode: MediaControlOpcode, - responder: SetValueResponder<ControlPointResultCode>, - }, -} - -/// State of the playing order characteristics when enabled on the server. -#[derive(Debug, Clone, PartialEq, Eq)] -struct PlayingOrderState { - /// Currently selected playing order. - current: PlayingOrder, - /// Bitmask of supported playing orders. - supported: SupportedPlayingOrders, -} - -impl PlayingOrderState { - /// Creates a new `PlayingOrderState` with the default current order - /// (`PlayingOrder::InOrderOnce`) and the given supported bitmask. - fn new(supported: SupportedPlayingOrders) -> Self { - Self { current: PlayingOrder::default(), supported } - } - - /// Sets the playing order if it is supported. - /// - /// Returns `true` if `order` was supported and applied, or `false` if - /// ignored because it was unsupported (MCS v1.0.1 Section 3.15.1). - fn set_order(&mut self, order: PlayingOrder) -> bool { - if self.supported.contains(order.into()) { - self.current = order; - true - } else { - false - } - } -} - -/// Local state of the characteristics in this server. -#[derive(Debug, Clone, PartialEq, Eq)] -struct McsLocalState { - /// Content Control ID (CCID) identifying this media service instance. - ccid: u8, - /// Human-readable media player application name. - player_name: String, - /// Title of the currently selected track (empty if no track loaded). - track_title: String, - /// Total duration of the current track (MCS v1.0.1 Section 3.6). - track_duration: TrackDuration, - /// Base playback position of the current track (MCS v1.0.1 Section 3.7). - track_position: TrackPosition, - /// Timestamp when `track_position` was set or updated. - position_updated_at: Option<std::time::Instant>, - /// Current player activity state. - media_state: MediaState, - /// URL pointing to media player icon graphic, if supported. - icon_url: Option<String>, - /// Playback speed multiplier, if supported. - playback_speed: Option<PlaybackSpeed>, - /// Seeking speed factor, if supported. - seeking_speed: Option<SeekingSpeed>, - /// Playing order and supported playing orders, if supported. - playing_orders: Option<PlayingOrderState>, - /// Supported media control point opcodes, if Media Control Point is - /// supported. - supported_opcodes: Option<SupportedOpcodes>, -} - -impl McsLocalState { - /// Creates a new [`McsLocalState`] with default values for an inactive - /// player with no track loaded per MCS v1.0.1 Section 3. - fn new(ccid: u8, player_name: impl Into<String>) -> Self { - Self { - ccid, - player_name: player_name.into(), - track_title: String::new(), - track_duration: TrackDuration::Unknown, - track_position: TrackPosition::Unavailable, - position_updated_at: None, - media_state: MediaState::Inactive, - icon_url: None, - playback_speed: None, - seeking_speed: None, - playing_orders: None, - supported_opcodes: None, - } - } - - /// Calculates the instantaneous track position based on elapsed playback - /// time (MCS v1.0.1 Section 3.7). - fn current_track_position(&self) -> TrackPosition { - // Per MCS Section 3.17, an inactive player has no current track, so it is - // unavailable. - if self.media_state == MediaState::Inactive { - return TrackPosition::Unavailable; - } - - // Playback timing has not started since the track was just initialized. - let Some(updated_at) = self.position_updated_at else { - return self.track_position; - }; - - // Playback is paused/seeking, so the position remains fixed. - if self.media_state != MediaState::Playing { - return self.track_position; - } - - let base = match (self.track_position, self.track_duration) { - (TrackPosition::FromStart(base), _) => base, - (TrackPosition::FromEnd(end), TrackDuration::Duration(total)) => { - total.saturating_sub(end) - } - _ => return self.track_position, - }; - - // TODO(b/540400364): Factor in optional playback speed when supported - let mut current = base + updated_at.elapsed(); - if let TrackDuration::Duration(total) = self.track_duration { - current = current.min(total); - } - TrackPosition::FromStart(current) - } - - /// Sets the track position and records the update timestamp. - fn set_track_position(&mut self, position: TrackPosition) { - self.track_position = position; - self.position_updated_at = Some(std::time::Instant::now()); - } - - /// Sets the playback speed. - fn set_playback_speed(&mut self, speed: PlaybackSpeed) { - self.playback_speed = Some(speed); - } - - /// Sets the playing order if playing order is supported. - fn set_playing_order(&mut self, order: PlayingOrder) { - if let Some(ref mut orders) = self.playing_orders { - let _ = orders.set_order(order); - } - } - - #[cfg(test)] - pub(crate) fn set_media_state(&mut self, state: MediaState) { - self.media_state = state; - } - - #[cfg(test)] - pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) { - self.supported_opcodes = Some(opcodes); - } - - /// Reads the characteristic bytes for `handle` at `offset`. - fn handle_read(&self, handle: Handle, offset: usize) -> Result<Vec<u8>, GattError> { - let read_at_offset = - |bytes: &[u8]| bytes.get(offset..).map(Vec::from).ok_or(GattError::InvalidOffset); - - match handle { - // Track Changed and Media Control Point are not readable per MCS v1.0.1 Section 3, - // Table 3.1. - TRACK_CHANGED_HANDLE | MEDIA_CONTROL_POINT_HANDLE => Err(GattError::ReadNotPermitted), - MEDIA_PLAYER_NAME_HANDLE => read_at_offset(self.player_name.as_bytes()), - TRACK_TITLE_HANDLE => read_at_offset(self.track_title.as_bytes()), - TRACK_DURATION_HANDLE => read_at_offset(&self.track_duration.raw_10ms().to_le_bytes()), - TRACK_POSITION_HANDLE => { - read_at_offset(&self.current_track_position().raw_10ms().to_le_bytes()) - } - MEDIA_STATE_HANDLE => read_at_offset(&[self.media_state.into()]), - CONTENT_CONTROL_ID_HANDLE => read_at_offset(&[self.ccid]), - MEDIA_PLAYER_ICON_URL_HANDLE => { - let url = self.icon_url.as_ref().ok_or(GattError::InvalidHandle)?; - read_at_offset(url.as_bytes()) - } - PLAYBACK_SPEED_HANDLE => { - let speed = self.playback_speed.ok_or(GattError::InvalidHandle)?; - read_at_offset(&[speed.into()]) - } - SEEKING_SPEED_HANDLE => { - let speed = self.seeking_speed.ok_or(GattError::InvalidHandle)?; - read_at_offset(&[speed.into()]) - } - PLAYING_ORDER_HANDLE => { - let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?; - read_at_offset(&[orders.current.into()]) - } - PLAYING_ORDERS_SUPPORTED_HANDLE => { - let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?; - read_at_offset(&orders.supported.bits().to_le_bytes()) - } - MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE => { - let opcodes = self.supported_opcodes.ok_or(GattError::InvalidHandle)?; - read_at_offset(&opcodes.bits().to_le_bytes()) - } - _ => Err(GattError::InvalidHandle), - } - } -} - /// Builder for configuring an MCS or GMCS GATT service. // TODO(b/549911651): Add support for OTS (Object Transfer Service) integration. #[derive(Debug, Clone, PartialEq)] @@ -974,7 +542,8 @@ use assert_matches::assert_matches; use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeTypes}; - use futures::StreamExt; + use bt_gatt::Characteristic; + use futures::{FutureExt, StreamExt}; #[test] fn builder_generic_service_definition() { @@ -1298,191 +867,6 @@ (server, fake_gatt_server, event_receiver, noop_cx) } - fn assert_read_characteristic( - server: &mut McsServer<FakeTypes>, - fake_gatt_server: &FakeServer, - event_receiver: &mut futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>, - handle: Handle, - expected: &[u8], - ) { - let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); - let peer = PeerId(1); - let service_id = server.service_def.id(); - fake_gatt_server.incoming_read(peer, service_id, handle, 0); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), expected); - } - - #[test] - fn read_mandatory_characteristics_default_values() { - let (mut server, fake_gatt_server, mut event_receiver) = - setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); - - // Media Player Name - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - MEDIA_PLAYER_NAME_HANDLE, - b"Test Player", - ); - - // Track Title - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - TRACK_TITLE_HANDLE, - b"", - ); - - // Track Duration - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - TRACK_DURATION_HANDLE, - &(-1i32).to_le_bytes(), - ); - - // Track Position - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - TRACK_POSITION_HANDLE, - &(-1i32).to_le_bytes(), - ); - - // Media State - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - MEDIA_STATE_HANDLE, - &[MediaState::Inactive.into()], - ); - - // Content Control ID - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - CONTENT_CONTROL_ID_HANDLE, - &[0x42], - ); - } - - #[test] - fn read_optional_characteristics_configured_values() { - let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( - McsServerBuilder::generic(0x42, "Test Player") - .with_icon_url("https://example.com/icon.png") - .with_player_speeds() - .with_playing_orders( - SupportedPlayingOrders::IN_ORDER_ONCE | SupportedPlayingOrders::SHUFFLE_ONCE, - ) - .with_supported_operations(SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE), - ); - - // Media Player Icon URL - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - MEDIA_PLAYER_ICON_URL_HANDLE, - b"https://example.com/icon.png", - ); - - // Playback Speed - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - PLAYBACK_SPEED_HANDLE, - &[0], - ); - - // Seeking Speed - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - SEEKING_SPEED_HANDLE, - &[0], - ); - - // Playing Order - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - PLAYING_ORDER_HANDLE, - &[PlayingOrder::InOrderOnce.into()], - ); - - // Playing Orders Supported - let expected_orders = - SupportedPlayingOrders::IN_ORDER_ONCE | SupportedPlayingOrders::SHUFFLE_ONCE; - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - PLAYING_ORDERS_SUPPORTED_HANDLE, - &expected_orders.bits().to_le_bytes(), - ); - - // Media Control Point Opcodes Supported - let expected_opcodes = SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE; - assert_read_characteristic( - &mut server, - &fake_gatt_server, - &mut event_receiver, - MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, - &expected_opcodes.bits().to_le_bytes(), - ); - } - - #[test] - fn read_unconfigured_optional_characteristics_returns_invalid_handle() { - let (mut server, fake_gatt_server, mut event_receiver) = - setup_test_server(McsServerBuilder::generic(0x42, "Mandatory Only Player")); - - let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); - let peer = PeerId(1); - let service_id = server.service_def.id(); - - let unconfigured_handles = [ - MEDIA_PLAYER_ICON_URL_HANDLE, - PLAYBACK_SPEED_HANDLE, - SEEKING_SPEED_HANDLE, - PLAYING_ORDER_HANDLE, - PLAYING_ORDERS_SUPPORTED_HANDLE, - MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, - ]; - - for handle in unconfigured_handles { - fake_gatt_server.incoming_read(peer, service_id, handle, 0); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded for handle {:?}", handle); - }; - assert_matches!( - value.unwrap_err(), - bt_gatt::types::Error::Gatt(GattError::InvalidHandle), - "handle {:?} should return InvalidHandle when unconfigured", - handle - ); - } - } - #[test] fn write_unconfigured_optional_characteristics_returns_invalid_handle() { let (mut server, fake_gatt_server, mut event_receiver) = @@ -1518,121 +902,6 @@ } #[test] - fn read_string_characteristics_with_offset() { - let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( - McsServerBuilder::generic(0x42, "Long Player Name") - .with_icon_url("https://example.com/icon.png"), - ); - - let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); - let peer = PeerId(1); - let service_id = server.service_def.id(); - - // Valid offset slice for Player Name - fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_NAME_HANDLE, 5); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), b"Player Name"); - - // Valid offset slice for Icon URL - fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_ICON_URL_HANDLE, 8); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), b"example.com/icon.png"); - - // Exact end offset returns empty slice - fake_gatt_server.incoming_read( - peer, - service_id, - MEDIA_PLAYER_NAME_HANDLE, - "Long Player Name".len() as u32, - ); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), b""); - - // Out-of-bounds offset returns InvalidOffset - fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_NAME_HANDLE, 100); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); - - // Out-of-bounds offset on Icon URL returns InvalidOffset - fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_ICON_URL_HANDLE, 100); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); - } - - #[test] - fn read_non_readable_characteristics_returns_error() { - let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); - let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( - McsServerBuilder::generic(0x42, "Test Player") - .with_supported_operations(SupportedOpcodes::default()), - ); - - let peer = PeerId(1); - let service_id = server.service_def.id(); - - // Track Changed is notify-only - fake_gatt_server.incoming_read(peer, service_id, TRACK_CHANGED_HANDLE, 0); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_matches!( - value.unwrap_err(), - bt_gatt::types::Error::Gatt(GattError::ReadNotPermitted) - ); - - // Media Control Point is write/notify-only - fake_gatt_server.incoming_read(peer, service_id, MEDIA_CONTROL_POINT_HANDLE, 0); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_matches!( - value.unwrap_err(), - bt_gatt::types::Error::Gatt(GattError::ReadNotPermitted) - ); - - // Unknown handle returns InvalidHandle - fake_gatt_server.incoming_read(peer, service_id, Handle(999), 0); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidHandle)); - } - - #[test] fn write_track_position_success() { let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); @@ -2747,163 +2016,4 @@ }; assert_eq!(value.unwrap(), vec![PlayingOrder::InOrderOnce.into()]); } - - #[test] - fn local_state_handle_read() { - let mut state = McsLocalState::new(0x01, "Player"); - state.media_state = MediaState::Paused; - state.playback_speed = Some(PlaybackSpeed::NORMAL); - state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::all())); - - // Track Position - assert_eq!( - state.handle_read(TRACK_POSITION_HANDLE, 0), - Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec()) - ); - - // Playback Speed - assert_eq!(state.handle_read(PLAYBACK_SPEED_HANDLE, 0), Ok(vec![0x00])); - - // Playing Order - assert_eq!( - state.handle_read(PLAYING_ORDER_HANDLE, 0), - Ok(vec![PlayingOrder::InOrderOnce.into()]) - ); - } - - #[test] - fn playing_order_state_helpers() { - let mut state = PlayingOrderState::new(SupportedPlayingOrders::IN_ORDER_ONCE); - assert_eq!(state.current, PlayingOrder::InOrderOnce); - assert_eq!(state.supported, SupportedPlayingOrders::IN_ORDER_ONCE); - assert!(!state.set_order(PlayingOrder::SingleOnce)); - assert_eq!(state.current, PlayingOrder::InOrderOnce); - assert!(state.set_order(PlayingOrder::InOrderOnce)); - - let mut all_state = PlayingOrderState::new(SupportedPlayingOrders::all()); - assert_eq!(all_state.current, PlayingOrder::InOrderOnce); - assert_eq!(all_state.supported, SupportedPlayingOrders::all()); - assert!(all_state.set_order(PlayingOrder::ShuffleRepeat)); - assert_eq!(all_state.current, PlayingOrder::ShuffleRepeat); - } - - #[test] - fn local_state_track_position_calculation() { - let mut state = McsLocalState::new(0x01, "Test Player"); - - // When no track is loaded, position is Unavailable regardless of state. - assert_eq!(state.current_track_position(), TrackPosition::Unavailable); - state.media_state = MediaState::Playing; - assert_eq!(state.current_track_position(), TrackPosition::Unavailable); - - // When paused or seeking with a loaded track, position does not advance with - // time. - state.media_state = MediaState::Paused; - state.track_position = TrackPosition::from_start(std::time::Duration::from_millis(5000)); - state.position_updated_at = - Some(std::time::Instant::now() - std::time::Duration::from_secs(10)); - assert_eq!( - state.current_track_position(), - TrackPosition::from_start(std::time::Duration::from_millis(5000)) - ); - state.media_state = MediaState::Seeking; - assert_eq!( - state.current_track_position(), - TrackPosition::from_start(std::time::Duration::from_millis(5000)) - ); - - // When playing with a loaded track (FromStart), position advances based on - // elapsed time. - state.media_state = MediaState::Playing; - state.track_duration = TrackDuration::from_duration(std::time::Duration::from_secs(20)); - state.track_position = TrackPosition::from_start(std::time::Duration::from_millis(5000)); - state.position_updated_at = - Some(std::time::Instant::now() - std::time::Duration::from_millis(500)); - let computed = state.current_track_position(); - let TrackPosition::FromStart(dur) = computed else { - panic!("expected FromStart"); - }; - assert!( - dur >= std::time::Duration::from_millis(5450) - && dur <= std::time::Duration::from_millis(5650), - "unexpected computed position: {dur:?}" - ); - - // When elapsed time exceeds track duration, position is clamped to duration. - state.position_updated_at = - Some(std::time::Instant::now() - std::time::Duration::from_secs(100)); - assert_eq!( - state.current_track_position(), - TrackPosition::from_start(std::time::Duration::from_secs(20)) - ); - - // When playing with a track position relative to end (FromEnd), base is - // duration - offset. - state.track_duration = TrackDuration::from_duration(std::time::Duration::from_secs(30)); - state.track_position = TrackPosition::from_end(std::time::Duration::from_secs(10)); - state.position_updated_at = - Some(std::time::Instant::now() - std::time::Duration::from_millis(500)); - // Base is 30s - 10s = 20s. Elapsed 500ms -> ~20.5s from start. - let computed = state.current_track_position(); - let TrackPosition::FromStart(dur) = computed else { - panic!("expected FromStart"); - }; - assert!( - dur >= std::time::Duration::from_millis(20450) - && dur <= std::time::Duration::from_millis(20650), - "unexpected computed position: {dur:?}" - ); - - // When FromEnd elapsed time exceeds track duration, position is clamped to - // duration. - state.position_updated_at = - Some(std::time::Instant::now() - std::time::Duration::from_secs(100)); - assert_eq!( - state.current_track_position(), - TrackPosition::from_start(std::time::Duration::from_secs(30)) - ); - - // When FromEnd is used with unknown duration, it cannot resolve to FromStart - // and returns FromEnd. - state.track_duration = TrackDuration::Unknown; - state.track_position = TrackPosition::from_end(std::time::Duration::from_secs(10)); - assert_eq!( - state.current_track_position(), - TrackPosition::from_end(std::time::Duration::from_secs(10)) - ); - } - - #[test] - fn read_track_position_during_active_playback() { - let (mut server, fake_gatt_server, mut event_receiver) = - setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); - - // Set server state to Playing with a loaded track at position 500 (5.0 - // seconds). - server.state.media_state = MediaState::Playing; - server.state.track_duration = - TrackDuration::from_duration(std::time::Duration::from_secs(60)); - server.state.track_position = - TrackPosition::from_start(std::time::Duration::from_millis(5000)); - server.state.position_updated_at = - Some(std::time::Instant::now() - std::time::Duration::from_millis(1000)); - - let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); - let peer = PeerId(1); - let service_id = server.service_def.id(); - fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0); - let _ = server.next().poll_unpin(&mut noop_cx); - let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - - let raw_bytes = value.unwrap(); - assert_eq!(raw_bytes.len(), 4); - let raw_10ms = i32::from_le_bytes(raw_bytes.try_into().unwrap()); - // 5000ms + 1000ms elapsed = 6000ms = 600 units of 10ms (allow minor jitter +/- - // 30 units) - assert!(raw_10ms >= 580 && raw_10ms <= 640, "unexpected raw_10ms: {raw_10ms}"); - } }
diff --git a/rust/bt-mcs/src/server/event.rs b/rust/bt-mcs/src/server/event.rs new file mode 100644 index 0000000..26fed15 --- /dev/null +++ b/rust/bt-mcs/src/server/event.rs
@@ -0,0 +1,213 @@ +// Copyright 2026 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use bt_common::PeerId; +use futures::channel::oneshot; +use futures::FutureExt; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use crate::types::*; + +/// Confirms the updated track position to store in local state and notify to +/// clients. +/// It is safe to drop without responding to ignore the update or if the value +/// has not changed. +pub type SetTrackPositionResponder = SetValueResponder<TrackPosition>; + +/// Confirms the updated playback speed to store in local state and notify to +/// clients. +/// It is safe to drop without responding to ignore the update or if the value +/// has not changed. +pub type SetPlaybackSpeedResponder = SetValueResponder<PlaybackSpeed>; + +/// Confirms the updated playing order to store in local state and notify to +/// clients. +/// It is safe to drop without responding to ignore the update or if the value +/// has not changed. +pub type SetPlayingOrderResponder = SetValueResponder<PlayingOrder>; + +/// Confirms the media control command result code to notify to the client. +/// A response is expected to confirm the result. If no response is provided, +/// then it is assumed that the Control Point request has failed and +/// `ControlPointResultCode::CommandCannotBeCompleted` will be sent to the peer. +pub type ControlPointWriteResponder = SetValueResponder<ControlPointResultCode>; + +/// Responder for an asynchronous characteristic write or control point command. +/// +/// Calling [`send`](Self::send) confirms the new value or command result to +/// update local state and notify clients. It is safe to drop the responder +/// without responding to cancel the update (or report command failure). +pub struct SetValueResponder<T> { + response_tx: oneshot::Sender<T>, +} + +impl<T: std::fmt::Debug> SetValueResponder<T> { + /// Confirms the value or result code determined by the media player + /// application and dispatches the corresponding GATT notification. + pub fn send(self, value: T) { + if let Err(result) = self.response_tx.send(value) { + log::warn!("Failed to send response: server dropped or closed: {result:?}"); + } + } +} + +impl<T> std::fmt::Debug for SetValueResponder<T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SetValueResponder").finish_non_exhaustive() + } +} + +/// An in-flight handler for a single-value asynchronous write response. +pub(crate) struct SetValueResponseFut<T> { + rx: oneshot::Receiver<T>, +} + +impl<T> SetValueResponseFut<T> { + pub(crate) fn create() -> (Self, SetValueResponder<T>) { + let (tx, rx) = oneshot::channel(); + (Self { rx }, SetValueResponder { response_tx: tx }) + } +} + +impl<T> Future for SetValueResponseFut<T> { + type Output = Option<T>; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { + self.rx.poll_unpin(cx).map(Result::ok) + } +} + +/// Responses produced when an in-flight asynchronous write response completes. +#[derive(Debug)] +pub(crate) enum PendingWriteResponse { + ControlPoint { + peer_id: PeerId, + opcode: MediaControlOpcode, + result_code: ControlPointResultCode, + }, + TrackPosition(TrackPosition), + PlaybackSpeed(PlaybackSpeed), + PlayingOrder(PlayingOrder), +} + +/// An in-flight asynchronous write response future. +pub(crate) enum PendingWriteResponseFut { + ControlPoint { + peer_id: PeerId, + opcode: MediaControlOpcode, + fut: SetValueResponseFut<ControlPointResultCode>, + }, + TrackPosition(SetValueResponseFut<TrackPosition>), + PlaybackSpeed(SetValueResponseFut<PlaybackSpeed>), + PlayingOrder(SetValueResponseFut<PlayingOrder>), +} + +impl Future for PendingWriteResponseFut { + type Output = Option<PendingWriteResponse>; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { + match self.as_mut().get_mut() { + Self::ControlPoint { peer_id, opcode, fut } => fut.poll_unpin(cx).map(|res| { + let code = res.unwrap_or(ControlPointResultCode::CommandCannotBeCompleted); + Some(PendingWriteResponse::ControlPoint { + peer_id: *peer_id, + opcode: opcode.clone(), + result_code: code, + }) + }), + Self::TrackPosition(fut) => { + fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::TrackPosition)) + } + Self::PlaybackSpeed(fut) => { + fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::PlaybackSpeed)) + } + Self::PlayingOrder(fut) => { + fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::PlayingOrder)) + } + } + } +} + +/// Events produced by an [`McsServer`] stream representing client actions that +/// require additional upper-layer application processing. +#[derive(Debug)] +pub enum McsServerEvent { + /// Request to set the track playback position. + SetTrackPosition { + peer_id: PeerId, + position: TrackPosition, + responder: SetValueResponder<TrackPosition>, + }, + /// Request to set the playback speed. + SetPlaybackSpeed { + peer_id: PeerId, + speed: PlaybackSpeed, + responder: SetValueResponder<PlaybackSpeed>, + }, + /// Request to set the playing order. + SetPlayingOrder { + peer_id: PeerId, + order: PlayingOrder, + responder: SetValueResponder<PlayingOrder>, + }, + /// Request to execute a media control command. + ControlPointCommand { + opcode: MediaControlOpcode, + responder: SetValueResponder<ControlPointResultCode>, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + #[test] + fn set_value_responder_and_fut() { + let (mut fut, responder) = SetValueResponseFut::<PlaybackSpeed>::create(); + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + + assert!(fut.poll_unpin(&mut cx).is_pending()); + + responder.send(PlaybackSpeed::DOUBLE); + assert_eq!(fut.poll_unpin(&mut cx), Poll::Ready(Some(PlaybackSpeed::DOUBLE))); + } + + #[test] + fn set_value_responder_dropped_results_in_none() { + let (mut fut, responder) = SetValueResponseFut::<TrackPosition>::create(); + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + + assert!(fut.poll_unpin(&mut cx).is_pending()); + + drop(responder); + assert_eq!(fut.poll_unpin(&mut cx), Poll::Ready(None)); + } + + #[test] + fn pending_write_response_control_point_dropped_defaults_to_cannot_be_completed() { + let (fut, responder) = SetValueResponseFut::<ControlPointResultCode>::create(); + let peer_id = PeerId(1); + let mut pending = PendingWriteResponseFut::ControlPoint { + peer_id, + opcode: MediaControlOpcode::Play, + fut, + }; + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + + assert!(pending.poll_unpin(&mut cx).is_pending()); + + drop(responder); + assert_matches!( + pending.poll_unpin(&mut cx), + Poll::Ready(Some(PendingWriteResponse::ControlPoint { + peer_id: p, + opcode: MediaControlOpcode::Play, + result_code: ControlPointResultCode::CommandCannotBeCompleted, + })) if p == peer_id + ); + } +}
diff --git a/rust/bt-mcs/src/server/state.rs b/rust/bt-mcs/src/server/state.rs new file mode 100644 index 0000000..aafa445 --- /dev/null +++ b/rust/bt-mcs/src/server/state.rs
@@ -0,0 +1,513 @@ +// Copyright 2026 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use bt_common::Uuid; +use bt_gatt::types::{ + AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, Handle, + SecurityLevels, +}; +use bt_gatt::Characteristic; + +use crate::types::*; + +// ============================================================================ +// Characteristic Definitions & Handles (MCS v1.0.1 Section 3) +// ============================================================================ + +/// Handle assigned to the Media Player Name characteristic. +pub(crate) const MEDIA_PLAYER_NAME_HANDLE: Handle = Handle(1); +/// Handle assigned to the Track Changed characteristic. +pub(crate) const TRACK_CHANGED_HANDLE: Handle = Handle(2); +/// Handle assigned to the Track Title characteristic. +pub(crate) const TRACK_TITLE_HANDLE: Handle = Handle(3); +/// Handle assigned to the Track Duration characteristic. +pub(crate) const TRACK_DURATION_HANDLE: Handle = Handle(4); +/// Handle assigned to the Track Position characteristic. +pub(crate) const TRACK_POSITION_HANDLE: Handle = Handle(5); +/// Handle assigned to the Media State characteristic. +pub(crate) const MEDIA_STATE_HANDLE: Handle = Handle(6); +/// Handle assigned to the Content Control ID (CCID) characteristic. +pub(crate) const CONTENT_CONTROL_ID_HANDLE: Handle = Handle(7); + +// ============================================================================ +// Optional Characteristic Handle Definitions (MCS v1.0.1 Section 3) +// ============================================================================ + +/// Handle assigned to the Media Player Icon URL characteristic. +pub(crate) const MEDIA_PLAYER_ICON_URL_HANDLE: Handle = Handle(8); +/// Handle assigned to the Playback Speed characteristic. +pub(crate) const PLAYBACK_SPEED_HANDLE: Handle = Handle(9); +/// Handle assigned to the Seeking Speed characteristic. +pub(crate) const SEEKING_SPEED_HANDLE: Handle = Handle(10); +/// Handle assigned to the Playing Order characteristic. +pub(crate) const PLAYING_ORDER_HANDLE: Handle = Handle(11); +/// Handle assigned to the Playing Orders Supported characteristic. +pub(crate) const PLAYING_ORDERS_SUPPORTED_HANDLE: Handle = Handle(12); +/// Handle assigned to the Media Control Point characteristic. +pub(crate) const MEDIA_CONTROL_POINT_HANDLE: Handle = Handle(13); +/// Handle assigned to the Media Control Point Opcodes Supported characteristic. +pub(crate) const MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE: Handle = Handle(14); + +/// Constructs a characteristic definition with the specified handle, UUID, +/// properties, and encryption-required permissions conforming to MCS v1.0.1 +/// Section 3. +pub(crate) fn build_characteristic( + handle: Handle, + uuid: Uuid, + properties: impl Into<CharacteristicProperties>, +) -> Characteristic { + let properties = properties.into(); + Characteristic { + handle, + uuid, + properties, + permissions: AttributePermissions::with_levels( + &properties, + &SecurityLevels::encryption_required(), + ), + descriptors: Vec::new(), + } +} + +/// The mandatory characteristics defined in MCS v1.0.1 Section 3, Table 3.1. +pub(crate) fn mandatory_characteristics() -> [Characteristic; 7] { + [ + build_characteristic( + MEDIA_PLAYER_NAME_HANDLE, + MEDIA_PLAYER_NAME_UUID, + CharacteristicProperties::READ_NOTIFY, + ), + build_characteristic( + TRACK_CHANGED_HANDLE, + TRACK_CHANGED_UUID, + CharacteristicProperty::Notify, + ), + build_characteristic( + TRACK_TITLE_HANDLE, + TRACK_TITLE_UUID, + CharacteristicProperties::READ_NOTIFY, + ), + build_characteristic( + TRACK_DURATION_HANDLE, + TRACK_DURATION_UUID, + CharacteristicProperties::READ_NOTIFY, + ), + build_characteristic( + TRACK_POSITION_HANDLE, + TRACK_POSITION_UUID, + CharacteristicProperties::READ_WRITE_NOTIFY, + ), + build_characteristic( + MEDIA_STATE_HANDLE, + MEDIA_STATE_UUID, + CharacteristicProperties::READ_NOTIFY, + ), + build_characteristic( + CONTENT_CONTROL_ID_HANDLE, + CONTENT_CONTROL_ID_UUID, + CharacteristicProperty::Read, + ), + ] +} + +/// Local state of the characteristics in this server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct McsLocalState { + /// Content Control ID (CCID) identifying this media service instance. + pub(crate) ccid: u8, + /// Human-readable media player application name. + pub(crate) player_name: String, + /// Title of the currently selected track (empty if no track loaded). + pub(crate) track_title: String, + /// Total duration of the current track (MCS v1.0.1 Section 3.6). + pub(crate) track_duration: TrackDuration, + /// Base playback position of the current track (MCS v1.0.1 Section 3.7). + pub(crate) track_position: TrackPosition, + /// Timestamp when `track_position` was set or updated. + pub(crate) position_updated_at: Option<std::time::Instant>, + /// Current player activity state. + pub(crate) media_state: MediaState, + /// URL pointing to media player icon graphic, if supported. + pub(crate) icon_url: Option<String>, + /// Playback speed multiplier, if supported. + pub(crate) playback_speed: Option<PlaybackSpeed>, + /// Seeking speed factor, if supported. + pub(crate) seeking_speed: Option<SeekingSpeed>, + /// Playing order and supported playing orders, if supported. + pub(crate) playing_orders: Option<PlayingOrderState>, + /// Supported media control point opcodes, if Media Control Point is + /// supported. + pub(crate) supported_opcodes: Option<SupportedOpcodes>, +} + +impl McsLocalState { + /// Creates a new [`McsLocalState`] with default values for an inactive + /// player with no track loaded per MCS v1.0.1 Section 3. + pub(crate) fn new(ccid: u8, player_name: impl Into<String>) -> Self { + Self { + ccid, + player_name: player_name.into(), + track_title: String::new(), + track_duration: TrackDuration::Unknown, + track_position: TrackPosition::Unavailable, + position_updated_at: None, + media_state: MediaState::Inactive, + icon_url: None, + playback_speed: None, + seeking_speed: None, + playing_orders: None, + supported_opcodes: None, + } + } + + /// Calculates the instantaneous track position based on elapsed playback + /// time (MCS v1.0.1 Section 3.7). + pub(crate) fn current_track_position(&self) -> TrackPosition { + // Per MCS Section 3.17, an inactive player has no current track, so it is + // unavailable. + if self.media_state == MediaState::Inactive { + return TrackPosition::Unavailable; + } + + // Playback timing has not started since the track was initialized or loaded. + let Some(updated_at) = self.position_updated_at else { + return self.track_position; + }; + + // Playback is paused or seeking, so the position remains fixed at the current + // offset. + if self.media_state != MediaState::Playing { + return self.track_position; + } + + let base = match (self.track_position, self.track_duration) { + (TrackPosition::FromStart(base), _) => base, + (TrackPosition::FromEnd(end), TrackDuration::Duration(total)) => { + total.saturating_sub(end) + } + _ => return self.track_position, + }; + + // TODO(b/540400364): Factor in optional playback speed when supported + let mut current = base + updated_at.elapsed(); + if let TrackDuration::Duration(total) = self.track_duration { + current = current.min(total); + } + TrackPosition::FromStart(current) + } + + /// Sets the track position and records the update timestamp. + pub(crate) fn set_track_position(&mut self, position: TrackPosition) { + self.track_position = position; + self.position_updated_at = Some(std::time::Instant::now()); + } + + /// Sets the playback speed. + pub(crate) fn set_playback_speed(&mut self, speed: PlaybackSpeed) { + self.playback_speed = Some(speed); + } + + /// Sets the playing order if playing order is supported. + pub(crate) fn set_playing_order(&mut self, order: PlayingOrder) { + if let Some(ref mut orders) = self.playing_orders { + if orders.supported.contains(order.into()) { + orders.current = order; + } + } + } + + #[cfg(test)] + pub(crate) fn set_media_state(&mut self, state: MediaState) { + self.media_state = state; + } + + #[cfg(test)] + pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) { + self.supported_opcodes = Some(opcodes); + } + + /// Reads the characteristic bytes for `handle` at `offset`. + pub(crate) fn handle_read(&self, handle: Handle, offset: usize) -> Result<Vec<u8>, GattError> { + let read_at_offset = + |bytes: &[u8]| bytes.get(offset..).map(Vec::from).ok_or(GattError::InvalidOffset); + + match handle { + // Track Changed and Media Control Point are not readable per MCS v1.0.1 Section 3, + // Table 3.1. + TRACK_CHANGED_HANDLE | MEDIA_CONTROL_POINT_HANDLE => Err(GattError::ReadNotPermitted), + MEDIA_PLAYER_NAME_HANDLE => read_at_offset(self.player_name.as_bytes()), + TRACK_TITLE_HANDLE => read_at_offset(self.track_title.as_bytes()), + TRACK_DURATION_HANDLE => read_at_offset(&self.track_duration.raw_10ms().to_le_bytes()), + TRACK_POSITION_HANDLE => { + read_at_offset(&self.current_track_position().raw_10ms().to_le_bytes()) + } + MEDIA_STATE_HANDLE => read_at_offset(&[self.media_state.into()]), + CONTENT_CONTROL_ID_HANDLE => read_at_offset(&[self.ccid]), + MEDIA_PLAYER_ICON_URL_HANDLE => { + let url = self.icon_url.as_ref().ok_or(GattError::InvalidHandle)?; + read_at_offset(url.as_bytes()) + } + PLAYBACK_SPEED_HANDLE => { + let speed = self.playback_speed.ok_or(GattError::InvalidHandle)?; + read_at_offset(&[speed.into()]) + } + SEEKING_SPEED_HANDLE => { + let speed = self.seeking_speed.ok_or(GattError::InvalidHandle)?; + read_at_offset(&[speed.into()]) + } + PLAYING_ORDER_HANDLE => { + let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?; + read_at_offset(&[orders.current.into()]) + } + PLAYING_ORDERS_SUPPORTED_HANDLE => { + let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?; + read_at_offset(&orders.supported.bits().to_le_bytes()) + } + MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE => { + let opcodes = self.supported_opcodes.ok_or(GattError::InvalidHandle)?; + read_at_offset(&opcodes.bits().to_le_bytes()) + } + _ => Err(GattError::InvalidHandle), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use std::time::Duration; + + #[test] + fn local_state_set_playing_order() { + let mut state = McsLocalState::new(0x01, "Player"); + state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SINGLE_ONCE)); + assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::InOrderOnce); + + state.set_playing_order(PlayingOrder::SingleOnce); + assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce); + + // Unsupported playing order is not applied + state.set_playing_order(PlayingOrder::ShuffleOnce); + assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce); + } + + #[test] + fn local_state_handle_read() { + let mut state = McsLocalState::new(0x01, "Player"); + state.media_state = MediaState::Paused; + state.playback_speed = Some(PlaybackSpeed::NORMAL); + state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::all())); + + // Track Position + assert_eq!( + state.handle_read(TRACK_POSITION_HANDLE, 0), + Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec()) + ); + + // Playback Speed + assert_eq!(state.handle_read(PLAYBACK_SPEED_HANDLE, 0), Ok(vec![0x00])); + + // Playing Order + assert_eq!( + state.handle_read(PLAYING_ORDER_HANDLE, 0), + Ok(vec![PlayingOrder::InOrderOnce.into()]) + ); + } + + #[test] + fn local_state_track_position_calculation() { + let mut state = McsLocalState::new(0x01, "Player"); + state.track_duration = TrackDuration::from_raw_10ms(1000); + + // Inactive player always has unavailable track position + state.media_state = MediaState::Inactive; + state.track_position = TrackPosition::from_raw_10ms(500); + assert_eq!(state.current_track_position(), TrackPosition::Unavailable); + + // Paused player returns base track position + state.media_state = MediaState::Paused; + assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(500)); + + // Playing player calculates elapsed time + state.media_state = MediaState::Playing; + state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(2)); + match state.current_track_position() { + TrackPosition::FromStart(dur) => { + assert!(dur >= Duration::from_millis(6900) && dur <= Duration::from_millis(7200)); + } + other => panic!("Expected FromStart duration, got {other:?}"), + } + + // Clamped to total duration + state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(20)); + assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(1000)); + } + + #[test] + fn read_mandatory_characteristics_default_values() { + let state = McsLocalState::new(0x05, "Test Player"); + + assert_eq!(state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 0), Ok(b"Test Player".to_vec())); + assert_eq!(state.handle_read(TRACK_TITLE_HANDLE, 0), Ok(b"".to_vec())); + assert_eq!( + state.handle_read(TRACK_DURATION_HANDLE, 0), + Ok(TrackDuration::Unknown.raw_10ms().to_le_bytes().to_vec()) + ); + assert_eq!( + state.handle_read(TRACK_POSITION_HANDLE, 0), + Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec()) + ); + assert_eq!(state.handle_read(MEDIA_STATE_HANDLE, 0), Ok(vec![MediaState::Inactive.into()])); + assert_eq!(state.handle_read(CONTENT_CONTROL_ID_HANDLE, 0), Ok(vec![0x05])); + } + + #[test] + fn read_optional_characteristics_configured_values() { + let mut state = McsLocalState::new(0x01, "Player"); + state.icon_url = Some("https://example.com/icon.png".to_string()); + state.playback_speed = Some(PlaybackSpeed::DOUBLE); + state.seeking_speed = Some(SeekingSpeed::new(10)); + state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SHUFFLE_ONCE)); + state.supported_opcodes = Some(SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE); + + assert_eq!( + state.handle_read(MEDIA_PLAYER_ICON_URL_HANDLE, 0), + Ok(b"https://example.com/icon.png".to_vec()) + ); + assert_eq!( + state.handle_read(PLAYBACK_SPEED_HANDLE, 0), + Ok(vec![PlaybackSpeed::DOUBLE.into()]) + ); + assert_eq!( + state.handle_read(SEEKING_SPEED_HANDLE, 0), + Ok(vec![SeekingSpeed::new(10).into()]) + ); + assert_eq!( + state.handle_read(PLAYING_ORDER_HANDLE, 0), + Ok(vec![PlayingOrder::InOrderOnce.into()]) + ); + assert_eq!( + state.handle_read(PLAYING_ORDERS_SUPPORTED_HANDLE, 0), + Ok(SupportedPlayingOrders::SHUFFLE_ONCE.bits().to_le_bytes().to_vec()) + ); + assert_eq!( + state.handle_read(MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, 0), + Ok((SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE).bits().to_le_bytes().to_vec()) + ); + } + + #[test] + fn read_string_characteristics_with_offset() { + let mut state = McsLocalState::new(0x01, "Long Player Name"); + state.track_title = "Track Number 1".to_string(); + state.icon_url = Some("https://example.com".to_string()); + + assert_eq!(state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 5), Ok(b"Player Name".to_vec())); + assert_eq!(state.handle_read(TRACK_TITLE_HANDLE, 6), Ok(b"Number 1".to_vec())); + assert_eq!(state.handle_read(MEDIA_PLAYER_ICON_URL_HANDLE, 8), Ok(b"example.com".to_vec())); + + assert_matches!( + state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 100), + Err(GattError::InvalidOffset) + ); + } + + #[test] + fn read_track_position_during_active_playback() { + let mut state = McsLocalState::new(0x01, "Player"); + state.track_duration = TrackDuration::from_raw_10ms(10000); + state.media_state = MediaState::Playing; + state.track_position = TrackPosition::from_raw_10ms(200); + state.position_updated_at = Some(std::time::Instant::now() - Duration::from_millis(500)); + + let res = state.handle_read(TRACK_POSITION_HANDLE, 0).expect("read should succeed"); + let raw = i32::from_le_bytes(res.try_into().unwrap()); + assert!(raw >= 240 && raw <= 260, "Expected track position ~250 (10ms units), got {raw}"); + } + + #[test] + fn read_unconfigured_optional_characteristics_returns_invalid_handle() { + let state = McsLocalState::new(0x01, "Player"); + + let optional_handles = [ + MEDIA_PLAYER_ICON_URL_HANDLE, + PLAYBACK_SPEED_HANDLE, + SEEKING_SPEED_HANDLE, + PLAYING_ORDER_HANDLE, + PLAYING_ORDERS_SUPPORTED_HANDLE, + MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, + ]; + + for handle in optional_handles { + assert_matches!( + state.handle_read(handle, 0), + Err(GattError::InvalidHandle), + "Handle {:?} should return InvalidHandle when not configured", + handle + ); + } + } + + #[test] + fn read_non_readable_characteristics_returns_error() { + let mut state = McsLocalState::new(0x01, "Player"); + state.supported_opcodes = Some(SupportedOpcodes::all()); + + assert_matches!( + state.handle_read(TRACK_CHANGED_HANDLE, 0), + Err(GattError::ReadNotPermitted) + ); + assert_matches!( + state.handle_read(MEDIA_CONTROL_POINT_HANDLE, 0), + Err(GattError::ReadNotPermitted) + ); + } + + #[test] + fn mandatory_characteristics_properties_and_permissions() { + let mandatory = mandatory_characteristics(); + assert_eq!(mandatory.len(), 7); + + assert_eq!(mandatory[0].handle, MEDIA_PLAYER_NAME_HANDLE); + assert_eq!(mandatory[0].uuid, MEDIA_PLAYER_NAME_UUID); + assert_eq!(mandatory[0].properties, CharacteristicProperties::READ_NOTIFY); + + assert_eq!(mandatory[1].handle, TRACK_CHANGED_HANDLE); + assert_eq!(mandatory[1].uuid, TRACK_CHANGED_UUID); + assert_eq!( + mandatory[1].properties, + CharacteristicProperties::from(CharacteristicProperty::Notify) + ); + + assert_eq!(mandatory[2].handle, TRACK_TITLE_HANDLE); + assert_eq!(mandatory[2].uuid, TRACK_TITLE_UUID); + assert_eq!(mandatory[2].properties, CharacteristicProperties::READ_NOTIFY); + + assert_eq!(mandatory[3].handle, TRACK_DURATION_HANDLE); + assert_eq!(mandatory[3].uuid, TRACK_DURATION_UUID); + assert_eq!(mandatory[3].properties, CharacteristicProperties::READ_NOTIFY); + + assert_eq!(mandatory[4].handle, TRACK_POSITION_HANDLE); + assert_eq!(mandatory[4].uuid, TRACK_POSITION_UUID); + assert_eq!(mandatory[4].properties, CharacteristicProperties::READ_WRITE_NOTIFY); + + assert_eq!(mandatory[5].handle, MEDIA_STATE_HANDLE); + assert_eq!(mandatory[5].uuid, MEDIA_STATE_UUID); + assert_eq!(mandatory[5].properties, CharacteristicProperties::READ_NOTIFY); + + assert_eq!(mandatory[6].handle, CONTENT_CONTROL_ID_HANDLE); + assert_eq!(mandatory[6].uuid, CONTENT_CONTROL_ID_UUID); + assert_eq!( + mandatory[6].properties, + CharacteristicProperties::from(CharacteristicProperty::Read) + ); + + for chrc in &mandatory { + assert!(chrc.permissions.read.map_or(true, |s| s.encryption)); + assert!(chrc.permissions.write.map_or(true, |s| s.encryption)); + assert!(chrc.permissions.update.map_or(true, |s| s.encryption)); + } + } +}