| // 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. |
| |
| //! Implements the Media Control Service (MCS) server. |
| |
| 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::{ |
| CharacteristicProperties, CharacteristicProperty, GattError, Handle, ServiceKind, |
| }; |
| use futures::stream::{FuturesUnordered, Stream}; |
| use pin_project::pin_project; |
| use std::future::Future; |
| 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; |
| |
| /// Internal state for the MCS server. |
| #[pin_project(project = LocalServiceProj)] |
| enum LocalServiceState<T: bt_gatt::ServerTypes> { |
| /// Service definition has not been registered in the GATT database. |
| NotPublished { |
| waker: Option<Waker>, |
| }, |
| /// Service registration is in progress. |
| Preparing { |
| #[pin] |
| fut: T::LocalServiceFut, |
| }, |
| /// Service registration is complete and active in the GATT database. |
| Published { |
| service: T::LocalService, |
| #[pin] |
| events: T::ServiceEventStream, |
| }, |
| Terminated, |
| } |
| |
| impl<T: bt_gatt::ServerTypes> Default for LocalServiceState<T> { |
| fn default() -> Self { |
| Self::NotPublished { waker: None } |
| } |
| } |
| |
| impl<T: bt_gatt::ServerTypes> LocalServiceState<T> { |
| 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> { |
| type Item = Result<ServiceEvent<T>, Error>; |
| |
| fn poll_next( |
| mut self: std::pin::Pin<&mut Self>, |
| cx: &mut Context<'_>, |
| ) -> Poll<Option<Self::Item>> { |
| loop { |
| match self.as_mut().project() { |
| LocalServiceProj::Terminated => return Poll::Ready(None), |
| LocalServiceProj::NotPublished { waker } => { |
| *waker = Some(cx.waker().clone()); |
| return Poll::Pending; |
| } |
| LocalServiceProj::Preparing { fut } => match futures::ready!(fut.poll(cx)) { |
| Ok(service) => { |
| let events = service.publish(); |
| self.as_mut().set(LocalServiceState::Published { service, events }); |
| } |
| Err(e) => { |
| self.as_mut().set(LocalServiceState::NotPublished { waker: None }); |
| return Poll::Ready(Some(Err(Error::Gatt(e)))); |
| } |
| }, |
| LocalServiceProj::Published { service: _, events } => { |
| match futures::ready!(events.poll_next(cx)) { |
| Some(Ok(event)) => return Poll::Ready(Some(Ok(event))), |
| Some(Err(e)) => { |
| self.as_mut().set(LocalServiceState::Terminated); |
| return Poll::Ready(Some(Err(Error::Gatt(e)))); |
| } |
| // Deferred to McsServer to allow draining in-flight control point |
| // responses. |
| None => return Poll::Ready(None), |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| /// Builder for configuring an MCS or GMCS GATT service. |
| // TODO(b/549911651): Add support for OTS (Object Transfer Service) integration. |
| #[derive(Debug, Clone, PartialEq)] |
| pub struct McsServerBuilder { |
| /// Service UUID assigned to the service. |
| service_uuid: Uuid, |
| /// Local state of the characteristics in this server. |
| state: McsLocalState, |
| } |
| |
| impl McsServerBuilder { |
| /// Creates a builder for the Generic Media Control Service. |
| pub fn generic(ccid: u8, player_name: impl Into<String>) -> Self { |
| Self::new(GENERIC_MEDIA_CONTROL_SERVICE_UUID, ccid, player_name) |
| } |
| |
| /// Creates a builder for an application-specific Media Control Service. |
| pub fn instance(ccid: u8, player_name: impl Into<String>) -> Self { |
| Self::new(MEDIA_CONTROL_SERVICE_UUID, ccid, player_name) |
| } |
| |
| fn new(service_uuid: Uuid, ccid: u8, player_name: impl Into<String>) -> Self { |
| Self { service_uuid, state: McsLocalState::new(ccid, player_name) } |
| } |
| |
| /// Enables media player icon URL support. |
| pub fn with_icon_url(mut self, url: impl Into<String>) -> Self { |
| self.state.icon_url = Some(url.into()); |
| self |
| } |
| |
| /// Enables playback and seeking speed support with default speeds. |
| pub fn with_player_speeds(mut self) -> Self { |
| self.state.playback_speed = Some(PlaybackSpeed::NORMAL); |
| self.state.seeking_speed = Some(SeekingSpeed::NOT_SEEKING); |
| self |
| } |
| |
| /// Enables playing order support with the given supported playing orders. |
| pub fn with_playing_orders(mut self, supported: SupportedPlayingOrders) -> Self { |
| self.state.playing_orders = Some(PlayingOrderState::new(supported)); |
| self |
| } |
| |
| /// Enables media control operations with the given supported opcodes. |
| pub fn with_supported_operations(mut self, supported: SupportedOpcodes) -> Self { |
| self.state.supported_opcodes = Some(supported); |
| self |
| } |
| |
| /// Constructs the GATT [`ServiceDefinition`] containing the mandatory |
| /// characteristics per MCS v1.0.1 Section 3 and any configured optional |
| /// characteristics. |
| pub fn build_service_definition(&self) -> Result<ServiceDefinition, Error> { |
| // The local `ServiceId` is derived from the provided `CCID`. This is valid |
| // because the CCID must be unique across all MCS/GMCS instances on the |
| // host server. Adding a duplicate characteristic will result in an |
| // Error. |
| let mut service_def = ServiceDefinition::new( |
| ServiceId::new(self.state.ccid.into()), |
| self.service_uuid, |
| ServiceKind::Primary, |
| ); |
| |
| for chrc in mandatory_characteristics() { |
| service_def.add_characteristic(chrc)?; |
| } |
| |
| if self.state.icon_url.is_some() { |
| service_def.add_characteristic(build_characteristic( |
| MEDIA_PLAYER_ICON_URL_HANDLE, |
| MEDIA_PLAYER_ICON_URL_UUID, |
| CharacteristicProperty::Read, |
| ))?; |
| } |
| if self.state.playback_speed.is_some() { |
| service_def.add_characteristic(build_characteristic( |
| PLAYBACK_SPEED_HANDLE, |
| PLAYBACK_SPEED_UUID, |
| CharacteristicProperties::READ_WRITE_NOTIFY, |
| ))?; |
| } |
| if self.state.seeking_speed.is_some() { |
| service_def.add_characteristic(build_characteristic( |
| SEEKING_SPEED_HANDLE, |
| SEEKING_SPEED_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ))?; |
| } |
| if self.state.playing_orders.is_some() { |
| service_def.add_characteristic(build_characteristic( |
| PLAYING_ORDER_HANDLE, |
| PLAYING_ORDER_UUID, |
| CharacteristicProperties::READ_WRITE_NOTIFY, |
| ))?; |
| service_def.add_characteristic(build_characteristic( |
| PLAYING_ORDERS_SUPPORTED_HANDLE, |
| PLAYING_ORDERS_SUPPORTED_UUID, |
| CharacteristicProperty::Read, |
| ))?; |
| } |
| if self.state.supported_opcodes.is_some() { |
| service_def.add_characteristic(build_characteristic( |
| MEDIA_CONTROL_POINT_HANDLE, |
| MEDIA_CONTROL_POINT_UUID, |
| CharacteristicProperties::WRITE_NOTIFY, |
| ))?; |
| service_def.add_characteristic(build_characteristic( |
| MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, |
| MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ))?; |
| } |
| |
| Ok(service_def) |
| } |
| |
| /// 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::new(service_def, self.state)) |
| } |
| } |
| |
| /// An instance of a Media Control Service (MCS) or Generic Media Control |
| /// Service (GMCS) GATT server. |
| #[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 |
| } |
| |
| /// Returns true if the server has successfully published the GATT service. |
| pub fn is_published(&self) -> bool { |
| self.local_service.is_published() |
| } |
| |
| /// Publishes the service to the GATT database. |
| pub fn publish(&mut self, server: T::Server) -> Result<(), Error> { |
| let LocalServiceState::NotPublished { waker } = &mut self.local_service else { |
| return Err(Error::AlreadyPublished); |
| }; |
| |
| let waker = waker.take(); |
| self.local_service = |
| LocalServiceState::Preparing { fut: server.prepare(self.service_def.clone()) }; |
| |
| if let Some(w) = waker { |
| w.wake(); |
| } |
| |
| 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<McsServerEvent, Error>; |
| |
| fn poll_next( |
| mut self: std::pin::Pin<&mut Self>, |
| cx: &mut Context<'_>, |
| ) -> Poll<Option<Self::Item>> { |
| loop { |
| 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 } => { |
| this.handle_read(handle, offset as usize, responder); |
| } |
| 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, |
| } |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| use assert_matches::assert_matches; |
| use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeTypes}; |
| use bt_gatt::Characteristic; |
| use futures::{FutureExt, StreamExt}; |
| |
| #[test] |
| fn builder_generic_service_definition() { |
| let builder = McsServerBuilder::generic(0x42, "Test Generic Player"); |
| 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); |
| assert_eq!(service_def.characteristics().count(), 7); |
| } |
| |
| #[test] |
| fn builder_instance_service_definition() { |
| let builder = McsServerBuilder::instance(0x07, "Test Instance Player"); |
| let service_def = builder |
| .build_service_definition() |
| .expect("instance service definition builds successfully"); |
| assert_eq!(service_def.uuid(), MEDIA_CONTROL_SERVICE_UUID); |
| assert_eq!(service_def.id(), ServiceId::new(0x07)); |
| assert_eq!(service_def.kind(), ServiceKind::Primary); |
| assert_eq!(service_def.characteristics().count(), 7); |
| } |
| |
| #[test] |
| fn builder_with_optional_characteristics_service_definition() { |
| let builder = McsServerBuilder::generic(0x10, "Full Player") |
| .with_icon_url("https://example.com/icon.png") |
| .with_player_speeds() |
| .with_playing_orders(SupportedPlayingOrders::default()) |
| .with_supported_operations(SupportedOpcodes::default()); |
| |
| let service_def = |
| builder.build_service_definition().expect("service definition builds successfully"); |
| assert_eq!(service_def.characteristics().count(), 14); |
| |
| let characteristics: Vec<&Characteristic> = service_def.characteristics().collect(); |
| let expected_characteristics = [ |
| mandatory_characteristics()[0].clone(), |
| mandatory_characteristics()[1].clone(), |
| mandatory_characteristics()[2].clone(), |
| mandatory_characteristics()[3].clone(), |
| mandatory_characteristics()[4].clone(), |
| mandatory_characteristics()[5].clone(), |
| mandatory_characteristics()[6].clone(), |
| build_characteristic( |
| MEDIA_PLAYER_ICON_URL_HANDLE, |
| MEDIA_PLAYER_ICON_URL_UUID, |
| CharacteristicProperty::Read, |
| ), |
| build_characteristic( |
| PLAYBACK_SPEED_HANDLE, |
| PLAYBACK_SPEED_UUID, |
| CharacteristicProperties::READ_WRITE_NOTIFY, |
| ), |
| build_characteristic( |
| SEEKING_SPEED_HANDLE, |
| SEEKING_SPEED_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_characteristic( |
| PLAYING_ORDER_HANDLE, |
| PLAYING_ORDER_UUID, |
| CharacteristicProperties::READ_WRITE_NOTIFY, |
| ), |
| build_characteristic( |
| PLAYING_ORDERS_SUPPORTED_HANDLE, |
| PLAYING_ORDERS_SUPPORTED_UUID, |
| CharacteristicProperty::Read, |
| ), |
| build_characteristic( |
| MEDIA_CONTROL_POINT_HANDLE, |
| MEDIA_CONTROL_POINT_UUID, |
| CharacteristicProperties::WRITE_NOTIFY, |
| ), |
| build_characteristic( |
| MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, |
| MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| ]; |
| |
| assert_eq!(characteristics.len(), expected_characteristics.len()); |
| for (i, expected) in expected_characteristics.iter().enumerate() { |
| let chrc = characteristics[i]; |
| assert_eq!(chrc.handle, expected.handle); |
| assert_eq!(chrc.uuid, expected.uuid); |
| assert_eq!(chrc.properties, expected.properties); |
| } |
| } |
| |
| #[test] |
| fn mandatory_characteristic_handles_uuids_and_properties() { |
| let builder = McsServerBuilder::generic(0x01, "Player"); |
| let service_def = |
| builder.build_service_definition().expect("service definition builds successfully"); |
| let characteristics: Vec<&Characteristic> = service_def.characteristics().collect(); |
| |
| let expected_mandatory = mandatory_characteristics(); |
| assert_eq!(characteristics.len(), expected_mandatory.len()); |
| |
| for (i, expected) in expected_mandatory.iter().enumerate() { |
| let chrc = characteristics[i]; |
| assert_eq!(chrc.handle, expected.handle); |
| assert_eq!(chrc.uuid, expected.uuid); |
| assert_eq!(chrc.properties, expected.properties); |
| |
| assert_eq!( |
| chrc.permissions.read.map(|s| s.encryption), |
| expected.permissions.read.map(|s| s.encryption) |
| ); |
| assert_eq!( |
| chrc.permissions.write.map(|s| s.encryption), |
| expected.permissions.write.map(|s| s.encryption) |
| ); |
| assert_eq!( |
| chrc.permissions.update.map(|s| s.encryption), |
| expected.permissions.update.map(|s| s.encryption) |
| ); |
| } |
| } |
| |
| #[test] |
| fn service_id_tracks_ccid() { |
| let builder1 = McsServerBuilder::instance(0x05, "Player 1"); |
| let builder2 = McsServerBuilder::generic(0x05, "Player 2"); |
| let builder3 = McsServerBuilder::instance(0x06, "Player 3"); |
| |
| 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)); |
| assert_ne!(def1.id(), def3.id()); |
| assert_eq!(def3.id(), ServiceId::new(6)); |
| } |
| |
| #[test] |
| fn build_server_success() { |
| let generic_server: McsServer<FakeTypes> = |
| McsServerBuilder::generic(0x42, "Test Generic Player") |
| .build() |
| .expect("generic server builds successfully"); |
| assert!(!generic_server.is_published()); |
| assert!(generic_server.is_generic_service()); |
| |
| let instance_server: McsServer<FakeTypes> = |
| McsServerBuilder::instance(0x42, "Test Instance Player") |
| .build() |
| .expect("instance server builds successfully"); |
| assert!(!instance_server.is_published()); |
| assert!(!instance_server.is_generic_service()); |
| } |
| |
| #[test] |
| fn publish_server_success() { |
| let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); |
| let mut server: McsServer<FakeTypes> = |
| McsServerBuilder::generic(0x42, "Test Generic Player") |
| .build() |
| .expect("server builds successfully"); |
| assert!(!server.is_published()); |
| |
| let (fake_gatt_server, _event_receiver) = FakeServer::new(); |
| let Poll::Pending = server.next().poll_unpin(&mut noop_cx) else { |
| panic!("Should be pending before publish"); |
| }; |
| |
| server.publish(fake_gatt_server).expect("publish succeeds"); |
| |
| // Advance state: Preparing -> Published |
| let Poll::Pending = server.next().poll_unpin(&mut noop_cx) else { |
| panic!("Should be pending after publish"); |
| }; |
| assert!(server.is_published()); |
| } |
| |
| #[test] |
| fn publish_server_already_published_error() { |
| let (fake_gatt_server, _event_receiver) = FakeServer::new(); |
| let mut server: McsServer<FakeTypes> = McsServerBuilder::generic(0x42, "Test Player") |
| .build() |
| .expect("server builds successfully"); |
| |
| server.publish(fake_gatt_server.clone()).expect("initial publish succeeds"); |
| let err = server.publish(fake_gatt_server); |
| assert_matches!(err, Err(Error::AlreadyPublished)); |
| } |
| |
| #[test] |
| fn duplicate_ccid_publish_error() { |
| let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); |
| let (fake_gatt_server, _event_receiver) = FakeServer::new(); |
| |
| let mut server1: McsServer<FakeTypes> = McsServerBuilder::instance(0x05, "Player 1") |
| .build() |
| .expect("server1 builds successfully"); |
| let mut server2: McsServer<FakeTypes> = McsServerBuilder::instance(0x05, "Player 2") |
| .build() |
| .expect("server2 builds successfully"); |
| |
| // The first server publishes successfully. |
| server1.publish(fake_gatt_server.clone()).expect("server1 publish call succeeds"); |
| let _ = server1.next().poll_unpin(&mut noop_cx); |
| assert!(server1.is_published()); |
| |
| // The GATT server rejects the second server attempting to publish with the |
| // duplicate CCID / ServiceId. |
| fake_gatt_server.set_next_prepare_result(Err(bt_gatt::types::Error::AlreadyPublished( |
| ServiceId::new(0x05), |
| ))); |
| server2.publish(fake_gatt_server).expect("server2 publish call succeeds"); |
| let poll_result = server2.next().poll_unpin(&mut noop_cx); |
| assert_matches!( |
| poll_result, |
| Poll::Ready(Some(Err(Error::Gatt(bt_gatt::types::Error::AlreadyPublished(_))))) |
| ); |
| } |
| |
| #[test] |
| fn server_stream_terminates_when_event_stream_closes() { |
| let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); |
| let (fake_gatt_server, _event_receiver) = FakeServer::new(); |
| let mut server: McsServer<FakeTypes> = McsServerBuilder::generic(0x42, "Test Player") |
| .build() |
| .expect("server builds successfully"); |
| |
| server.publish(fake_gatt_server).expect("publish succeeds"); |
| |
| // Advance to Published |
| let Poll::Pending = server.next().poll_unpin(&mut noop_cx) else { |
| panic!("Should be pending after publish"); |
| }; |
| assert!(server.is_published()); |
| |
| // Replace local_service events stream with a custom channel that can be |
| // explicitly closed. |
| 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 }; |
| |
| // Dropping the sender closes the event stream. |
| drop(sender); |
| |
| // 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)); |
| |
| // 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)); |
| } |
| |
| #[track_caller] |
| fn expect_service_event( |
| events: &mut futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>, |
| ) -> FakeServerEvent { |
| let mut cx = Context::from_waker(futures::task::noop_waker_ref()); |
| match events.poll_next_unpin(&mut cx) { |
| Poll::Ready(Some(event)) => event, |
| x => panic!("Expected fake server event, got {x:?}"), |
| } |
| } |
| |
| #[track_caller] |
| fn expect_no_service_event( |
| events: &mut futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>, |
| ) { |
| let mut cx = Context::from_waker(futures::task::noop_waker_ref()); |
| assert_matches!(events.poll_next_unpin(&mut cx), Poll::Pending); |
| } |
| |
| fn setup_test_server( |
| builder: McsServerBuilder, |
| ) -> ( |
| McsServer<FakeTypes>, |
| FakeServer, |
| futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>, |
| ) { |
| let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); |
| let (fake_gatt_server, mut event_receiver) = FakeServer::new(); |
| let mut server: McsServer<FakeTypes> = builder.build().expect("server builds successfully"); |
| server.publish(fake_gatt_server.clone()).expect("publish succeeds"); |
| let _ = server.next().poll_unpin(&mut noop_cx); |
| assert!(matches!( |
| expect_service_event(&mut event_receiver), |
| FakeServerEvent::Published { .. } |
| )); |
| (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); |
| expect_no_service_event(&mut event_receiver); |
| (server, fake_gatt_server, event_receiver, noop_cx) |
| } |
| |
| #[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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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 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); |
| expect_no_service_event(&mut event_receiver); |
| |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| else { |
| panic!("expected WriteResponded"); |
| }; |
| assert_matches!( |
| value.unwrap_err(), |
| 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); |
| expect_no_service_event(&mut event_receiver); |
| |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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); |
| expect_no_service_event(&mut event_receiver); |
| |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| else { |
| panic!("expected WriteResponded"); |
| }; |
| assert!(value.is_ok()); |
| |
| let FakeServerEvent::Notified { handle, value, peers, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| else { |
| panic!("expected WriteResponded"); |
| }; |
| assert!(value.is_ok()); |
| |
| let FakeServerEvent::Notified { handle, value, peers, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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); |
| expect_no_service_event(&mut event_receiver); |
| |
| // 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, .. } = |
| expect_service_event(&mut event_receiver) |
| else { |
| panic!("expected 1st WriteResponded"); |
| }; |
| assert!(val1.is_ok()); |
| |
| let FakeServerEvent::WriteResponded { value: val2, .. } = |
| expect_service_event(&mut event_receiver) |
| 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 = expect_service_event(&mut event_receiver); |
| let n2 = expect_service_event(&mut event_receiver); |
| |
| 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 |
| expect_no_service_event(&mut event_receiver); |
| } |
| |
| #[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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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); |
| expect_no_service_event(&mut event_receiver); |
| |
| // 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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 |
| expect_no_service_event(&mut event_receiver); |
| |
| // 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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); |
| expect_no_service_event(&mut event_receiver); |
| |
| // 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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); |
| expect_no_service_event(&mut event_receiver); |
| |
| // 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| 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, .. } = |
| expect_service_event(&mut event_receiver) |
| else { |
| panic!("expected ReadResponded"); |
| }; |
| assert_eq!(value.unwrap(), vec![PlayingOrder::InOrderOnce.into()]); |
| } |
| } |