| // 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(()) | |
| } | |
| fn notify_track_changed(&mut self) { | |
| self.local_service.notify(&TRACK_TITLE_HANDLE, self.state.track_title.as_bytes(), &[]); | |
| self.local_service.notify( | |
| &TRACK_DURATION_HANDLE, | |
| &self.state.track_duration.to_notify_value(), | |
| &[], | |
| ); | |
| self.local_service.notify( | |
| &TRACK_POSITION_HANDLE, | |
| &self.state.track_position.to_notify_value(), | |
| &[], | |
| ); | |
| // Characteristic updates must precede Track Changed notification (MCS | |
| // v1.0.1 Section 3.4.1). | |
| self.local_service.notify(&TRACK_CHANGED_HANDLE, &[], &[]); | |
| } | |
| fn notify_current_track_position(&mut self) { | |
| let raw_pos = self.state.current_track_position().to_notify_value(); | |
| self.local_service.notify(&TRACK_POSITION_HANDLE, &raw_pos, &[]); | |
| } | |
| /// Sets the current track metadata and dispatches track update | |
| /// notifications. | |
| pub fn set_track( | |
| &mut self, | |
| title: impl Into<String>, | |
| duration: TrackDuration, | |
| position: TrackPosition, | |
| ) { | |
| self.state.set_track(title.into(), duration, position); | |
| self.notify_track_changed(); | |
| } | |
| /// Clears the current track metadata and dispatches track update | |
| /// notifications. | |
| pub fn clear_track(&mut self) { | |
| self.state.clear_track(); | |
| self.notify_track_changed(); | |
| } | |
| /// Sets the current playback state and dispatches state update | |
| /// notifications. | |
| pub fn set_media_state(&mut self, state: MediaState) { | |
| if self.state.media_state == state { | |
| return; | |
| } | |
| let previous_state = self.state.media_state; | |
| self.state.set_media_state(state); | |
| self.local_service.notify(&MEDIA_STATE_HANDLE, &[state.into()], &[]); | |
| // Notify track position when paused from active playback or seeking, | |
| // when entering seeking, or when transitioning to inactive (MCS | |
| // v1.0.1 Section 3.7.1). | |
| if (state == MediaState::Paused | |
| && (previous_state == MediaState::Playing || previous_state == MediaState::Seeking)) | |
| || state == MediaState::Seeking | |
| || (state == MediaState::Inactive && previous_state != MediaState::Inactive) | |
| { | |
| self.notify_current_track_position(); | |
| } | |
| } | |
| /// Sets the playback position within the current track and dispatches | |
| /// position notifications. | |
| pub fn set_track_position(&mut self, position: TrackPosition) { | |
| self.state.set_track_position(position); | |
| self.local_service.notify( | |
| &TRACK_POSITION_HANDLE, | |
| &self.state.track_position.to_notify_value(), | |
| &[], | |
| ); | |
| } | |
| /// Sets the media player name and dispatches name update notifications. | |
| pub fn set_player_name(&mut self, name: impl Into<String>) { | |
| let name_str = name.into(); | |
| self.state.set_player_name(name_str.clone()); | |
| self.local_service.notify(&MEDIA_PLAYER_NAME_HANDLE, name_str.as_bytes(), &[]); | |
| } | |
| /// Sets the media player icon URL. | |
| pub fn set_icon_url(&mut self, url: impl Into<String>) -> Result<(), Error> { | |
| self.state.set_icon_url(url.into()) | |
| } | |
| /// Sets the playback speed and dispatches speed update notifications. | |
| pub fn set_playback_speed(&mut self, speed: PlaybackSpeed) -> Result<(), Error> { | |
| self.state.set_playback_speed(speed)?; | |
| self.local_service.notify(&PLAYBACK_SPEED_HANDLE, &[speed.into()], &[]); | |
| // Notify track position when playback speed changes during active | |
| // playback (MCS v1.0.1 Section 3.7.1). | |
| if self.state.media_state == MediaState::Playing { | |
| self.notify_current_track_position(); | |
| } | |
| Ok(()) | |
| } | |
| /// Sets the seeking speed and dispatches seeking speed update | |
| /// notifications. | |
| pub fn set_seeking_speed(&mut self, speed: SeekingSpeed) -> Result<(), Error> { | |
| self.state.set_seeking_speed(speed)?; | |
| self.local_service.notify(&SEEKING_SPEED_HANDLE, &[speed.into()], &[]); | |
| // Notify track position if seeking speed is updated while seeking (MCS | |
| // v1.0.1 Section 3.7.1, 3.9). | |
| if self.state.media_state == MediaState::Seeking { | |
| self.notify_current_track_position(); | |
| } | |
| Ok(()) | |
| } | |
| /// Sets the playing order and dispatches playing order update | |
| /// notifications. | |
| pub fn set_playing_order(&mut self, order: PlayingOrder) -> Result<(), Error> { | |
| self.state.set_playing_order(order)?; | |
| self.local_service.notify(&PLAYING_ORDER_HANDLE, &[order.into()], &[]); | |
| Ok(()) | |
| } | |
| /// Sets the supported media control opcodes and dispatches opcode update | |
| /// notifications. | |
| pub fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) -> Result<(), Error> { | |
| self.state.set_supported_opcodes(opcodes)?; | |
| self.local_service.notify( | |
| &MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, | |
| &opcodes.bits().to_le_bytes(), | |
| &[], | |
| ); | |
| Ok(()) | |
| } | |
| } | |
| impl<'a, T: bt_gatt::ServerTypes> McsServerProj<'a, T> { | |
| fn notify_current_track_position(&mut self) { | |
| let raw_pos = self.state.current_track_position().to_notify_value(); | |
| self.local_service.notify(&TRACK_POSITION_HANDLE, &raw_pos, &[]); | |
| } | |
| 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, | |
| &self.state.track_position.to_notify_value(), | |
| &[], | |
| ); | |
| } | |
| PendingWriteResponse::PlaybackSpeed(speed) => { | |
| if self.state.set_playback_speed(speed).is_ok() { | |
| self.local_service.notify(&PLAYBACK_SPEED_HANDLE, &[speed.into()], &[]); | |
| // Notify track position when playback speed changes during | |
| // active playback (MCS v1.0.1 Section | |
| // 3.7.1). | |
| if self.state.media_state == MediaState::Playing { | |
| self.notify_current_track_position(); | |
| } | |
| } | |
| } | |
| PendingWriteResponse::PlayingOrder(order) => { | |
| if self.state.set_playing_order(order).is_ok() { | |
| 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, FakeServerEventsExt, 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)); | |
| } | |
| 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!(event_receiver.expect_event(), 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); | |
| event_receiver.expect_no_event(); | |
| (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 value = event_receiver.expect_write_response(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); | |
| event_receiver.expect_no_event(); | |
| 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)); | |
| event_receiver.expect_write_response(TRACK_POSITION_HANDLE).unwrap(); | |
| // 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 (value, peers) = event_receiver.expect_notification(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 value = event_receiver.expect_read_response(TRACK_POSITION_HANDLE).unwrap(); | |
| assert_eq!(value, 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 value = event_receiver.expect_write_response(TRACK_POSITION_HANDLE); | |
| 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 value = event_receiver.expect_write_response(TRACK_POSITION_HANDLE); | |
| 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); | |
| event_receiver.expect_no_event(); | |
| 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); | |
| event_receiver.expect_write_response(PLAYBACK_SPEED_HANDLE).unwrap(); | |
| // Upper layer confirms playback speed | |
| responder.send(speed); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| let (value, peers) = event_receiver.expect_notification(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 value = event_receiver.expect_read_response(PLAYBACK_SPEED_HANDLE).unwrap(); | |
| assert_eq!(value, 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 value = event_receiver.expect_write_response(PLAYBACK_SPEED_HANDLE); | |
| 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 value = event_receiver.expect_write_response(PLAYBACK_SPEED_HANDLE); | |
| 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); | |
| event_receiver.expect_no_event(); | |
| 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); | |
| event_receiver.expect_write_response(PLAYING_ORDER_HANDLE).unwrap(); | |
| // Upper layer confirms playing order | |
| responder.send(order); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| let (value, peers) = event_receiver.expect_notification(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 value = event_receiver.expect_read_response(PLAYING_ORDER_HANDLE).unwrap(); | |
| assert_eq!(value, 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); | |
| event_receiver.expect_write_response(PLAYING_ORDER_HANDLE).unwrap(); | |
| // 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 value = event_receiver.expect_read_response(PLAYING_ORDER_HANDLE).unwrap(); | |
| assert_eq!(value, 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 value = event_receiver.expect_write_response(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 | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| // Immediate Media Control Point Notification sent with | |
| // OPCODE_NOT_SUPPORTED (0x02) | |
| let (value, peers) = event_receiver.expect_notification(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 | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| // Immediate Media Control Point Notification sent with | |
| // MEDIA_PLAYER_INACTIVE (0x03) | |
| let (value, peers) = event_receiver.expect_notification(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); | |
| let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(val, vec![MediaState::Playing.into()]); | |
| // 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 | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| // 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 (value, peers) = event_receiver.expect_notification(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); | |
| let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(val, vec![MediaState::Playing.into()]); | |
| // 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); | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| responder.send(ControlPointResultCode::Success); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| let (value, peers) = event_receiver.expect_notification(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); | |
| let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(val, vec![MediaState::Playing.into()]); | |
| // 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); | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| let (value, peers) = event_receiver.expect_notification(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 value = event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE); | |
| 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 value = event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE); | |
| 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); | |
| let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(val, vec![MediaState::Playing.into()]); | |
| server | |
| .set_supported_opcodes(SupportedOpcodes::PLAY) | |
| .expect("set_supported_opcodes succeeds"); | |
| let (val, peers) = | |
| event_receiver.expect_notification(MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE); | |
| assert_eq!(val, SupportedOpcodes::PLAY.bits().to_le_bytes().to_vec()); | |
| assert_eq!(peers, vec![peer]); | |
| // 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); | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| let (value, peers) = event_receiver.expect_notification(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); | |
| let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(val, vec![MediaState::Playing.into()]); | |
| // 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 | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| // Responder is dropped without calling send | |
| drop(responder); | |
| // Polling the server dispatches the fallback notification | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| let (value, peers) = event_receiver.expect_notification(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); | |
| let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(val, vec![MediaState::Playing.into()]); | |
| // 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); | |
| event_receiver.expect_no_event(); | |
| // 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 | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| // 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 (val1, peers1) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); | |
| let (val2, peers2) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); | |
| let received = vec![(peers1, val1), (peers2, val2)]; | |
| 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 | |
| event_receiver.expect_no_event(); | |
| } | |
| #[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); | |
| let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(val, vec![MediaState::Playing.into()]); | |
| // 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 | |
| event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); | |
| // 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 (value, peers) = event_receiver.expect_notification(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; | |
| server.state.track_duration = TrackDuration::from_raw_10ms(3000); // 30s | |
| 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); | |
| event_receiver.expect_no_event(); | |
| // 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)); | |
| assert_matches!(event_receiver.expect_write_response(TRACK_POSITION_HANDLE), Ok(())); | |
| // Upper layer confirms the updated track position | |
| responder.send(position); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| let (value, peers) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| assert_eq!(peers, vec![peer]); | |
| // MCS Section 3.7.1: The characteristic value shall be set to the | |
| // offset from the start of the track to the new playing | |
| // position (3000 - 500 = 2500). | |
| assert_eq!(value, (2500i32).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 value = event_receiver | |
| .expect_read_response(TRACK_POSITION_HANDLE) | |
| .expect("read response succeeds"); | |
| assert_eq!(value, (2500i32).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 | |
| assert_matches!(event_receiver.expect_write_response(PLAYBACK_SPEED_HANDLE), 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 | |
| event_receiver.expect_no_event(); | |
| // 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 value = event_receiver | |
| .expect_read_response(PLAYBACK_SPEED_HANDLE) | |
| .expect("read response succeeds"); | |
| assert_eq!(value, 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:?}"); | |
| }; | |
| assert_matches!(event_receiver.expect_write_response(TRACK_POSITION_HANDLE), Ok(())); | |
| drop(responder); | |
| let poll_result = server.next().poll_unpin(&mut noop_cx); | |
| assert_matches!(poll_result, Poll::Pending); | |
| event_receiver.expect_no_event(); | |
| // 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 value = event_receiver | |
| .expect_read_response(TRACK_POSITION_HANDLE) | |
| .expect("read response succeeds"); | |
| assert_eq!(value, 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:?}"); | |
| }; | |
| assert_matches!(event_receiver.expect_write_response(PLAYING_ORDER_HANDLE), Ok(())); | |
| drop(responder); | |
| let poll_result = server.next().poll_unpin(&mut noop_cx); | |
| assert_matches!(poll_result, Poll::Pending); | |
| event_receiver.expect_no_event(); | |
| // 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 value = event_receiver | |
| .expect_read_response(PLAYING_ORDER_HANDLE) | |
| .expect("read response succeeds"); | |
| assert_eq!(value, 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); | |
| assert_matches!(event_receiver.expect_write_response(PLAYING_ORDER_HANDLE), 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 value = event_receiver | |
| .expect_read_response(PLAYING_ORDER_HANDLE) | |
| .expect("read response succeeds"); | |
| assert_eq!(value, vec![PlayingOrder::InOrderOnce.into()]); | |
| } | |
| #[test] | |
| fn pending_write_response_rejected_by_state_does_not_notify() { | |
| let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( | |
| McsServerBuilder::generic(0x42, "Test Player") | |
| .with_playing_orders(SupportedPlayingOrders::SINGLE_ONCE), | |
| ); | |
| 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); | |
| event_receiver.expect_no_event(); | |
| fake_gatt_server.incoming_write( | |
| peer, | |
| service_id, | |
| PLAYING_ORDER_HANDLE, | |
| 0, | |
| vec![PlayingOrder::SingleOnce.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:?}"); | |
| }; | |
| assert_matches!(event_receiver.expect_write_response(PLAYING_ORDER_HANDLE), Ok(())); | |
| // Upper layer attempts to set an unsupported playing order. | |
| responder.send(PlayingOrder::ShuffleOnce); | |
| let poll_result = server.next().poll_unpin(&mut noop_cx); | |
| assert_matches!(poll_result, Poll::Pending); | |
| // State rejected the update, so no notification should be emitted. | |
| event_receiver.expect_no_event(); | |
| fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| let value = event_receiver | |
| .expect_read_response(PLAYING_ORDER_HANDLE) | |
| .expect("read response succeeds"); | |
| assert_eq!(value, vec![PlayingOrder::InOrderOnce.into()]); | |
| } | |
| #[test] | |
| fn public_api_set_and_clear_track() { | |
| 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); | |
| for handle in | |
| [TRACK_CHANGED_HANDLE, TRACK_TITLE_HANDLE, TRACK_DURATION_HANDLE, TRACK_POSITION_HANDLE] | |
| { | |
| fake_gatt_server.incoming_client_configuration( | |
| peer, | |
| service_id, | |
| handle, | |
| bt_gatt::server::NotificationType::Notify, | |
| ); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| event_receiver.expect_no_event(); | |
| } | |
| // Set track | |
| server.set_track( | |
| "Song Title", | |
| TrackDuration::from_raw_10ms(3000), | |
| TrackPosition::from_raw_10ms(0), | |
| ); | |
| // Verify notifications per MCS Section 3.4.1 / 3.7.1 | |
| let (val, _) = event_receiver.expect_notification(TRACK_TITLE_HANDLE); | |
| assert_eq!(val, b"Song Title"); | |
| let (val, _) = event_receiver.expect_notification(TRACK_DURATION_HANDLE); | |
| assert_eq!(val, 3000i32.to_le_bytes()); | |
| let (val, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| assert_eq!(val, 0i32.to_le_bytes()); | |
| let (val, _) = event_receiver.expect_notification(TRACK_CHANGED_HANDLE); | |
| assert_eq!(val, vec![]); | |
| event_receiver.expect_no_event(); | |
| // Clear track | |
| server.clear_track(); | |
| let (val, _) = event_receiver.expect_notification(TRACK_TITLE_HANDLE); | |
| assert_eq!(val, vec![]); | |
| let (val, _) = event_receiver.expect_notification(TRACK_DURATION_HANDLE); | |
| assert_eq!(val, TrackDuration::Unknown.raw_10ms().to_le_bytes()); | |
| let (val, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| assert_eq!(val, TrackPosition::Unavailable.raw_10ms().to_le_bytes()); | |
| let (val, _) = event_receiver.expect_notification(TRACK_CHANGED_HANDLE); | |
| assert_eq!(val, vec![]); | |
| event_receiver.expect_no_event(); | |
| } | |
| #[test] | |
| fn public_api_set_media_state_and_track_position() { | |
| 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); | |
| for handle in [MEDIA_STATE_HANDLE, TRACK_POSITION_HANDLE] { | |
| fake_gatt_server.incoming_client_configuration( | |
| peer, | |
| service_id, | |
| handle, | |
| bt_gatt::server::NotificationType::Notify, | |
| ); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| event_receiver.expect_no_event(); | |
| } | |
| server.set_media_state(MediaState::Playing); | |
| let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(value, vec![MediaState::Playing.into()]); | |
| server.set_track_position(TrackPosition::from_raw_10ms(1500)); | |
| let (value, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| assert_eq!(value, 1500i32.to_le_bytes().to_vec()); | |
| // Pausing from Playing emits both MediaState and TrackPosition | |
| // notifications per MCS §3.7.1 | |
| server.set_media_state(MediaState::Paused); | |
| let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(value, vec![MediaState::Paused.into()]); | |
| let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| // Setting same MediaState is a no-op and produces no notifications per | |
| // MCS v1.0.1 Section 3.17.1. | |
| server.set_media_state(MediaState::Paused); | |
| event_receiver.expect_no_event(); | |
| // Transitioning to Inactive emits MediaState and | |
| // TrackPosition::Unavailable per MCS Section 3.7.1. | |
| server.set_media_state(MediaState::Inactive); | |
| let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(value, vec![MediaState::Inactive.into()]); | |
| let (value, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| assert_eq!(value, TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec()); | |
| // Repeated transition to Inactive produces no notifications. | |
| server.set_media_state(MediaState::Inactive); | |
| event_receiver.expect_no_event(); | |
| // Transition back to Paused with a track to test FromEnd position | |
| // resolving (MCS Section 3.7.1). | |
| server.set_media_state(MediaState::Paused); | |
| let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(value, vec![MediaState::Paused.into()]); | |
| server.set_track( | |
| "Track", | |
| TrackDuration::from_raw_10ms(3000), | |
| TrackPosition::from_raw_10ms(0), | |
| ); | |
| let _ = event_receiver.expect_notification(TRACK_TITLE_HANDLE); | |
| let _ = event_receiver.expect_notification(TRACK_DURATION_HANDLE); | |
| let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| let _ = event_receiver.expect_notification(TRACK_CHANGED_HANDLE); | |
| server.set_track_position(TrackPosition::from_end(std::time::Duration::from_millis(5000))); | |
| let (value, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| // Duration is 3000 (30.0s), 5.0s from end is 25.0s = 2500 raw units, | |
| // not -500. | |
| assert_eq!(value, 2500i32.to_le_bytes().to_vec()); | |
| } | |
| #[test] | |
| fn public_api_media_identity() { | |
| let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( | |
| McsServerBuilder::generic(0x42, "Old Name") | |
| .with_icon_url("https://example.com/old.png"), | |
| ); | |
| 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, | |
| MEDIA_PLAYER_NAME_HANDLE, | |
| bt_gatt::server::NotificationType::Notify, | |
| ); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| event_receiver.expect_no_event(); | |
| server.set_player_name("New Name"); | |
| let (value, _) = event_receiver.expect_notification(MEDIA_PLAYER_NAME_HANDLE); | |
| assert_eq!(value, b"New Name"); | |
| server.set_icon_url("https://example.com/new.png").expect("set_icon_url succeeds"); | |
| // Icon URL is Read-only, so verify value via GATT read response | |
| fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_ICON_URL_HANDLE, 0); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| let value = event_receiver | |
| .expect_read_response(MEDIA_PLAYER_ICON_URL_HANDLE) | |
| .expect("read response succeeds"); | |
| assert_eq!(value, b"https://example.com/new.png"); | |
| // Test server without icon URL returns FeatureNotSupported | |
| let (mut server_no_icon, _, _) = | |
| setup_test_server(McsServerBuilder::generic(0x43, "Player")); | |
| assert_matches!( | |
| server_no_icon.set_icon_url("https://example.com"), | |
| Err(Error::FeatureNotSupported("icon_url")) | |
| ); | |
| } | |
| #[test] | |
| fn public_api_playback_speed_and_seeking_speed() { | |
| 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); | |
| for handle in | |
| [MEDIA_STATE_HANDLE, TRACK_POSITION_HANDLE, PLAYBACK_SPEED_HANDLE, SEEKING_SPEED_HANDLE] | |
| { | |
| fake_gatt_server.incoming_client_configuration( | |
| peer, | |
| service_id, | |
| handle, | |
| bt_gatt::server::NotificationType::Notify, | |
| ); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| event_receiver.expect_no_event(); | |
| } | |
| server.set_playback_speed(PlaybackSpeed::DOUBLE).expect("set_playback_speed succeeds"); | |
| let (value, _) = event_receiver.expect_notification(PLAYBACK_SPEED_HANDLE); | |
| assert_eq!(value, vec![PlaybackSpeed::DOUBLE.into()]); | |
| // During active playback, setting playback speed emits both | |
| // PlaybackSpeed and TrackPosition notifications (MCS v1.0.1 | |
| // Section 3.7.1). | |
| server.set_media_state(MediaState::Playing); | |
| let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(value, vec![MediaState::Playing.into()]); | |
| server.set_playback_speed(PlaybackSpeed::HALF).expect("set_playback_speed succeeds"); | |
| let (value, _) = event_receiver.expect_notification(PLAYBACK_SPEED_HANDLE); | |
| assert_eq!(value, vec![PlaybackSpeed::HALF.into()]); | |
| let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| // Transition to Seeking emits MediaState and TrackPosition | |
| // notifications | |
| server.set_media_state(MediaState::Seeking); | |
| let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(value, vec![MediaState::Seeking.into()]); | |
| let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| // During seeking, setting seeking speed emits both SeekingSpeed and | |
| // TrackPosition | |
| server.set_seeking_speed(SeekingSpeed::new(2)).expect("set_seeking_speed succeeds"); | |
| let (value, _) = event_receiver.expect_notification(SEEKING_SPEED_HANDLE); | |
| assert_eq!(value, vec![SeekingSpeed::new(2).into()]); | |
| let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| // Pausing from Seeking emits both MediaState and TrackPosition | |
| // notifications | |
| server.set_media_state(MediaState::Paused); | |
| let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); | |
| assert_eq!(value, vec![MediaState::Paused.into()]); | |
| let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); | |
| // Test server without player speeds returns FeatureNotSupported | |
| let (mut server_no_speeds, _, _) = | |
| setup_test_server(McsServerBuilder::generic(0x43, "Player")); | |
| assert_matches!( | |
| server_no_speeds.set_playback_speed(PlaybackSpeed::DOUBLE), | |
| Err(Error::FeatureNotSupported("playback_speed")) | |
| ); | |
| assert_matches!( | |
| server_no_speeds.set_seeking_speed(SeekingSpeed::new(2)), | |
| Err(Error::FeatureNotSupported("seeking_speed")) | |
| ); | |
| } | |
| #[test] | |
| fn public_api_playing_order_and_supported_opcodes() { | |
| let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( | |
| McsServerBuilder::generic(0x42, "Test Player") | |
| .with_playing_orders( | |
| SupportedPlayingOrders::IN_ORDER_ONCE | SupportedPlayingOrders::SHUFFLE_ONCE, | |
| ) | |
| .with_supported_operations(SupportedOpcodes::PLAY), | |
| ); | |
| let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); | |
| let peer = PeerId(1); | |
| let service_id = ServiceId::new(0x42); | |
| for handle in [PLAYING_ORDER_HANDLE, MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE] { | |
| fake_gatt_server.incoming_client_configuration( | |
| peer, | |
| service_id, | |
| handle, | |
| bt_gatt::server::NotificationType::Notify, | |
| ); | |
| let _ = server.next().poll_unpin(&mut noop_cx); | |
| event_receiver.expect_no_event(); | |
| } | |
| // Supported order succeeds and notifies | |
| server.set_playing_order(PlayingOrder::ShuffleOnce).expect("set_playing_order succeeds"); | |
| let (value, _) = event_receiver.expect_notification(PLAYING_ORDER_HANDLE); | |
| assert_eq!(value, vec![PlayingOrder::ShuffleOnce.into()]); | |
| // Unsupported order returns Error and does not notify | |
| assert_matches!( | |
| server.set_playing_order(PlayingOrder::SingleRepeat), | |
| Err(Error::UnsupportedPlayingOrder(PlayingOrder::SingleRepeat)) | |
| ); | |
| event_receiver.expect_no_event(); | |
| // Update supported opcodes | |
| let new_opcodes = SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE; | |
| server.set_supported_opcodes(new_opcodes).expect("set_supported_opcodes succeeds"); | |
| let (value, _) = | |
| event_receiver.expect_notification(MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE); | |
| assert_eq!(value, new_opcodes.bits().to_le_bytes().to_vec()); | |
| // Test server without playing orders or control point returns | |
| // FeatureNotSupported | |
| let (mut server_no_optional, _, _) = | |
| setup_test_server(McsServerBuilder::generic(0x43, "Player")); | |
| assert_matches!( | |
| server_no_optional.set_playing_order(PlayingOrder::InOrderOnce), | |
| Err(Error::FeatureNotSupported("playing_orders")) | |
| ); | |
| assert_matches!( | |
| server_no_optional.set_supported_opcodes(SupportedOpcodes::PLAY), | |
| Err(Error::FeatureNotSupported("media_control_point")) | |
| ); | |
| } | |
| #[test] | |
| fn public_api_unpublished_server_updates_state() { | |
| let mut server = McsServerBuilder::generic(0x42, "Test Player") | |
| .with_icon_url("https://example.com/old.png") | |
| .with_player_speeds() | |
| .with_playing_orders(SupportedPlayingOrders::all()) | |
| .with_supported_operations(SupportedOpcodes::all()) | |
| .build::<FakeTypes>() | |
| .unwrap(); | |
| server.set_media_state(MediaState::Playing); | |
| server.set_track( | |
| "Title", | |
| TrackDuration::from_raw_10ms(100), | |
| TrackPosition::from_raw_10ms(0), | |
| ); | |
| server.clear_track(); | |
| server.set_track_position(TrackPosition::from_raw_10ms(0)); | |
| server.set_player_name("Name"); | |
| assert_matches!(server.set_icon_url("https://example.com"), Ok(())); | |
| assert_matches!(server.set_playback_speed(PlaybackSpeed::NORMAL), Ok(())); | |
| assert_matches!(server.set_seeking_speed(SeekingSpeed::NOT_SEEKING), Ok(())); | |
| assert_matches!(server.set_playing_order(PlayingOrder::InOrderOnce), Ok(())); | |
| assert_matches!(server.set_supported_opcodes(SupportedOpcodes::PLAY), Ok(())); | |
| } | |
| } |