| // 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::Uuid; |
| use bt_gatt::server::{LocalService, Server as _, ServiceDefinition, ServiceEvent, ServiceId}; |
| use bt_gatt::types::{ |
| AttributePermissions, CharacteristicProperties, CharacteristicProperty, Handle, SecurityLevels, |
| ServiceKind, |
| }; |
| use bt_gatt::Characteristic; |
| use futures::stream::Stream; |
| use pin_project::pin_project; |
| use std::future::Future; |
| use std::task::{Context, Poll, Waker}; |
| |
| use crate::types::*; |
| use crate::Error; |
| |
| // ============================================================================ |
| // Mandatory Characteristic Handle Definitions (MCS v1.0.1 Section 3) |
| // ============================================================================ |
| |
| /// Handle assigned to the Media Player Name characteristic. |
| const MEDIA_PLAYER_NAME_HANDLE: Handle = Handle(1); |
| /// Handle assigned to the Track Changed characteristic. |
| const TRACK_CHANGED_HANDLE: Handle = Handle(2); |
| /// Handle assigned to the Track Title characteristic. |
| const TRACK_TITLE_HANDLE: Handle = Handle(3); |
| /// Handle assigned to the Track Duration characteristic. |
| const TRACK_DURATION_HANDLE: Handle = Handle(4); |
| /// Handle assigned to the Track Position characteristic. |
| const TRACK_POSITION_HANDLE: Handle = Handle(5); |
| /// Handle assigned to the Playback Speed characteristic. |
| const PLAYBACK_SPEED_HANDLE: Handle = Handle(6); |
| /// Handle assigned to the Seeking Speed characteristic. |
| const SEEKING_SPEED_HANDLE: Handle = Handle(7); |
| /// Handle assigned to the Playing Order characteristic. |
| const PLAYING_ORDER_HANDLE: Handle = Handle(8); |
| /// Handle assigned to the Playing Orders Supported characteristic. |
| const PLAYING_ORDERS_SUPPORTED_HANDLE: Handle = Handle(9); |
| /// Handle assigned to the Media State characteristic. |
| const MEDIA_STATE_HANDLE: Handle = Handle(10); |
| /// Handle assigned to the Media Control Point characteristic. |
| const MEDIA_CONTROL_POINT_HANDLE: Handle = Handle(11); |
| /// Handle assigned to the Media Control Point Opcodes Supported characteristic. |
| const MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE: Handle = Handle(12); |
| /// Handle assigned to the Content Control ID (CCID) characteristic. |
| const CONTENT_CONTROL_ID_HANDLE: Handle = Handle(13); |
| |
| /// All 13 mandatory characteristics defined in MCS v1.0.1 Section 3. |
| fn mandatory_characteristics() -> [Characteristic; 13] { |
| [ |
| build_mandatory_characteristic( |
| MEDIA_PLAYER_NAME_HANDLE, |
| MEDIA_PLAYER_NAME_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| TRACK_CHANGED_HANDLE, |
| TRACK_CHANGED_UUID, |
| CharacteristicProperty::Notify, |
| ), |
| build_mandatory_characteristic( |
| TRACK_TITLE_HANDLE, |
| TRACK_TITLE_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| TRACK_DURATION_HANDLE, |
| TRACK_DURATION_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| TRACK_POSITION_HANDLE, |
| TRACK_POSITION_UUID, |
| CharacteristicProperties::READ_WRITE_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| PLAYBACK_SPEED_HANDLE, |
| PLAYBACK_SPEED_UUID, |
| CharacteristicProperties::READ_WRITE_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| SEEKING_SPEED_HANDLE, |
| SEEKING_SPEED_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| PLAYING_ORDER_HANDLE, |
| PLAYING_ORDER_UUID, |
| CharacteristicProperties::READ_WRITE_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| PLAYING_ORDERS_SUPPORTED_HANDLE, |
| PLAYING_ORDERS_SUPPORTED_UUID, |
| CharacteristicProperty::Read, |
| ), |
| build_mandatory_characteristic( |
| MEDIA_STATE_HANDLE, |
| MEDIA_STATE_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| MEDIA_CONTROL_POINT_HANDLE, |
| MEDIA_CONTROL_POINT_UUID, |
| CharacteristicProperties::WRITE_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, |
| MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_mandatory_characteristic( |
| CONTENT_CONTROL_ID_HANDLE, |
| CONTENT_CONTROL_ID_UUID, |
| CharacteristicProperty::Read, |
| ), |
| ] |
| } |
| |
| /// Specification of a mandatory GATT characteristic for MCS. |
| /// Constructs a characteristic definition with the specified handle, UUID, |
| /// properties, and encryption-required permissions conforming to MCS v1.0.1 |
| /// Section 3. |
| fn build_mandatory_characteristic( |
| handle: Handle, |
| uuid: Uuid, |
| properties: impl Into<CharacteristicProperties>, |
| ) -> Characteristic { |
| let properties = properties.into(); |
| Characteristic { |
| handle, |
| uuid, |
| properties, |
| permissions: AttributePermissions::with_levels( |
| &properties, |
| &SecurityLevels::encryption_required(), |
| ), |
| descriptors: Vec::new(), |
| } |
| } |
| |
| /// Internal state for the MCS server. |
| #[pin_project(project = LocalServiceProj)] |
| enum LocalServiceState<T: bt_gatt::ServerTypes> { |
| /// 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 { .. }) |
| } |
| } |
| |
| 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)))); |
| } |
| None => { |
| self.as_mut().set(LocalServiceState::Terminated); |
| return Poll::Ready(None); |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| /// Builder for configuring an MCS or GMCS GATT service. |
| #[derive(Debug, Clone, PartialEq)] |
| pub struct McsServerBuilder { |
| /// Service UUID assigned to the service. |
| service_uuid: Uuid, |
| /// Content Control ID (CCID) identifying this media service instance. This |
| /// is unique across all MCS/GMCS instances on the host server. |
| ccid: u8, |
| /// Human-readable media player application name. |
| player_name: String, |
| } |
| |
| 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, ccid, player_name: player_name.into() } |
| } |
| |
| /// Constructs the complete GATT [`ServiceDefinition`] containing all 13 |
| /// Mandatory Characteristics defined in MCS v1.0.1 Section 3. |
| 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.ccid.into()), |
| self.service_uuid, |
| ServiceKind::Primary, |
| ); |
| |
| for chrc in mandatory_characteristics() { |
| service_def.add_characteristic(chrc)?; |
| } |
| |
| 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 { |
| service_def, |
| local_service: Default::default(), |
| ccid: self.ccid, |
| player_name: self.player_name, |
| }) |
| } |
| } |
| |
| /// An instance of a Media Control Service (MCS) or Generic Media Control |
| /// Service (GMCS) GATT server. |
| #[pin_project] |
| pub struct McsServer<T: bt_gatt::ServerTypes> { |
| service_def: ServiceDefinition, |
| #[pin] |
| local_service: LocalServiceState<T>, |
| ccid: u8, |
| player_name: String, |
| } |
| |
| impl<T: bt_gatt::ServerTypes> McsServer<T> { |
| /// 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(()) |
| } |
| } |
| |
| impl<T: bt_gatt::ServerTypes> Stream for McsServer<T> { |
| type Item = Result<(), 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(); |
| let gatt_event = match futures::ready!(this.local_service.as_mut().poll_next(cx)) { |
| None => return Poll::Ready(None), |
| Some(Err(e)) => return Poll::Ready(Some(Err(e))), |
| Some(Ok(event)) => event, |
| }; |
| match gatt_event { |
| // TODO(b/540400364): Add support for characteristic reads and writes |
| _ => continue, |
| } |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| use bt_gatt::test_utils::{FakeServer, FakeTypes}; |
| 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("generic 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(), 13); |
| } |
| |
| #[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(), 13); |
| } |
| |
| #[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::instance(0x05, "Player 2"); |
| let builder3 = McsServerBuilder::instance(0x06, "Player 3"); |
| |
| let def1 = builder1.build_service_definition().expect("valid definition"); |
| let def2 = builder2.build_service_definition().expect("valid definition"); |
| let def3 = builder3.build_service_definition().expect("valid definition"); |
| |
| 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))); |
| } |
| } |