blob: aafa44599bea4ba6101f1e08379c3c8acd1e4676 [file] [edit]
// 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.
use bt_common::Uuid;
use bt_gatt::types::{
AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, Handle,
SecurityLevels,
};
use bt_gatt::Characteristic;
use crate::types::*;
// ============================================================================
// Characteristic Definitions & Handles (MCS v1.0.1 Section 3)
// ============================================================================
/// Handle assigned to the Media Player Name characteristic.
pub(crate) const MEDIA_PLAYER_NAME_HANDLE: Handle = Handle(1);
/// Handle assigned to the Track Changed characteristic.
pub(crate) const TRACK_CHANGED_HANDLE: Handle = Handle(2);
/// Handle assigned to the Track Title characteristic.
pub(crate) const TRACK_TITLE_HANDLE: Handle = Handle(3);
/// Handle assigned to the Track Duration characteristic.
pub(crate) const TRACK_DURATION_HANDLE: Handle = Handle(4);
/// Handle assigned to the Track Position characteristic.
pub(crate) const TRACK_POSITION_HANDLE: Handle = Handle(5);
/// Handle assigned to the Media State characteristic.
pub(crate) const MEDIA_STATE_HANDLE: Handle = Handle(6);
/// Handle assigned to the Content Control ID (CCID) characteristic.
pub(crate) 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.
pub(crate) const MEDIA_PLAYER_ICON_URL_HANDLE: Handle = Handle(8);
/// Handle assigned to the Playback Speed characteristic.
pub(crate) const PLAYBACK_SPEED_HANDLE: Handle = Handle(9);
/// Handle assigned to the Seeking Speed characteristic.
pub(crate) const SEEKING_SPEED_HANDLE: Handle = Handle(10);
/// Handle assigned to the Playing Order characteristic.
pub(crate) const PLAYING_ORDER_HANDLE: Handle = Handle(11);
/// Handle assigned to the Playing Orders Supported characteristic.
pub(crate) const PLAYING_ORDERS_SUPPORTED_HANDLE: Handle = Handle(12);
/// Handle assigned to the Media Control Point characteristic.
pub(crate) const MEDIA_CONTROL_POINT_HANDLE: Handle = Handle(13);
/// Handle assigned to the Media Control Point Opcodes Supported characteristic.
pub(crate) const MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE: Handle = Handle(14);
/// Constructs a characteristic definition with the specified handle, UUID,
/// properties, and encryption-required permissions conforming to MCS v1.0.1
/// Section 3.
pub(crate) 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(),
}
}
/// The mandatory characteristics defined in MCS v1.0.1 Section 3, Table 3.1.
pub(crate) 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,
),
]
}
/// Local state of the characteristics in this server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct McsLocalState {
/// Content Control ID (CCID) identifying this media service instance.
pub(crate) ccid: u8,
/// Human-readable media player application name.
pub(crate) player_name: String,
/// Title of the currently selected track (empty if no track loaded).
pub(crate) track_title: String,
/// Total duration of the current track (MCS v1.0.1 Section 3.6).
pub(crate) track_duration: TrackDuration,
/// Base playback position of the current track (MCS v1.0.1 Section 3.7).
pub(crate) track_position: TrackPosition,
/// Timestamp when `track_position` was set or updated.
pub(crate) position_updated_at: Option<std::time::Instant>,
/// Current player activity state.
pub(crate) media_state: MediaState,
/// URL pointing to media player icon graphic, if supported.
pub(crate) icon_url: Option<String>,
/// Playback speed multiplier, if supported.
pub(crate) playback_speed: Option<PlaybackSpeed>,
/// Seeking speed factor, if supported.
pub(crate) seeking_speed: Option<SeekingSpeed>,
/// Playing order and supported playing orders, if supported.
pub(crate) playing_orders: Option<PlayingOrderState>,
/// Supported media control point opcodes, if Media Control Point is
/// supported.
pub(crate) 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.
pub(crate) 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).
pub(crate) 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 initialized or loaded.
let Some(updated_at) = self.position_updated_at else {
return self.track_position;
};
// Playback is paused or seeking, so the position remains fixed at the current
// offset.
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)
}
/// Sets the track position and records the update timestamp.
pub(crate) fn set_track_position(&mut self, position: TrackPosition) {
self.track_position = position;
self.position_updated_at = Some(std::time::Instant::now());
}
/// Sets the playback speed.
pub(crate) fn set_playback_speed(&mut self, speed: PlaybackSpeed) {
self.playback_speed = Some(speed);
}
/// Sets the playing order if playing order is supported.
pub(crate) fn set_playing_order(&mut self, order: PlayingOrder) {
if let Some(ref mut orders) = self.playing_orders {
if orders.supported.contains(order.into()) {
orders.current = order;
}
}
}
#[cfg(test)]
pub(crate) fn set_media_state(&mut self, state: MediaState) {
self.media_state = state;
}
#[cfg(test)]
pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) {
self.supported_opcodes = Some(opcodes);
}
/// Reads the characteristic bytes for `handle` at `offset`.
pub(crate) 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.into()])
}
SEEKING_SPEED_HANDLE => {
let speed = self.seeking_speed.ok_or(GattError::InvalidHandle)?;
read_at_offset(&[speed.into()])
}
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),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
use std::time::Duration;
#[test]
fn local_state_set_playing_order() {
let mut state = McsLocalState::new(0x01, "Player");
state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SINGLE_ONCE));
assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::InOrderOnce);
state.set_playing_order(PlayingOrder::SingleOnce);
assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce);
// Unsupported playing order is not applied
state.set_playing_order(PlayingOrder::ShuffleOnce);
assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce);
}
#[test]
fn local_state_handle_read() {
let mut state = McsLocalState::new(0x01, "Player");
state.media_state = MediaState::Paused;
state.playback_speed = Some(PlaybackSpeed::NORMAL);
state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::all()));
// Track Position
assert_eq!(
state.handle_read(TRACK_POSITION_HANDLE, 0),
Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec())
);
// Playback Speed
assert_eq!(state.handle_read(PLAYBACK_SPEED_HANDLE, 0), Ok(vec![0x00]));
// Playing Order
assert_eq!(
state.handle_read(PLAYING_ORDER_HANDLE, 0),
Ok(vec![PlayingOrder::InOrderOnce.into()])
);
}
#[test]
fn local_state_track_position_calculation() {
let mut state = McsLocalState::new(0x01, "Player");
state.track_duration = TrackDuration::from_raw_10ms(1000);
// Inactive player always has unavailable track position
state.media_state = MediaState::Inactive;
state.track_position = TrackPosition::from_raw_10ms(500);
assert_eq!(state.current_track_position(), TrackPosition::Unavailable);
// Paused player returns base track position
state.media_state = MediaState::Paused;
assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(500));
// Playing player calculates elapsed time
state.media_state = MediaState::Playing;
state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(2));
match state.current_track_position() {
TrackPosition::FromStart(dur) => {
assert!(dur >= Duration::from_millis(6900) && dur <= Duration::from_millis(7200));
}
other => panic!("Expected FromStart duration, got {other:?}"),
}
// Clamped to total duration
state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(20));
assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(1000));
}
#[test]
fn read_mandatory_characteristics_default_values() {
let state = McsLocalState::new(0x05, "Test Player");
assert_eq!(state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 0), Ok(b"Test Player".to_vec()));
assert_eq!(state.handle_read(TRACK_TITLE_HANDLE, 0), Ok(b"".to_vec()));
assert_eq!(
state.handle_read(TRACK_DURATION_HANDLE, 0),
Ok(TrackDuration::Unknown.raw_10ms().to_le_bytes().to_vec())
);
assert_eq!(
state.handle_read(TRACK_POSITION_HANDLE, 0),
Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec())
);
assert_eq!(state.handle_read(MEDIA_STATE_HANDLE, 0), Ok(vec![MediaState::Inactive.into()]));
assert_eq!(state.handle_read(CONTENT_CONTROL_ID_HANDLE, 0), Ok(vec![0x05]));
}
#[test]
fn read_optional_characteristics_configured_values() {
let mut state = McsLocalState::new(0x01, "Player");
state.icon_url = Some("https://example.com/icon.png".to_string());
state.playback_speed = Some(PlaybackSpeed::DOUBLE);
state.seeking_speed = Some(SeekingSpeed::new(10));
state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SHUFFLE_ONCE));
state.supported_opcodes = Some(SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE);
assert_eq!(
state.handle_read(MEDIA_PLAYER_ICON_URL_HANDLE, 0),
Ok(b"https://example.com/icon.png".to_vec())
);
assert_eq!(
state.handle_read(PLAYBACK_SPEED_HANDLE, 0),
Ok(vec![PlaybackSpeed::DOUBLE.into()])
);
assert_eq!(
state.handle_read(SEEKING_SPEED_HANDLE, 0),
Ok(vec![SeekingSpeed::new(10).into()])
);
assert_eq!(
state.handle_read(PLAYING_ORDER_HANDLE, 0),
Ok(vec![PlayingOrder::InOrderOnce.into()])
);
assert_eq!(
state.handle_read(PLAYING_ORDERS_SUPPORTED_HANDLE, 0),
Ok(SupportedPlayingOrders::SHUFFLE_ONCE.bits().to_le_bytes().to_vec())
);
assert_eq!(
state.handle_read(MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, 0),
Ok((SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE).bits().to_le_bytes().to_vec())
);
}
#[test]
fn read_string_characteristics_with_offset() {
let mut state = McsLocalState::new(0x01, "Long Player Name");
state.track_title = "Track Number 1".to_string();
state.icon_url = Some("https://example.com".to_string());
assert_eq!(state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 5), Ok(b"Player Name".to_vec()));
assert_eq!(state.handle_read(TRACK_TITLE_HANDLE, 6), Ok(b"Number 1".to_vec()));
assert_eq!(state.handle_read(MEDIA_PLAYER_ICON_URL_HANDLE, 8), Ok(b"example.com".to_vec()));
assert_matches!(
state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 100),
Err(GattError::InvalidOffset)
);
}
#[test]
fn read_track_position_during_active_playback() {
let mut state = McsLocalState::new(0x01, "Player");
state.track_duration = TrackDuration::from_raw_10ms(10000);
state.media_state = MediaState::Playing;
state.track_position = TrackPosition::from_raw_10ms(200);
state.position_updated_at = Some(std::time::Instant::now() - Duration::from_millis(500));
let res = state.handle_read(TRACK_POSITION_HANDLE, 0).expect("read should succeed");
let raw = i32::from_le_bytes(res.try_into().unwrap());
assert!(raw >= 240 && raw <= 260, "Expected track position ~250 (10ms units), got {raw}");
}
#[test]
fn read_unconfigured_optional_characteristics_returns_invalid_handle() {
let state = McsLocalState::new(0x01, "Player");
let optional_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 optional_handles {
assert_matches!(
state.handle_read(handle, 0),
Err(GattError::InvalidHandle),
"Handle {:?} should return InvalidHandle when not configured",
handle
);
}
}
#[test]
fn read_non_readable_characteristics_returns_error() {
let mut state = McsLocalState::new(0x01, "Player");
state.supported_opcodes = Some(SupportedOpcodes::all());
assert_matches!(
state.handle_read(TRACK_CHANGED_HANDLE, 0),
Err(GattError::ReadNotPermitted)
);
assert_matches!(
state.handle_read(MEDIA_CONTROL_POINT_HANDLE, 0),
Err(GattError::ReadNotPermitted)
);
}
#[test]
fn mandatory_characteristics_properties_and_permissions() {
let mandatory = mandatory_characteristics();
assert_eq!(mandatory.len(), 7);
assert_eq!(mandatory[0].handle, MEDIA_PLAYER_NAME_HANDLE);
assert_eq!(mandatory[0].uuid, MEDIA_PLAYER_NAME_UUID);
assert_eq!(mandatory[0].properties, CharacteristicProperties::READ_NOTIFY);
assert_eq!(mandatory[1].handle, TRACK_CHANGED_HANDLE);
assert_eq!(mandatory[1].uuid, TRACK_CHANGED_UUID);
assert_eq!(
mandatory[1].properties,
CharacteristicProperties::from(CharacteristicProperty::Notify)
);
assert_eq!(mandatory[2].handle, TRACK_TITLE_HANDLE);
assert_eq!(mandatory[2].uuid, TRACK_TITLE_UUID);
assert_eq!(mandatory[2].properties, CharacteristicProperties::READ_NOTIFY);
assert_eq!(mandatory[3].handle, TRACK_DURATION_HANDLE);
assert_eq!(mandatory[3].uuid, TRACK_DURATION_UUID);
assert_eq!(mandatory[3].properties, CharacteristicProperties::READ_NOTIFY);
assert_eq!(mandatory[4].handle, TRACK_POSITION_HANDLE);
assert_eq!(mandatory[4].uuid, TRACK_POSITION_UUID);
assert_eq!(mandatory[4].properties, CharacteristicProperties::READ_WRITE_NOTIFY);
assert_eq!(mandatory[5].handle, MEDIA_STATE_HANDLE);
assert_eq!(mandatory[5].uuid, MEDIA_STATE_UUID);
assert_eq!(mandatory[5].properties, CharacteristicProperties::READ_NOTIFY);
assert_eq!(mandatory[6].handle, CONTENT_CONTROL_ID_HANDLE);
assert_eq!(mandatory[6].uuid, CONTENT_CONTROL_ID_UUID);
assert_eq!(
mandatory[6].properties,
CharacteristicProperties::from(CharacteristicProperty::Read)
);
for chrc in &mandatory {
assert!(chrc.permissions.read.map_or(true, |s| s.encryption));
assert!(chrc.permissions.write.map_or(true, |s| s.encryption));
assert!(chrc.permissions.update.map_or(true, |s| s.encryption));
}
}
}