rust/bt-mcs: Handle GATT write requests and control point commands Implement GATT write request processing and asynchronous Media Control Point command execution for the Media Control Service. Define an event that is yielded to upper layers. Events can be handled and responded to which generate the required GATT notification. Handle Media Control Point writes by checking opcode support and player activity state, emitting immediate notifications for fast rejections. For supported opcodes, provide a `ControlPointResponder` to allow the application to complete the operation asynchronously. Bug: 540400364 Test: cargo test -p bt-mcs, ./presubmit.sh Change-Id: If4545955dce9873dfdf84f889898cb944c8f0e1c Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3740
diff --git a/rust/bt-mcs/Cargo.toml b/rust/bt-mcs/Cargo.toml index a30afdb..3ada3f7 100644 --- a/rust/bt-mcs/Cargo.toml +++ b/rust/bt-mcs/Cargo.toml
@@ -9,8 +9,10 @@ bt-common.workspace = true bt-gatt.workspace = true futures.workspace = true +log.workspace = true pin-project.workspace = true thiserror.workspace = true [dev-dependencies] +assert_matches.workspace = true bt-gatt = { workspace = true, features = ["test-utils"] }
diff --git a/rust/bt-mcs/src/lib.rs b/rust/bt-mcs/src/lib.rs index 2ef3b54..3d5520d 100644 --- a/rust/bt-mcs/src/lib.rs +++ b/rust/bt-mcs/src/lib.rs
@@ -7,4 +7,12 @@ pub mod types; pub use crate::error::Error; -pub use crate::server::{McsServer, McsServerBuilder}; +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 be7e567..25e336c 100644 --- a/rust/bt-mcs/src/server.rs +++ b/rust/bt-mcs/src/server.rs
@@ -4,18 +4,23 @@ //! Implements the Media Control Service (MCS) server. -use bt_common::Uuid; +use bt_common::packet_encoding::Decodable; +use bt_common::{PeerId, Uuid}; use bt_gatt::server::{ LocalService, ReadResponder, Server as _, ServiceDefinition, ServiceEvent, ServiceId, + WriteResponder, }; use bt_gatt::types::{ AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, Handle, SecurityLevels, ServiceKind, }; use bt_gatt::Characteristic; -use futures::stream::Stream; +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}; use crate::types::*; @@ -152,6 +157,12 @@ fn is_published(&self) -> bool { matches!(self, LocalServiceState::Published { .. }) } + + fn notify(&self, handle: &Handle, data: &[u8], peers: &[PeerId]) { + if let LocalServiceState::Published { service, .. } = self { + service.notify(handle, data, peers); + } + } } impl<T: bt_gatt::ServerTypes> Stream for LocalServiceState<T> { @@ -185,10 +196,9 @@ self.as_mut().set(LocalServiceState::Terminated); return Poll::Ready(Some(Err(Error::Gatt(e)))); } - None => { - self.as_mut().set(LocalServiceState::Terminated); - return Poll::Ready(None); - } + // Deferred to McsServer to allow draining in-flight control point + // responses. + None => return Poll::Ready(None), } } } @@ -196,6 +206,184 @@ } } +/// 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 { @@ -215,10 +403,10 @@ media_state: MediaState, /// URL pointing to media player icon graphic, if supported. icon_url: Option<String>, - /// Playback speed multiplier (0 = 1.0x normal speed), if supported. - playback_speed: Option<i8>, - /// Seeking speed factor (0 = not seeking), if supported. - seeking_speed: Option<i8>, + /// 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 @@ -281,10 +469,39 @@ 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. @@ -303,11 +520,11 @@ } PLAYBACK_SPEED_HANDLE => { let speed = self.playback_speed.ok_or(GattError::InvalidHandle)?; - read_at_offset(&[speed as u8]) + read_at_offset(&[speed.into()]) } SEEKING_SPEED_HANDLE => { let speed = self.seeking_speed.ok_or(GattError::InvalidHandle)?; - read_at_offset(&[speed as u8]) + read_at_offset(&[speed.into()]) } PLAYING_ORDER_HANDLE => { let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?; @@ -359,8 +576,8 @@ /// Enables playback and seeking speed support with default speeds. pub fn with_player_speeds(mut self) -> Self { - self.state.playback_speed = Some(0); - self.state.seeking_speed = Some(0); + self.state.playback_speed = Some(PlaybackSpeed::NORMAL); + self.state.seeking_speed = Some(SeekingSpeed::NOT_SEEKING); self } @@ -446,22 +663,33 @@ /// Builds an [`McsServer`] configured with this builder. pub fn build<T: bt_gatt::ServerTypes>(self) -> Result<McsServer<T>, Error> { let service_def = self.build_service_definition()?; - Ok(McsServer { service_def, local_service: Default::default(), state: self.state }) + Ok(McsServer::new(service_def, self.state)) } } /// An instance of a Media Control Service (MCS) or Generic Media Control /// Service (GMCS) GATT server. -#[pin_project] +#[pin_project(project = McsServerProj)] pub struct McsServer<T: bt_gatt::ServerTypes> { service_def: ServiceDefinition, #[pin] local_service: LocalServiceState<T>, /// Local state of the characteristics in this server. state: McsLocalState, + #[pin] + pending_write_responses: FuturesUnordered<PendingWriteResponseFut>, } impl<T: bt_gatt::ServerTypes> McsServer<T> { + fn new(service_def: ServiceDefinition, state: McsLocalState) -> Self { + Self { + service_def, + local_service: Default::default(), + state, + pending_write_responses: FuturesUnordered::new(), + } + } + /// Returns true if this server is a GMCS server. pub fn is_generic_service(&self) -> bool { self.service_def.uuid() == GENERIC_MEDIA_CONTROL_SERVICE_UUID @@ -489,33 +717,251 @@ Ok(()) } + #[cfg(test)] + pub(crate) fn set_media_state(&mut self, state: MediaState) { + self.state.set_media_state(state); + } + + #[cfg(test)] + pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) { + self.state.set_supported_opcodes(opcodes); + } +} + +impl<'a, T: bt_gatt::ServerTypes> McsServerProj<'a, T> { fn handle_read<R: ReadResponder>(&self, handle: Handle, offset: usize, responder: R) { match self.state.handle_read(handle, offset) { Ok(bytes) => responder.respond(&bytes), Err(err) => responder.error(err), } } + + fn handle_write<W: WriteResponder>( + &mut self, + peer_id: PeerId, + handle: Handle, + offset: u32, + value: &[u8], + responder: W, + ) -> Option<McsServerEvent> { + // Reject non-zero offsets as MCS writable characteristics do not support + // long writes. + if offset != 0 { + responder.error(GattError::InvalidOffset); + return None; + } + + match handle { + MEDIA_CONTROL_POINT_HANDLE => { + self.handle_control_point_write(peer_id, value, responder) + } + TRACK_POSITION_HANDLE => { + if value.len() != 4 { + responder.error(GattError::InvalidAttributeValueLength); + return None; + } + let raw = i32::from_le_bytes(value.try_into().unwrap()); + let position = TrackPosition::from_raw_10ms(raw); + responder.acknowledge(); + let (fut, resp) = SetValueResponseFut::create(); + self.pending_write_responses.push(PendingWriteResponseFut::TrackPosition(fut)); + Some(McsServerEvent::SetTrackPosition { peer_id, position, responder: resp }) + } + PLAYBACK_SPEED_HANDLE => { + if self.state.playback_speed.is_none() { + responder.error(GattError::InvalidHandle); + return None; + } + if value.len() != 1 { + responder.error(GattError::InvalidAttributeValueLength); + return None; + } + let speed = PlaybackSpeed::from(value[0] as i8); + responder.acknowledge(); + let (fut, resp) = SetValueResponseFut::create(); + self.pending_write_responses.push(PendingWriteResponseFut::PlaybackSpeed(fut)); + Some(McsServerEvent::SetPlaybackSpeed { peer_id, speed, responder: resp }) + } + PLAYING_ORDER_HANDLE => { + let Some(ref playing_orders) = self.state.playing_orders else { + responder.error(GattError::InvalidHandle); + return None; + }; + if value.len() != 1 { + responder.error(GattError::InvalidAttributeValueLength); + return None; + } + let Ok(order) = PlayingOrder::try_from(value[0]) else { + // Invalid playing order values (MCS v1.0.1 Section 3.15.1). + responder.acknowledge(); + return None; + }; + if !playing_orders.supported.contains(order.into()) { + // Unsupported playing order is ignored per MCS v1.0.1 Section 3.15.1. + responder.acknowledge(); + return None; + } + responder.acknowledge(); + let (fut, resp) = SetValueResponseFut::create(); + self.pending_write_responses.push(PendingWriteResponseFut::PlayingOrder(fut)); + Some(McsServerEvent::SetPlayingOrder { peer_id, order, responder: resp }) + } + // Read-only characteristics cannot be written to. + MEDIA_PLAYER_NAME_HANDLE + | TRACK_CHANGED_HANDLE + | TRACK_TITLE_HANDLE + | TRACK_DURATION_HANDLE + | MEDIA_PLAYER_ICON_URL_HANDLE + | SEEKING_SPEED_HANDLE + | PLAYING_ORDERS_SUPPORTED_HANDLE + | MEDIA_STATE_HANDLE + | MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE + | CONTENT_CONTROL_ID_HANDLE => { + responder.error(GattError::WriteNotPermitted); + None + } + // TODO(b/549911651): Add support for optional characteristics (e.g. Search Control + // Point and OTS object IDs). + _ => { + responder.error(GattError::InvalidHandle); + None + } + } + } + + fn handle_control_point_write<W: WriteResponder>( + &mut self, + peer_id: PeerId, + value: &[u8], + responder: W, + ) -> Option<McsServerEvent> { + // The Media Control Point characteristic is optional - reject all requests if + // it is not enabled on this server. + let Some(supported_opcodes) = self.state.supported_opcodes else { + responder.error(GattError::InvalidHandle); + return None; + }; + + let Some(&raw_opcode) = value.first() else { + responder.error(GattError::InvalidAttributeValueLength); + return None; + }; + + let notify_error = |responder: W, result_code: ControlPointResultCode| { + responder.acknowledge(); + self.local_service.notify( + &MEDIA_CONTROL_POINT_HANDLE, + &[raw_opcode, result_code.into()], + &[peer_id], + ); + None + }; + + // Validate opcode decoding and server opcode support (see MCS v1.0.1 Section + // 3.18.2). + let opcode = match MediaControlOpcode::decode(value) { + (Ok(opcode), _) if supported_opcodes.contains((&opcode).into()) => opcode, + _ => return notify_error(responder, ControlPointResultCode::OpcodeNotSupported), + }; + + // Reject commands if the media player is inactive. + // TODO(b/540400364): The spec also technically allows the handling of the + // command if the media player supports it with no active track. Revisit + // this if needed. + if self.state.media_state == MediaState::Inactive { + return notify_error(responder, ControlPointResultCode::MediaPlayerInactive); + } + + // Acknowledge the GATT write and produce a new event for the request. + responder.acknowledge(); + let (fut, responder) = SetValueResponseFut::create(); + self.pending_write_responses.push(PendingWriteResponseFut::ControlPoint { + peer_id, + opcode: opcode.clone(), + fut, + }); + Some(McsServerEvent::ControlPointCommand { opcode, responder }) + } + + /// Attempts to handle a pending write response from the upper layer + /// application. + fn handle_pending_write_response(&mut self, response: PendingWriteResponse) { + match response { + PendingWriteResponse::ControlPoint { peer_id, opcode, result_code } => { + // Dispatches a Media Control Point GATT notification containing + // [opcode, result_code] to the peer per MCS v1.0.1 Section 3.18.2. + self.local_service.notify( + &MEDIA_CONTROL_POINT_HANDLE, + &[opcode.raw_opcode(), result_code.into()], + &[peer_id], + ); + } + PendingWriteResponse::TrackPosition(position) => { + self.state.set_track_position(position); + self.local_service.notify( + &TRACK_POSITION_HANDLE, + &position.raw_10ms().to_le_bytes(), + &[], + ); + } + PendingWriteResponse::PlaybackSpeed(speed) => { + self.state.set_playback_speed(speed); + self.local_service.notify(&PLAYBACK_SPEED_HANDLE, &[speed.into()], &[]); + } + PendingWriteResponse::PlayingOrder(order) => { + self.state.set_playing_order(order); + self.local_service.notify(&PLAYING_ORDER_HANDLE, &[order.into()], &[]); + } + } + } } impl<T: bt_gatt::ServerTypes> Stream for McsServer<T> { - type Item = Result<(), Error>; + type Item = Result<McsServerEvent, Error>; fn poll_next( mut self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>, ) -> Poll<Option<Self::Item>> { loop { - let gatt_event = - match futures::ready!(self.as_mut().project().local_service.poll_next(cx)) { - None => return Poll::Ready(None), - Some(Err(e)) => return Poll::Ready(Some(Err(e))), - Some(Ok(event)) => event, - }; + let mut this = self.as_mut().project(); + // Drain any completed asynchronous write responses before processing new + // GATT events. + if let Poll::Ready(Some(response)) = this.pending_write_responses.as_mut().poll_next(cx) + { + if let Some(response) = response { + this.handle_pending_write_response(response); + } + continue; + } + + let gatt_event = match futures::ready!(this.local_service.as_mut().poll_next(cx)) { + None => { + // Continue polling until all in-flight responses have been drained before + // terminating the stream. + if this.pending_write_responses.is_empty() { + this.local_service.as_mut().set(LocalServiceState::Terminated); + return Poll::Ready(None); + } else { + return Poll::Pending; + } + } + Some(Err(e)) => return Poll::Ready(Some(Err(e))), + Some(Ok(event)) => event, + }; + match gatt_event { ServiceEvent::Read { peer_id: _, handle, offset, responder } => { - self.handle_read(handle, offset as usize, responder); + this.handle_read(handle, offset as usize, responder); } - // TODO(b/540400364): Add support for characteristic writes + ServiceEvent::Write { peer_id, handle, offset, value, responder } => { + let value_bytes = value.to_owned(); + if let Some(event) = + this.handle_write(peer_id, handle, offset, &value_bytes, responder) + { + return Poll::Ready(Some(Ok(event))); + } + } _ => continue, } } @@ -526,17 +972,15 @@ mod tests { use super::*; - use bt_common::PeerId; + use assert_matches::assert_matches; use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeTypes}; - use bt_gatt::types::GattError; - use futures::{FutureExt, StreamExt}; + use futures::StreamExt; #[test] fn builder_generic_service_definition() { let builder = McsServerBuilder::generic(0x42, "Test Generic Player"); - let service_def = builder - .build_service_definition() - .expect("generic service definition builds successfully"); + let service_def = + builder.build_service_definition().expect("service definition builds successfully"); assert_eq!(service_def.uuid(), GENERIC_MEDIA_CONTROL_SERVICE_UUID); assert_eq!(service_def.id(), ServiceId::new(0x42)); assert_eq!(service_def.kind(), ServiceKind::Primary); @@ -656,12 +1100,15 @@ #[test] fn service_id_tracks_ccid() { let builder1 = McsServerBuilder::instance(0x05, "Player 1"); - let builder2 = McsServerBuilder::instance(0x05, "Player 2"); + let builder2 = McsServerBuilder::generic(0x05, "Player 2"); let builder3 = McsServerBuilder::instance(0x06, "Player 3"); - let def1 = builder1.build_service_definition().expect("valid definition"); - let def2 = builder2.build_service_definition().expect("valid definition"); - let def3 = builder3.build_service_definition().expect("valid definition"); + let def1 = + builder1.build_service_definition().expect("service definition builds successfully"); + let def2 = + builder2.build_service_definition().expect("service definition builds successfully"); + let def3 = + builder3.build_service_definition().expect("service definition builds successfully"); assert_eq!(def1.id(), def2.id()); assert_eq!(def1.id(), ServiceId::new(5)); @@ -718,7 +1165,7 @@ server.publish(fake_gatt_server.clone()).expect("initial publish succeeds"); let err = server.publish(fake_gatt_server); - assert!(matches!(err, Err(Error::AlreadyPublished))); + assert_matches!(err, Err(Error::AlreadyPublished)); } #[test] @@ -745,10 +1192,10 @@ ))); server2.publish(fake_gatt_server).expect("server2 publish call succeeds"); let poll_result = server2.next().poll_unpin(&mut noop_cx); - assert!(matches!( + assert_matches!( poll_result, Poll::Ready(Some(Err(Error::Gatt(bt_gatt::types::Error::AlreadyPublished(_))))) - )); + ); } #[test] @@ -782,11 +1229,11 @@ // Polling the server returns None indicating the stream has terminated. let poll_result = server.next().poll_unpin(&mut noop_cx); - assert!(matches!(poll_result, Poll::Ready(None))); + assert_matches!(poll_result, Poll::Ready(None)); // Subsequent polls on terminated state also return None. let poll_result = server.next().poll_unpin(&mut noop_cx); - assert!(matches!(poll_result, Poll::Ready(None))); + assert_matches!(poll_result, Poll::Ready(None)); } fn setup_test_server( @@ -805,6 +1252,30 @@ (server, fake_gatt_server, event_receiver) } + fn setup_test_server_with_control_point( + peer: PeerId, + ) -> ( + McsServer<FakeTypes>, + FakeServer, + futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>, + Context<'static>, + ) { + 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::all()), + ); + fake_gatt_server.incoming_client_configuration( + peer, + server.service_def.id(), + MEDIA_CONTROL_POINT_HANDLE, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + let _ = event_receiver.try_recv(); + (server, fake_gatt_server, event_receiver, noop_cx) + } + fn assert_read_characteristic( server: &mut McsServer<FakeTypes>, fake_gatt_server: &FakeServer, @@ -981,8 +1452,9 @@ else { panic!("expected ReadResponded for handle {:?}", handle); }; - assert!( - matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidHandle)), + assert_matches!( + value.unwrap_err(), + bt_gatt::types::Error::Gatt(GattError::InvalidHandle), "handle {:?} should return InvalidHandle when unconfigured", handle ); @@ -990,6 +1462,40 @@ } #[test] + fn write_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_writes = [ + (PLAYBACK_SPEED_HANDLE, vec![0x00]), + (PLAYING_ORDER_HANDLE, vec![PlayingOrder::SingleOnce.into()]), + (MEDIA_CONTROL_POINT_HANDLE, vec![MediaControlOpcode::Play.raw_opcode()]), + ]; + + for (handle, value) in unconfigured_writes { + fake_gatt_server.incoming_write(peer, service_id, handle, 0, value); + let poll_result = server.next().poll_unpin(&mut noop_cx); + // Must NOT emit any McsServerEvent. + assert_matches!(poll_result, Poll::Pending); + let bt_gatt::test_utils::FakeServerEvent::WriteResponded { value, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded for handle {:?}", handle); + }; + assert_matches!( + value.unwrap_err(), + bt_gatt::types::Error::Gatt(GattError::InvalidHandle), + "handle {:?} should return InvalidHandle on write when unconfigured", + handle + ); + } + } + + #[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") @@ -1043,10 +1549,7 @@ else { panic!("expected ReadResponded"); }; - assert!(matches!( - value.unwrap_err(), - bt_gatt::types::Error::Gatt(GattError::InvalidOffset) - )); + 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); @@ -1056,10 +1559,7 @@ else { panic!("expected ReadResponded"); }; - assert!(matches!( - value.unwrap_err(), - bt_gatt::types::Error::Gatt(GattError::InvalidOffset) - )); + assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); } #[test] @@ -1081,10 +1581,10 @@ else { panic!("expected ReadResponded"); }; - assert!(matches!( + 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); @@ -1094,10 +1594,10 @@ else { panic!("expected ReadResponded"); }; - assert!(matches!( + 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); @@ -1107,10 +1607,1129 @@ else { panic!("expected ReadResponded"); }; - assert!(matches!( + 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")); + server.state.media_state = MediaState::Paused; + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + TRACK_POSITION_HANDLE, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + let _ = event_receiver.try_recv(); + + fake_gatt_server.incoming_write( + peer, + service_id, + TRACK_POSITION_HANDLE, + 0, + 4200i32.to_le_bytes().to_vec(), + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::SetTrackPosition { + peer_id, + position, + responder, + }))) = poll_result + else { + panic!("expected SetTrackPosition event, got {poll_result:?}"); + }; + assert_eq!(peer_id, peer); + assert_eq!(position, TrackPosition::from_raw_10ms(4200)); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Upper layer confirms the set track position. + responder.send(position); + + // Server processes the confirmation, updates state, and sends notification. + let _ = server.next().poll_unpin(&mut noop_cx); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, TRACK_POSITION_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, 4200i32.to_le_bytes().to_vec()); + + // Subsequent read reflects the updated track position + fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + assert_eq!(value.unwrap(), 4200i32.to_le_bytes()); + } + + #[test] + fn write_track_position_invalid_length_or_offset() { + let (mut server, fake_gatt_server, mut event_receiver) = + setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + // Invalid offset (non-zero) + fake_gatt_server.incoming_write( + peer, + service_id, + TRACK_POSITION_HANDLE, + 1, + 4200i32.to_le_bytes().to_vec(), + ); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); + + // Invalid value length (3 bytes instead of 4) + fake_gatt_server.incoming_write( + peer, + service_id, + TRACK_POSITION_HANDLE, + 0, + vec![0x01, 0x02, 0x03], + ); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert_matches!( value.unwrap_err(), - bt_gatt::types::Error::Gatt(GattError::InvalidHandle) - )); + bt_gatt::types::Error::Gatt(GattError::InvalidAttributeValueLength) + ); + } + + #[test] + fn write_playback_speed_success() { + let (mut server, fake_gatt_server, mut event_receiver) = + setup_test_server(McsServerBuilder::generic(0x42, "Test Player").with_player_speeds()); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + PLAYBACK_SPEED_HANDLE, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + let _ = event_receiver.try_recv(); + + fake_gatt_server.incoming_write( + peer, + service_id, + PLAYBACK_SPEED_HANDLE, + 0, + vec![-64i8 as u8], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::SetPlaybackSpeed { peer_id, speed, responder }))) = + poll_result + else { + panic!("expected SetPlaybackSpeed event, got {poll_result:?}"); + }; + assert_eq!(peer_id, peer); + assert_eq!(speed, PlaybackSpeed::HALF); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Upper layer confirms playback speed + responder.send(speed); + let _ = server.next().poll_unpin(&mut noop_cx); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, PLAYBACK_SPEED_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![-64i8 as u8]); + + // Subsequent read reflects the updated playback speed + fake_gatt_server.incoming_read(peer, service_id, PLAYBACK_SPEED_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + assert_eq!(value.unwrap(), vec![-64i8 as u8]); + } + + #[test] + fn write_playback_speed_invalid_length_or_offset() { + let (mut server, fake_gatt_server, mut event_receiver) = + setup_test_server(McsServerBuilder::generic(0x42, "Test Player").with_player_speeds()); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + // Invalid offset (non-zero) + fake_gatt_server.incoming_write(peer, service_id, PLAYBACK_SPEED_HANDLE, 1, vec![0x00]); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); + + // Invalid value length (2 bytes instead of 1) + fake_gatt_server.incoming_write( + peer, + service_id, + PLAYBACK_SPEED_HANDLE, + 0, + vec![0x00, 0x01], + ); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert_matches!( + value.unwrap_err(), + bt_gatt::types::Error::Gatt(GattError::InvalidAttributeValueLength) + ); + } + + #[test] + fn write_playing_order_supported() { + let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( + McsServerBuilder::generic(0x42, "Test Player") + .with_playing_orders(SupportedPlayingOrders::all()), + ); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + PLAYING_ORDER_HANDLE, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + let _ = event_receiver.try_recv(); + + fake_gatt_server.incoming_write( + peer, + service_id, + PLAYING_ORDER_HANDLE, + 0, + vec![PlayingOrder::ShuffleOnce.into()], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::SetPlayingOrder { peer_id, order, responder }))) = + poll_result + else { + panic!("expected SetPlayingOrder event, got {poll_result:?}"); + }; + assert_eq!(peer_id, peer); + assert_eq!(order, PlayingOrder::ShuffleOnce); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Upper layer confirms playing order + responder.send(order); + let _ = server.next().poll_unpin(&mut noop_cx); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, PLAYING_ORDER_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![PlayingOrder::ShuffleOnce.into()]); + + // Subsequent read reflects the updated playing order + fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + assert_eq!(value.unwrap(), vec![PlayingOrder::ShuffleOnce.into()]); + } + + #[test] + fn write_playing_order_unsupported_is_ignored() { + let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( + McsServerBuilder::generic(0x42, "Test Player") + .with_playing_orders(SupportedPlayingOrders::IN_ORDER_ONCE), + ); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + // Writing ShuffleOnce when only InOrderOnce is supported is ignored per MCS + // v1.0.1 Section 3.15.1 + fake_gatt_server.incoming_write( + peer, + service_id, + PLAYING_ORDER_HANDLE, + 0, + vec![PlayingOrder::ShuffleOnce.into()], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Value remains unchanged (InOrderOnce) + fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + assert_eq!(value.unwrap(), vec![PlayingOrder::InOrderOnce.into()]); + } + + #[test] + fn write_read_only_characteristic_returns_error() { + let (mut server, fake_gatt_server, mut event_receiver) = + setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + let read_only_writes = [ + (MEDIA_PLAYER_NAME_HANDLE, b"New Name".to_vec()), + (TRACK_CHANGED_HANDLE, vec![]), + (TRACK_TITLE_HANDLE, b"New Title".to_vec()), + (TRACK_DURATION_HANDLE, 1000i32.to_le_bytes().to_vec()), + (MEDIA_PLAYER_ICON_URL_HANDLE, b"https://example.com".to_vec()), + (SEEKING_SPEED_HANDLE, vec![0x00]), + (PLAYING_ORDERS_SUPPORTED_HANDLE, vec![0x01, 0x00]), + (MEDIA_STATE_HANDLE, vec![0x01]), + (MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, vec![0x01, 0x00, 0x00, 0x00]), + (CONTENT_CONTROL_ID_HANDLE, vec![0x99]), + ]; + + for (handle, value) in read_only_writes { + fake_gatt_server.incoming_write(peer, service_id, handle, 0, value); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded for handle {:?}", handle); + }; + assert_matches!( + value.unwrap_err(), + bt_gatt::types::Error::Gatt(GattError::WriteNotPermitted), + "handle {:?} should return WriteNotPermitted on write", + handle + ); + } + } + + #[test] + fn control_point_unsupported_opcode_immediate_notification() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + + // Write an unsupported/RFU opcode (0xEE) + fake_gatt_server.incoming_write( + peer, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![0xEE], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + + // ATT write is acknowledged + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Immediate Media Control Point Notification sent with OPCODE_NOT_SUPPORTED + // (0x02) + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![0xEE, ControlPointResultCode::OpcodeNotSupported.into()]); + } + + #[test] + fn control_point_inactive_player_immediate_notification() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + + // Write Play opcode (0x01) while player is Inactive + fake_gatt_server.incoming_write( + peer, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![0x01], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + + // ATT write is acknowledged + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Immediate Media Control Point Notification sent with MEDIA_PLAYER_INACTIVE + // (0x03) + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![0x01, ControlPointResultCode::MediaPlayerInactive.into()]); + } + + #[test] + fn control_point_valid_command_success() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + server.set_media_state(MediaState::Playing); + + // Write Pause opcode (0x02) + fake_gatt_server.incoming_write( + peer, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![0x02], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::ControlPointCommand { opcode, responder }))) = + poll_result + else { + panic!("expected ControlPointCommand event, got {poll_result:?}"); + }; + assert_eq!(opcode, MediaControlOpcode::Pause); + + // ATT write is acknowledged + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Upper layer executes command and responds with Success + responder.send(ControlPointResultCode::Success); + + // Polling the server dispatches the notification + let _ = server.next().poll_unpin(&mut noop_cx); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![0x02, ControlPointResultCode::Success.into()]); + } + + #[test] + fn control_point_parameterized_command_success() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + server.set_media_state(MediaState::Playing); + + // MoveRelative opcode with 4-byte offset parameter + let opcode = MediaControlOpcode::MoveRelative(1500); + let mut write_val = vec![opcode.raw_opcode()]; + write_val.extend_from_slice(&1500i32.to_le_bytes()); + + fake_gatt_server.incoming_write(peer, service_id, MEDIA_CONTROL_POINT_HANDLE, 0, write_val); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::ControlPointCommand { + opcode: rx_opcode, + responder, + }))) = poll_result + else { + panic!("expected ControlPointCommand event, got {poll_result:?}"); + }; + assert_eq!(rx_opcode, opcode); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + responder.send(ControlPointResultCode::Success); + let _ = server.next().poll_unpin(&mut noop_cx); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![opcode.raw_opcode(), ControlPointResultCode::Success.into()]); + } + + #[test] + fn control_point_truncated_parameter_sends_opcode_not_supported() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + server.set_media_state(MediaState::Playing); + + // MoveRelative requires 5 octets; provide only 2 octets + let raw_op = MediaControlOpcode::MoveRelative(0).raw_opcode(); + fake_gatt_server.incoming_write( + peer, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![raw_op, 0x01], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![raw_op, ControlPointResultCode::OpcodeNotSupported.into()]); + } + + #[test] + fn control_point_empty_value_or_invalid_offset_returns_gatt_error() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + + // Non-zero offset returns InvalidOffset + fake_gatt_server.incoming_write( + peer, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 1, + vec![0x01], + ); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); + + // Empty value returns InvalidAttributeValueLength + fake_gatt_server.incoming_write(peer, service_id, MEDIA_CONTROL_POINT_HANDLE, 0, vec![]); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert_matches!( + value.unwrap_err(), + bt_gatt::types::Error::Gatt(GattError::InvalidAttributeValueLength) + ); + } + + #[test] + fn control_point_unsupported_opcode_on_active_player_returns_opcode_not_supported() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + server.set_media_state(MediaState::Playing); + server.set_supported_opcodes(SupportedOpcodes::PLAY); + + // Pause is valid in spec, but not in supported_opcodes for this server instance + fake_gatt_server.incoming_write( + peer, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![MediaControlOpcode::Pause.raw_opcode()], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!( + value, + vec![ + MediaControlOpcode::Pause.raw_opcode(), + ControlPointResultCode::OpcodeNotSupported.into() + ] + ); + } + + #[test] + fn control_point_dropped_responder_sends_cannot_be_completed() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + server.set_media_state(MediaState::Playing); + + // Write Stop opcode (0x05) + fake_gatt_server.incoming_write( + peer, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![0x05], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::ControlPointCommand { opcode, responder }))) = + poll_result + else { + panic!("expected ControlPointCommand event, got {poll_result:?}"); + }; + assert_eq!(opcode, MediaControlOpcode::Stop); + + // ATT write is acknowledged + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Responder is dropped without calling send + drop(responder); + + // Polling the server dispatches the fallback notification + let _ = server.next().poll_unpin(&mut noop_cx); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![0x05, ControlPointResultCode::CommandCannotBeCompleted.into()]); + } + + #[test] + fn control_point_multiple_consecutive_notifications() { + let peer1 = PeerId(1); + let peer2 = PeerId(2); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer1); + server.set_media_state(MediaState::Playing); + + // Also configure notifications for peer2 + fake_gatt_server.incoming_client_configuration( + peer2, + server.service_def.id(), + MEDIA_CONTROL_POINT_HANDLE, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + let _ = event_receiver.try_recv(); + + // Issue 1st command (Play from peer1) + fake_gatt_server.incoming_write( + peer1, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![MediaControlOpcode::Play.raw_opcode()], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::ControlPointCommand { + opcode: op1, + responder: resp1, + }))) = poll_result + else { + panic!("expected 1st ControlPointCommand event, got {poll_result:?}"); + }; + assert_eq!(op1, MediaControlOpcode::Play); + + // Issue 2nd command (NextTrack from peer2) + fake_gatt_server.incoming_write( + peer2, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![MediaControlOpcode::NextTrack.raw_opcode()], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::ControlPointCommand { + opcode: op2, + responder: resp2, + }))) = poll_result + else { + panic!("expected 2nd ControlPointCommand event, got {poll_result:?}"); + }; + assert_eq!(op2, MediaControlOpcode::NextTrack); + + // Drain write acknowledgements + let FakeServerEvent::WriteResponded { value: val1, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected 1st WriteResponded"); + }; + assert!(val1.is_ok()); + + let FakeServerEvent::WriteResponded { value: val2, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected 2nd WriteResponded"); + }; + assert!(val2.is_ok()); + + // Respond to both commands in a row before polling the stream + resp1.send(ControlPointResultCode::Success); + resp2.send(ControlPointResultCode::CommandCannotBeCompleted); + + // Polling the server stream drains both pending control point responses and + // issues two local service notifications in a single poll_next invocation. + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + + let n1 = event_receiver.try_recv().unwrap(); + let n2 = event_receiver.try_recv().unwrap(); + + let mut received = Vec::new(); + for event in [n1, n2] { + let FakeServerEvent::Notified { handle, value, peers, .. } = event else { + panic!("expected Notified event, got {event:?}"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + received.push((peers, value)); + } + + assert!(received.contains(&( + vec![peer1], + vec![MediaControlOpcode::Play.raw_opcode(), ControlPointResultCode::Success.into()] + ))); + assert!(received.contains(&( + vec![peer2], + vec![ + MediaControlOpcode::NextTrack.raw_opcode(), + ControlPointResultCode::CommandCannotBeCompleted.into() + ] + ))); + + // No further events pending + assert_matches!(event_receiver.try_recv(), Err(_)); + } + + #[test] + fn server_stream_drains_pending_control_point_responses_before_terminating() { + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = + setup_test_server_with_control_point(peer); + server.set_media_state(MediaState::Playing); + + // Write Pause opcode (0x02) + fake_gatt_server.incoming_write( + peer, + service_id, + MEDIA_CONTROL_POINT_HANDLE, + 0, + vec![0x02], + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::ControlPointCommand { opcode, responder }))) = + poll_result + else { + panic!("expected ControlPointCommand event, got {poll_result:?}"); + }; + assert_eq!(opcode, MediaControlOpcode::Pause); + + // ATT write is acknowledged + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Replace local_service events stream with a custom channel and close it. + let (sender, receiver) = futures::channel::mpsc::unbounded(); + let LocalServiceState::Published { service, .. } = + std::mem::replace(&mut server.local_service, LocalServiceState::Terminated) + else { + panic!("Expected server to be in Published state"); + }; + server.local_service = LocalServiceState::Published { service, events: receiver }; + drop(sender); + + // Polling the server returns Pending because there is still an in-flight + // responder + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + + // Upper layer executes command and responds with Success + responder.send(ControlPointResultCode::Success); + + // Polling the server dispatches the notification and drains the in-flight + // response, then terminates the stream as the GATT service event stream + // has closed. + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Ready(None)); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, vec![0x02, ControlPointResultCode::Success.into()]); + } + + #[test] + fn write_negative_track_position_success() { + let (mut server, fake_gatt_server, mut event_receiver) = + setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); + server.state.media_state = MediaState::Paused; + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + TRACK_POSITION_HANDLE, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + let _ = event_receiver.try_recv(); + + // Negative value represents offset from end of track per MCS v1.0.1 Section + // 3.7.1 + fake_gatt_server.incoming_write( + peer, + service_id, + TRACK_POSITION_HANDLE, + 0, + (-500i32).to_le_bytes().to_vec(), + ); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::SetTrackPosition { + peer_id, + position, + responder, + }))) = poll_result + else { + panic!("expected SetTrackPosition event, got {poll_result:?}"); + }; + assert_eq!(peer_id, peer); + assert_eq!(position, TrackPosition::from_raw_10ms(-500)); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Upper layer confirms the updated track position + responder.send(position); + let _ = server.next().poll_unpin(&mut noop_cx); + + let FakeServerEvent::Notified { handle, value, peers, .. } = + event_receiver.try_recv().unwrap() + else { + panic!("expected Notified"); + }; + assert_eq!(handle, TRACK_POSITION_HANDLE); + assert_eq!(peers, vec![peer]); + assert_eq!(value, (-500i32).to_le_bytes().to_vec()); + + // Subsequent read reflects the updated track position + fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + assert_eq!(value.unwrap(), (-500i32).to_le_bytes()); + } + + #[test] + fn write_responder_dropped_without_send_does_not_notify_or_mutate() { + let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( + McsServerBuilder::generic(0x42, "Test Player") + .with_player_speeds() + .with_playing_orders(SupportedPlayingOrders::all()), + ); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + // 1. Playback speed write + fake_gatt_server.incoming_write(peer, service_id, PLAYBACK_SPEED_HANDLE, 0, vec![0x40]); + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::SetPlaybackSpeed { responder, .. }))) = poll_result + else { + panic!("expected SetPlaybackSpeed event, got {poll_result:?}"); + }; + // ATT write is acknowledged + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Drop responder without calling send + drop(responder); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + // No notification should be emitted + assert_matches!(event_receiver.try_recv(), Err(_)); + + // Playback speed remains unchanged + fake_gatt_server.incoming_read(peer, service_id, PLAYBACK_SPEED_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + assert_eq!(value.unwrap(), vec![PlaybackSpeed::NORMAL.into()]); + + // 2. Track position write + fake_gatt_server.incoming_write( + peer, + service_id, + TRACK_POSITION_HANDLE, + 0, + 500i32.to_le_bytes().to_vec(), + ); + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::SetTrackPosition { responder, .. }))) = poll_result + else { + panic!("expected SetTrackPosition event, got {poll_result:?}"); + }; + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + drop(responder); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + assert_matches!(event_receiver.try_recv(), Err(_)); + + // Track position remains unavailable + fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + assert_eq!(value.unwrap(), TrackPosition::Unavailable.raw_10ms().to_le_bytes()); + + // 3. Playing order write + fake_gatt_server.incoming_write( + peer, + service_id, + PLAYING_ORDER_HANDLE, + 0, + vec![PlayingOrder::ShuffleOnce.into()], + ); + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::SetPlayingOrder { responder, .. }))) = poll_result + else { + panic!("expected SetPlayingOrder event, got {poll_result:?}"); + }; + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + drop(responder); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + assert_matches!(event_receiver.try_recv(), Err(_)); + + // Playing order remains default InOrderOnce + fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + assert_eq!(value.unwrap(), vec![PlayingOrder::InOrderOnce.into()]); + } + + #[test] + fn write_playing_order_invalid_value_is_ignored() { + let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( + McsServerBuilder::generic(0x42, "Test Player") + .with_playing_orders(SupportedPlayingOrders::all()), + ); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + // Write an out-of-range RFU value (0xFF) + fake_gatt_server.incoming_write(peer, service_id, PLAYING_ORDER_HANDLE, 0, vec![0xFF]); + + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + + let FakeServerEvent::WriteResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected WriteResponded"); + }; + assert!(value.is_ok()); + + // Value remains unchanged (default InOrderOnce) + fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let FakeServerEvent::ReadResponded { value, .. } = event_receiver.try_recv().unwrap() + else { + panic!("expected ReadResponded"); + }; + 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]
diff --git a/rust/bt-mcs/src/types.rs b/rust/bt-mcs/src/types.rs index 1d9d8d0..35594c4 100644 --- a/rust/bt-mcs/src/types.rs +++ b/rust/bt-mcs/src/types.rs
@@ -706,6 +706,205 @@ } } +/// Playback speed of the media player (MCS v1.0.1 Section 3.8). +/// Expressed as a power of 2 with a scale factor of 64: multiplier = 2^(speed / +/// 64). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlaybackSpeed(i8); + +impl PlaybackSpeed { + /// Minimum supported playback speed. + pub const MIN: Self = Self(-128); + /// Quarter (0.25x) playback speed. + pub const QUARTER: Self = Self(-128); + /// Half (0.50x) playback speed. + pub const HALF: Self = Self(-64); + /// Normal (1.00x) playback speed. + pub const NORMAL: Self = Self(0); + /// Double (2.00x) playback speed. + pub const DOUBLE: Self = Self(64); + /// Maximum supported playback speed. + pub const MAX: Self = Self(127); + + /// Calculates the floating-point speed multiplier (e.g., 1.0 for normal, + /// 0.5 for half speed). + pub fn as_multiplier(&self) -> f64 { + 2.0f64.powf(self.0 as f64 / 64.0) + } + + /// Constructs a `PlaybackSpeed` from a floating-point multiplier, clamping + /// to the supported range. + pub fn from_multiplier(multiplier: f64) -> Self { + if multiplier <= 0.0 { + return Self::MIN; + } + let p = (64.0 * multiplier.log2()).round(); + let clamped = p.clamp(i8::MIN as f64, i8::MAX as f64) as i8; + Self(clamped) + } +} + +impl Default for PlaybackSpeed { + fn default() -> Self { + Self::NORMAL + } +} + +impl From<i8> for PlaybackSpeed { + fn from(raw: i8) -> Self { + Self(raw) + } +} + +impl From<PlaybackSpeed> for i8 { + fn from(speed: PlaybackSpeed) -> Self { + speed.0 + } +} + +impl From<PlaybackSpeed> for u8 { + fn from(speed: PlaybackSpeed) -> Self { + speed.0 as u8 + } +} + +impl TryFrom<&[u8]> for PlaybackSpeed { + type Error = PacketError; + + fn try_from(buf: &[u8]) -> Result<Self, Self::Error> { + if buf.len() != 1 { + return Err(PacketError::UnexpectedDataLength); + } + Ok(Self(buf[0] as i8)) + } +} + +impl Encodable for PlaybackSpeed { + type Error = PacketError; + + fn encoded_len(&self) -> usize { + 1 + } + + fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> { + if buf.is_empty() { + return Err(PacketError::BufferTooSmall); + } + buf[0] = self.0 as u8; + Ok(()) + } +} + +impl Decodable for PlaybackSpeed { + type Error = PacketError; + + fn decode(buf: &[u8]) -> (Result<Self, Self::Error>, usize) { + if buf.is_empty() { + return (Err(PacketError::UnexpectedDataLength), 0); + } + (Ok(Self(buf[0] as i8)), 1) + } +} + +/// Seeking speed factor of the media player (MCS v1.0.1 Section 3.9). +/// A signed integer representing the multiplier factor during fast-forwarding +/// or fast-rewinding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SeekingSpeed(i8); + +impl SeekingSpeed { + /// Not seeking. + pub const NOT_SEEKING: Self = Self(0); + + /// Constructs a `SeekingSpeed` from the signed speed factor. + pub const fn new(factor: i8) -> Self { + Self(factor) + } + + /// Returns the signed seeking factor. + pub const fn factor(&self) -> i8 { + self.0 + } + + /// Returns `true` if the media player is currently fast-forwarding or + /// rewinding. + pub const fn is_seeking(&self) -> bool { + self.0 != 0 + } + + /// Returns `true` if the media player is fast-forwarding. + pub const fn is_fast_forwarding(&self) -> bool { + self.0 > 0 + } + + /// Returns `true` if the media player is fast-rewinding. + pub const fn is_fast_rewinding(&self) -> bool { + self.0 < 0 + } +} + +impl Default for SeekingSpeed { + fn default() -> Self { + Self::NOT_SEEKING + } +} + +impl From<i8> for SeekingSpeed { + fn from(raw: i8) -> Self { + Self(raw) + } +} + +impl From<SeekingSpeed> for i8 { + fn from(speed: SeekingSpeed) -> Self { + speed.0 + } +} + +impl From<SeekingSpeed> for u8 { + fn from(speed: SeekingSpeed) -> Self { + speed.0 as u8 + } +} + +impl TryFrom<&[u8]> for SeekingSpeed { + type Error = PacketError; + + fn try_from(buf: &[u8]) -> Result<Self, Self::Error> { + if buf.len() != 1 { + return Err(PacketError::UnexpectedDataLength); + } + Ok(Self(buf[0] as i8)) + } +} + +impl Encodable for SeekingSpeed { + type Error = PacketError; + + fn encoded_len(&self) -> usize { + 1 + } + + fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> { + if buf.is_empty() { + return Err(PacketError::BufferTooSmall); + } + buf[0] = self.0 as u8; + Ok(()) + } +} + +impl Decodable for SeekingSpeed { + type Error = PacketError; + + fn decode(buf: &[u8]) -> (Result<Self, Self::Error>, usize) { + if buf.is_empty() { + return (Err(PacketError::UnexpectedDataLength), 0); + } + (Ok(Self(buf[0] as i8)), 1) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1146,4 +1345,91 @@ assert_eq!(consumed, 0); assert_eq!(err.unwrap_err(), PacketError::UnexpectedDataLength); } + + #[test] + fn playback_speed_helpers_and_encoding() { + assert_eq!(i8::from(PlaybackSpeed::NORMAL), 0); + assert_eq!(i8::from(PlaybackSpeed::QUARTER), -128); + assert_eq!(i8::from(PlaybackSpeed::HALF), -64); + assert_eq!(i8::from(PlaybackSpeed::DOUBLE), 64); + assert_eq!(i8::from(PlaybackSpeed::MAX), 127); + + assert!((PlaybackSpeed::NORMAL.as_multiplier() - 1.0).abs() < 1e-6); + assert!((PlaybackSpeed::QUARTER.as_multiplier() - 0.25).abs() < 1e-6); + assert!((PlaybackSpeed::HALF.as_multiplier() - 0.5).abs() < 1e-6); + assert!((PlaybackSpeed::DOUBLE.as_multiplier() - 2.0).abs() < 1e-6); + + assert_eq!(PlaybackSpeed::from_multiplier(1.0), PlaybackSpeed::NORMAL); + assert_eq!(PlaybackSpeed::from_multiplier(0.25), PlaybackSpeed::QUARTER); + assert_eq!(PlaybackSpeed::from_multiplier(0.5), PlaybackSpeed::HALF); + assert_eq!(PlaybackSpeed::from_multiplier(2.0), PlaybackSpeed::DOUBLE); + assert_eq!(PlaybackSpeed::from_multiplier(0.0), PlaybackSpeed::MIN); + assert_eq!(PlaybackSpeed::from_multiplier(100.0), PlaybackSpeed::MAX); + + let speed = PlaybackSpeed::DOUBLE; + let mut buf = [0u8; 1]; + assert!(speed.encode(&mut buf).is_ok()); + assert_eq!(buf[0], 64); + + let (decoded, consumed) = PlaybackSpeed::decode(&buf); + assert_eq!(consumed, 1); + assert_eq!(decoded.unwrap(), speed); + + assert_eq!(PlaybackSpeed::try_from(&buf[..]).unwrap(), speed); + assert_eq!( + PlaybackSpeed::try_from(&[][..]).unwrap_err(), + PacketError::UnexpectedDataLength + ); + assert_eq!( + PlaybackSpeed::try_from(&[1, 2][..]).unwrap_err(), + PacketError::UnexpectedDataLength + ); + + let mut empty = [0u8; 0]; + assert_eq!(speed.encode(&mut empty).unwrap_err(), PacketError::BufferTooSmall); + let (err, consumed) = PlaybackSpeed::decode(&[]); + assert_eq!(consumed, 0); + assert_eq!(err.unwrap_err(), PacketError::UnexpectedDataLength); + } + + #[test] + fn seeking_speed_helpers_and_encoding() { + assert_eq!(SeekingSpeed::NOT_SEEKING.factor(), 0); + assert!(!SeekingSpeed::NOT_SEEKING.is_seeking()); + assert!(!SeekingSpeed::NOT_SEEKING.is_fast_forwarding()); + assert!(!SeekingSpeed::NOT_SEEKING.is_fast_rewinding()); + + let fast_forward = SeekingSpeed::new(4); + assert_eq!(fast_forward.factor(), 4); + assert!(fast_forward.is_seeking()); + assert!(fast_forward.is_fast_forwarding()); + assert!(!fast_forward.is_fast_rewinding()); + + let fast_rewind = SeekingSpeed::new(-4); + assert_eq!(fast_rewind.factor(), -4); + assert!(fast_rewind.is_seeking()); + assert!(!fast_rewind.is_fast_forwarding()); + assert!(fast_rewind.is_fast_rewinding()); + + let mut buf = [0u8; 1]; + assert!(fast_forward.encode(&mut buf).is_ok()); + assert_eq!(buf[0], 4); + + let (decoded, consumed) = SeekingSpeed::decode(&buf); + assert_eq!(consumed, 1); + assert_eq!(decoded.unwrap(), fast_forward); + + assert_eq!(SeekingSpeed::try_from(&buf[..]).unwrap(), fast_forward); + assert_eq!(SeekingSpeed::try_from(&[][..]).unwrap_err(), PacketError::UnexpectedDataLength); + assert_eq!( + SeekingSpeed::try_from(&[1, 2][..]).unwrap_err(), + PacketError::UnexpectedDataLength + ); + + let mut empty = [0u8; 0]; + assert_eq!(fast_forward.encode(&mut empty).unwrap_err(), PacketError::BufferTooSmall); + let (err, consumed) = SeekingSpeed::decode(&[]); + assert_eq!(consumed, 0); + assert_eq!(err.unwrap_err(), PacketError::UnexpectedDataLength); + } }