blob: be7e567476f3dbbdcff938e2d2d30076f4a0e7dd [file]
// 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, ReadResponder, Server as _, ServiceDefinition, ServiceEvent, ServiceId,
};
use bt_gatt::types::{
AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, 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 Media State characteristic.
const MEDIA_STATE_HANDLE: Handle = Handle(6);
/// Handle assigned to the Content Control ID (CCID) characteristic.
const CONTENT_CONTROL_ID_HANDLE: Handle = Handle(7);
// ============================================================================
// Optional Characteristic Handle Definitions (MCS v1.0.1 Section 3)
// ============================================================================
/// Handle assigned to the Media Player Icon URL characteristic.
const MEDIA_PLAYER_ICON_URL_HANDLE: Handle = Handle(8);
/// Handle assigned to the Playback Speed characteristic.
const PLAYBACK_SPEED_HANDLE: Handle = Handle(9);
/// Handle assigned to the Seeking Speed characteristic.
const SEEKING_SPEED_HANDLE: Handle = Handle(10);
/// Handle assigned to the Playing Order characteristic.
const PLAYING_ORDER_HANDLE: Handle = Handle(11);
/// Handle assigned to the Playing Orders Supported characteristic.
const PLAYING_ORDERS_SUPPORTED_HANDLE: Handle = Handle(12);
/// Handle assigned to the Media Control Point characteristic.
const MEDIA_CONTROL_POINT_HANDLE: Handle = Handle(13);
/// Handle assigned to the Media Control Point Opcodes Supported characteristic.
const MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE: Handle = Handle(14);
/// The mandatory characteristics defined in MCS v1.0.1 Section 3, Table 3.1.
fn mandatory_characteristics() -> [Characteristic; 7] {
[
build_characteristic(
MEDIA_PLAYER_NAME_HANDLE,
MEDIA_PLAYER_NAME_UUID,
CharacteristicProperties::READ_NOTIFY,
),
build_characteristic(
TRACK_CHANGED_HANDLE,
TRACK_CHANGED_UUID,
CharacteristicProperty::Notify,
),
build_characteristic(
TRACK_TITLE_HANDLE,
TRACK_TITLE_UUID,
CharacteristicProperties::READ_NOTIFY,
),
build_characteristic(
TRACK_DURATION_HANDLE,
TRACK_DURATION_UUID,
CharacteristicProperties::READ_NOTIFY,
),
build_characteristic(
TRACK_POSITION_HANDLE,
TRACK_POSITION_UUID,
CharacteristicProperties::READ_WRITE_NOTIFY,
),
build_characteristic(
MEDIA_STATE_HANDLE,
MEDIA_STATE_UUID,
CharacteristicProperties::READ_NOTIFY,
),
build_characteristic(
CONTENT_CONTROL_ID_HANDLE,
CONTENT_CONTROL_ID_UUID,
CharacteristicProperty::Read,
),
]
}
/// Constructs a characteristic definition with the specified handle, UUID,
/// properties, and encryption-required permissions conforming to MCS v1.0.1
/// Section 3.
fn build_characteristic(
handle: Handle,
uuid: Uuid,
properties: impl Into<CharacteristicProperties>,
) -> Characteristic {
let properties = properties.into();
Characteristic {
handle,
uuid,
properties,
permissions: AttributePermissions::with_levels(
&properties,
&SecurityLevels::encryption_required(),
),
descriptors: Vec::new(),
}
}
/// Internal state for the MCS server.
#[pin_project(project = LocalServiceProj)]
enum LocalServiceState<T: bt_gatt::ServerTypes> {
/// 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);
}
}
}
}
}
}
}
/// Local state of the characteristics in this server.
#[derive(Debug, Clone, PartialEq, Eq)]
struct McsLocalState {
/// Content Control ID (CCID) identifying this media service instance.
ccid: u8,
/// Human-readable media player application name.
player_name: String,
/// Title of the currently selected track (empty if no track loaded).
track_title: String,
/// Total duration of the current track (MCS v1.0.1 Section 3.6).
track_duration: TrackDuration,
/// Base playback position of the current track (MCS v1.0.1 Section 3.7).
track_position: TrackPosition,
/// Timestamp when `track_position` was set or updated.
position_updated_at: Option<std::time::Instant>,
/// Current player activity state.
media_state: MediaState,
/// URL pointing to media player icon graphic, if supported.
icon_url: Option<String>,
/// Playback speed multiplier (0 = 1.0x normal speed), if supported.
playback_speed: Option<i8>,
/// Seeking speed factor (0 = not seeking), if supported.
seeking_speed: Option<i8>,
/// Playing order and supported playing orders, if supported.
playing_orders: Option<PlayingOrderState>,
/// Supported media control point opcodes, if Media Control Point is
/// supported.
supported_opcodes: Option<SupportedOpcodes>,
}
impl McsLocalState {
/// Creates a new [`McsLocalState`] with default values for an inactive
/// player with no track loaded per MCS v1.0.1 Section 3.
fn new(ccid: u8, player_name: impl Into<String>) -> Self {
Self {
ccid,
player_name: player_name.into(),
track_title: String::new(),
track_duration: TrackDuration::Unknown,
track_position: TrackPosition::Unavailable,
position_updated_at: None,
media_state: MediaState::Inactive,
icon_url: None,
playback_speed: None,
seeking_speed: None,
playing_orders: None,
supported_opcodes: None,
}
}
/// Calculates the instantaneous track position based on elapsed playback
/// time (MCS v1.0.1 Section 3.7).
fn current_track_position(&self) -> TrackPosition {
// Per MCS Section 3.17, an inactive player has no current track, so it is
// unavailable.
if self.media_state == MediaState::Inactive {
return TrackPosition::Unavailable;
}
// Playback timing has not started since the track was just initialized.
let Some(updated_at) = self.position_updated_at else {
return self.track_position;
};
// Playback is paused/seeking, so the position remains fixed.
if self.media_state != MediaState::Playing {
return self.track_position;
}
let base = match (self.track_position, self.track_duration) {
(TrackPosition::FromStart(base), _) => base,
(TrackPosition::FromEnd(end), TrackDuration::Duration(total)) => {
total.saturating_sub(end)
}
_ => return self.track_position,
};
// TODO(b/540400364): Factor in optional playback speed when supported
let mut current = base + updated_at.elapsed();
if let TrackDuration::Duration(total) = self.track_duration {
current = current.min(total);
}
TrackPosition::FromStart(current)
}
/// Reads the characteristic bytes for `handle` at `offset`.
fn handle_read(&self, handle: Handle, offset: usize) -> Result<Vec<u8>, GattError> {
let read_at_offset =
|bytes: &[u8]| bytes.get(offset..).map(Vec::from).ok_or(GattError::InvalidOffset);
match handle {
// Track Changed and Media Control Point are not readable per MCS v1.0.1 Section 3,
// Table 3.1.
TRACK_CHANGED_HANDLE | MEDIA_CONTROL_POINT_HANDLE => Err(GattError::ReadNotPermitted),
MEDIA_PLAYER_NAME_HANDLE => read_at_offset(self.player_name.as_bytes()),
TRACK_TITLE_HANDLE => read_at_offset(self.track_title.as_bytes()),
TRACK_DURATION_HANDLE => read_at_offset(&self.track_duration.raw_10ms().to_le_bytes()),
TRACK_POSITION_HANDLE => {
read_at_offset(&self.current_track_position().raw_10ms().to_le_bytes())
}
MEDIA_STATE_HANDLE => read_at_offset(&[self.media_state.into()]),
CONTENT_CONTROL_ID_HANDLE => read_at_offset(&[self.ccid]),
MEDIA_PLAYER_ICON_URL_HANDLE => {
let url = self.icon_url.as_ref().ok_or(GattError::InvalidHandle)?;
read_at_offset(url.as_bytes())
}
PLAYBACK_SPEED_HANDLE => {
let speed = self.playback_speed.ok_or(GattError::InvalidHandle)?;
read_at_offset(&[speed as u8])
}
SEEKING_SPEED_HANDLE => {
let speed = self.seeking_speed.ok_or(GattError::InvalidHandle)?;
read_at_offset(&[speed as u8])
}
PLAYING_ORDER_HANDLE => {
let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?;
read_at_offset(&[orders.current.into()])
}
PLAYING_ORDERS_SUPPORTED_HANDLE => {
let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?;
read_at_offset(&orders.supported.bits().to_le_bytes())
}
MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE => {
let opcodes = self.supported_opcodes.ok_or(GattError::InvalidHandle)?;
read_at_offset(&opcodes.bits().to_le_bytes())
}
_ => Err(GattError::InvalidHandle),
}
}
}
/// Builder for configuring an MCS or GMCS GATT service.
// TODO(b/549911651): Add support for OTS (Object Transfer Service) integration.
#[derive(Debug, Clone, PartialEq)]
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(0);
self.state.seeking_speed = Some(0);
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 { service_def, local_service: Default::default(), state: self.state })
}
}
/// 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>,
/// Local state of the characteristics in this server.
state: McsLocalState,
}
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(())
}
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),
}
}
}
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 gatt_event =
match futures::ready!(self.as_mut().project().local_service.poll_next(cx)) {
None => return Poll::Ready(None),
Some(Err(e)) => return Poll::Ready(Some(Err(e))),
Some(Ok(event)) => event,
};
match gatt_event {
ServiceEvent::Read { peer_id: _, handle, offset, responder } => {
self.handle_read(handle, offset as usize, responder);
}
// TODO(b/540400364): Add support for characteristic writes
_ => continue,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bt_common::PeerId;
use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeTypes};
use bt_gatt::types::GattError;
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(), 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::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)));
}
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.try_recv().unwrap(), FakeServerEvent::Published { .. }));
(server, fake_gatt_server, event_receiver)
}
fn assert_read_characteristic(
server: &mut McsServer<FakeTypes>,
fake_gatt_server: &FakeServer,
event_receiver: &mut futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>,
handle: Handle,
expected: &[u8],
) {
let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
let peer = PeerId(1);
let service_id = server.service_def.id();
fake_gatt_server.incoming_read(peer, service_id, handle, 0);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert_eq!(value.unwrap(), expected);
}
#[test]
fn read_mandatory_characteristics_default_values() {
let (mut server, fake_gatt_server, mut event_receiver) =
setup_test_server(McsServerBuilder::generic(0x42, "Test Player"));
// Media Player Name
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
MEDIA_PLAYER_NAME_HANDLE,
b"Test Player",
);
// Track Title
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
TRACK_TITLE_HANDLE,
b"",
);
// Track Duration
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
TRACK_DURATION_HANDLE,
&(-1i32).to_le_bytes(),
);
// Track Position
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
TRACK_POSITION_HANDLE,
&(-1i32).to_le_bytes(),
);
// Media State
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
MEDIA_STATE_HANDLE,
&[MediaState::Inactive.into()],
);
// Content Control ID
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
CONTENT_CONTROL_ID_HANDLE,
&[0x42],
);
}
#[test]
fn read_optional_characteristics_configured_values() {
let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server(
McsServerBuilder::generic(0x42, "Test Player")
.with_icon_url("https://example.com/icon.png")
.with_player_speeds()
.with_playing_orders(
SupportedPlayingOrders::IN_ORDER_ONCE | SupportedPlayingOrders::SHUFFLE_ONCE,
)
.with_supported_operations(SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE),
);
// Media Player Icon URL
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
MEDIA_PLAYER_ICON_URL_HANDLE,
b"https://example.com/icon.png",
);
// Playback Speed
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
PLAYBACK_SPEED_HANDLE,
&[0],
);
// Seeking Speed
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
SEEKING_SPEED_HANDLE,
&[0],
);
// Playing Order
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
PLAYING_ORDER_HANDLE,
&[PlayingOrder::InOrderOnce.into()],
);
// Playing Orders Supported
let expected_orders =
SupportedPlayingOrders::IN_ORDER_ONCE | SupportedPlayingOrders::SHUFFLE_ONCE;
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
PLAYING_ORDERS_SUPPORTED_HANDLE,
&expected_orders.bits().to_le_bytes(),
);
// Media Control Point Opcodes Supported
let expected_opcodes = SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE;
assert_read_characteristic(
&mut server,
&fake_gatt_server,
&mut event_receiver,
MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE,
&expected_opcodes.bits().to_le_bytes(),
);
}
#[test]
fn read_unconfigured_optional_characteristics_returns_invalid_handle() {
let (mut server, fake_gatt_server, mut event_receiver) =
setup_test_server(McsServerBuilder::generic(0x42, "Mandatory Only Player"));
let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
let peer = PeerId(1);
let service_id = server.service_def.id();
let unconfigured_handles = [
MEDIA_PLAYER_ICON_URL_HANDLE,
PLAYBACK_SPEED_HANDLE,
SEEKING_SPEED_HANDLE,
PLAYING_ORDER_HANDLE,
PLAYING_ORDERS_SUPPORTED_HANDLE,
MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE,
];
for handle in unconfigured_handles {
fake_gatt_server.incoming_read(peer, service_id, handle, 0);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded for handle {:?}", handle);
};
assert!(
matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidHandle)),
"handle {:?} should return InvalidHandle when unconfigured",
handle
);
}
}
#[test]
fn read_string_characteristics_with_offset() {
let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server(
McsServerBuilder::generic(0x42, "Long Player Name")
.with_icon_url("https://example.com/icon.png"),
);
let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
let peer = PeerId(1);
let service_id = server.service_def.id();
// Valid offset slice for Player Name
fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_NAME_HANDLE, 5);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert_eq!(value.unwrap(), b"Player Name");
// Valid offset slice for Icon URL
fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_ICON_URL_HANDLE, 8);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert_eq!(value.unwrap(), b"example.com/icon.png");
// Exact end offset returns empty slice
fake_gatt_server.incoming_read(
peer,
service_id,
MEDIA_PLAYER_NAME_HANDLE,
"Long Player Name".len() as u32,
);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert_eq!(value.unwrap(), b"");
// Out-of-bounds offset returns InvalidOffset
fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_NAME_HANDLE, 100);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert!(matches!(
value.unwrap_err(),
bt_gatt::types::Error::Gatt(GattError::InvalidOffset)
));
// Out-of-bounds offset on Icon URL returns InvalidOffset
fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_ICON_URL_HANDLE, 100);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert!(matches!(
value.unwrap_err(),
bt_gatt::types::Error::Gatt(GattError::InvalidOffset)
));
}
#[test]
fn read_non_readable_characteristics_returns_error() {
let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server(
McsServerBuilder::generic(0x42, "Test Player")
.with_supported_operations(SupportedOpcodes::default()),
);
let peer = PeerId(1);
let service_id = server.service_def.id();
// Track Changed is notify-only
fake_gatt_server.incoming_read(peer, service_id, TRACK_CHANGED_HANDLE, 0);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert!(matches!(
value.unwrap_err(),
bt_gatt::types::Error::Gatt(GattError::ReadNotPermitted)
));
// Media Control Point is write/notify-only
fake_gatt_server.incoming_read(peer, service_id, MEDIA_CONTROL_POINT_HANDLE, 0);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert!(matches!(
value.unwrap_err(),
bt_gatt::types::Error::Gatt(GattError::ReadNotPermitted)
));
// Unknown handle returns InvalidHandle
fake_gatt_server.incoming_read(peer, service_id, Handle(999), 0);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
assert!(matches!(
value.unwrap_err(),
bt_gatt::types::Error::Gatt(GattError::InvalidHandle)
));
}
#[test]
fn local_state_track_position_calculation() {
let mut state = McsLocalState::new(0x01, "Test Player");
// When no track is loaded, position is Unavailable regardless of state.
assert_eq!(state.current_track_position(), TrackPosition::Unavailable);
state.media_state = MediaState::Playing;
assert_eq!(state.current_track_position(), TrackPosition::Unavailable);
// When paused or seeking with a loaded track, position does not advance with
// time.
state.media_state = MediaState::Paused;
state.track_position = TrackPosition::from_start(std::time::Duration::from_millis(5000));
state.position_updated_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(10));
assert_eq!(
state.current_track_position(),
TrackPosition::from_start(std::time::Duration::from_millis(5000))
);
state.media_state = MediaState::Seeking;
assert_eq!(
state.current_track_position(),
TrackPosition::from_start(std::time::Duration::from_millis(5000))
);
// When playing with a loaded track (FromStart), position advances based on
// elapsed time.
state.media_state = MediaState::Playing;
state.track_duration = TrackDuration::from_duration(std::time::Duration::from_secs(20));
state.track_position = TrackPosition::from_start(std::time::Duration::from_millis(5000));
state.position_updated_at =
Some(std::time::Instant::now() - std::time::Duration::from_millis(500));
let computed = state.current_track_position();
let TrackPosition::FromStart(dur) = computed else {
panic!("expected FromStart");
};
assert!(
dur >= std::time::Duration::from_millis(5450)
&& dur <= std::time::Duration::from_millis(5650),
"unexpected computed position: {dur:?}"
);
// When elapsed time exceeds track duration, position is clamped to duration.
state.position_updated_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(100));
assert_eq!(
state.current_track_position(),
TrackPosition::from_start(std::time::Duration::from_secs(20))
);
// When playing with a track position relative to end (FromEnd), base is
// duration - offset.
state.track_duration = TrackDuration::from_duration(std::time::Duration::from_secs(30));
state.track_position = TrackPosition::from_end(std::time::Duration::from_secs(10));
state.position_updated_at =
Some(std::time::Instant::now() - std::time::Duration::from_millis(500));
// Base is 30s - 10s = 20s. Elapsed 500ms -> ~20.5s from start.
let computed = state.current_track_position();
let TrackPosition::FromStart(dur) = computed else {
panic!("expected FromStart");
};
assert!(
dur >= std::time::Duration::from_millis(20450)
&& dur <= std::time::Duration::from_millis(20650),
"unexpected computed position: {dur:?}"
);
// When FromEnd elapsed time exceeds track duration, position is clamped to
// duration.
state.position_updated_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(100));
assert_eq!(
state.current_track_position(),
TrackPosition::from_start(std::time::Duration::from_secs(30))
);
// When FromEnd is used with unknown duration, it cannot resolve to FromStart
// and returns FromEnd.
state.track_duration = TrackDuration::Unknown;
state.track_position = TrackPosition::from_end(std::time::Duration::from_secs(10));
assert_eq!(
state.current_track_position(),
TrackPosition::from_end(std::time::Duration::from_secs(10))
);
}
#[test]
fn read_track_position_during_active_playback() {
let (mut server, fake_gatt_server, mut event_receiver) =
setup_test_server(McsServerBuilder::generic(0x42, "Test Player"));
// Set server state to Playing with a loaded track at position 500 (5.0
// seconds).
server.state.media_state = MediaState::Playing;
server.state.track_duration =
TrackDuration::from_duration(std::time::Duration::from_secs(60));
server.state.track_position =
TrackPosition::from_start(std::time::Duration::from_millis(5000));
server.state.position_updated_at =
Some(std::time::Instant::now() - std::time::Duration::from_millis(1000));
let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
let peer = PeerId(1);
let service_id = server.service_def.id();
fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0);
let _ = server.next().poll_unpin(&mut noop_cx);
let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
event_receiver.try_recv().unwrap()
else {
panic!("expected ReadResponded");
};
let raw_bytes = value.unwrap();
assert_eq!(raw_bytes.len(), 4);
let raw_10ms = i32::from_le_bytes(raw_bytes.try_into().unwrap());
// 5000ms + 1000ms elapsed = 6000ms = 600 units of 10ms (allow minor jitter +/-
// 30 units)
assert!(raw_10ms >= 580 && raw_10ms <= 640, "unexpected raw_10ms: {raw_10ms}");
}
}