| // 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::*; | |
| use crate::Error; | |
| // ============================================================================ | |
| // 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; | |
| } | |
| let base = match (self.track_position, self.track_duration) { | |
| (TrackPosition::FromStart(base), _) => base, | |
| (TrackPosition::FromEnd(end), TrackDuration::Duration(total)) => { | |
| total.saturating_sub(end) | |
| } | |
| (TrackPosition::FromEnd(_), TrackDuration::Unknown) => { | |
| return TrackPosition::Unavailable; | |
| } | |
| _ => return self.track_position, | |
| }; | |
| // Playback is paused, so position remains fixed at current offset. | |
| if self.media_state == MediaState::Paused { | |
| return TrackPosition::FromStart(base); | |
| } | |
| // Playback timing has not started since the track was initialized or | |
| // loaded. | |
| let Some(updated_at) = self.position_updated_at else { | |
| return TrackPosition::FromStart(base); | |
| }; | |
| let elapsed = updated_at.elapsed(); | |
| let current = match self.media_state { | |
| MediaState::Playing => { | |
| let speed_multiplier = self.playback_speed.map_or(1.0, |s| s.as_multiplier()); | |
| base + elapsed.mul_f64(speed_multiplier) | |
| } | |
| MediaState::Seeking => { | |
| let factor = self.seeking_speed.map_or(0, |s| s.factor()); | |
| if factor > 0 { | |
| base + elapsed.mul_f64(factor as f64) | |
| } else if factor < 0 { | |
| base.saturating_sub(elapsed.mul_f64(factor.unsigned_abs() as f64)) | |
| } else { | |
| base | |
| } | |
| } | |
| _ => base, | |
| }; | |
| let bounded = if let TrackDuration::Duration(total) = self.track_duration { | |
| current.min(total) | |
| } else { | |
| current | |
| }; | |
| TrackPosition::FromStart(bounded) | |
| } | |
| /// Updates the track position and synchronizes the timestamp anchor. | |
| fn update_track_position(&mut self, position: TrackPosition) { | |
| let resolved = match (position, self.track_duration) { | |
| (TrackPosition::FromStart(base), _) => TrackPosition::FromStart(base), | |
| (TrackPosition::FromEnd(end), TrackDuration::Duration(total)) => { | |
| TrackPosition::FromStart(total.saturating_sub(end)) | |
| } | |
| (TrackPosition::FromEnd(_), TrackDuration::Unknown) => TrackPosition::Unavailable, | |
| (TrackPosition::Unavailable, _) => TrackPosition::Unavailable, | |
| }; | |
| self.track_position = resolved; | |
| self.position_updated_at = match resolved { | |
| TrackPosition::Unavailable => None, | |
| _ => Some(std::time::Instant::now()), | |
| }; | |
| } | |
| /// Sets the media playback state and updates the playback position clock | |
| /// anchor accordingly. | |
| pub(crate) fn set_media_state(&mut self, state: MediaState) { | |
| if self.media_state == state { | |
| return; | |
| } | |
| match state { | |
| MediaState::Playing | MediaState::Paused | MediaState::Seeking => { | |
| let position = if self.media_state == MediaState::Playing | |
| || self.media_state == MediaState::Seeking | |
| { | |
| self.current_track_position() | |
| } else { | |
| self.track_position | |
| }; | |
| self.update_track_position(position); | |
| } | |
| MediaState::Inactive => { | |
| self.update_track_position(TrackPosition::Unavailable); | |
| } | |
| } | |
| self.media_state = state; | |
| } | |
| /// Sets the track position and records the update timestamp. | |
| pub(crate) fn set_track_position(&mut self, position: TrackPosition) { | |
| self.update_track_position(position); | |
| } | |
| /// Sets the current track title, duration, and position. | |
| pub(crate) fn set_track( | |
| &mut self, | |
| title: String, | |
| duration: TrackDuration, | |
| position: TrackPosition, | |
| ) { | |
| self.track_title = title; | |
| self.track_duration = duration; | |
| self.update_track_position(position); | |
| } | |
| /// Clears the active track metadata and position. | |
| pub(crate) fn clear_track(&mut self) { | |
| self.track_title.clear(); | |
| self.track_duration = TrackDuration::Unknown; | |
| self.update_track_position(TrackPosition::Unavailable); | |
| } | |
| /// Sets the human-readable media player name. | |
| pub(crate) fn set_player_name(&mut self, name: String) { | |
| self.player_name = name; | |
| } | |
| /// Sets the media player icon URL if supported. | |
| pub(crate) fn set_icon_url(&mut self, url: String) -> Result<(), Error> { | |
| if self.icon_url.is_none() { | |
| return Err(Error::FeatureNotSupported("icon_url")); | |
| } | |
| self.icon_url = Some(url); | |
| Ok(()) | |
| } | |
| /// Sets the playback speed. | |
| pub(crate) fn set_playback_speed(&mut self, speed: PlaybackSpeed) -> Result<(), Error> { | |
| if self.playback_speed.is_none() { | |
| return Err(Error::FeatureNotSupported("playback_speed")); | |
| } | |
| // Freeze current track position before adjusting speed so the new | |
| // multiplier is not retroactively applied to prior playback | |
| // time (MCS v1.0.1 Section 3.7.1). | |
| if self.media_state == MediaState::Playing { | |
| self.update_track_position(self.current_track_position()); | |
| } | |
| self.playback_speed = Some(speed); | |
| Ok(()) | |
| } | |
| /// Sets the seeking speed if supported. | |
| pub(crate) fn set_seeking_speed(&mut self, speed: SeekingSpeed) -> Result<(), Error> { | |
| if self.seeking_speed.is_none() { | |
| return Err(Error::FeatureNotSupported("seeking_speed")); | |
| } | |
| // Freeze current track position before entering or adjusting seeking | |
| // speed so prior elapsed playback is preserved (MCS v1.0.1 | |
| // Section 3.7.1, 3.9). | |
| if self.media_state == MediaState::Playing || self.media_state == MediaState::Seeking { | |
| self.update_track_position(self.current_track_position()); | |
| } | |
| self.seeking_speed = Some(speed); | |
| Ok(()) | |
| } | |
| /// Sets the playing order if supported and enabled. | |
| pub(crate) fn set_playing_order(&mut self, order: PlayingOrder) -> Result<(), Error> { | |
| let Some(ref mut orders) = self.playing_orders else { | |
| return Err(Error::FeatureNotSupported("playing_orders")); | |
| }; | |
| if !orders.supported.contains(order.into()) { | |
| return Err(Error::UnsupportedPlayingOrder(order)); | |
| } | |
| orders.current = order; | |
| Ok(()) | |
| } | |
| /// Sets the supported opcodes if supported. | |
| pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) -> Result<(), Error> { | |
| if self.supported_opcodes.is_none() { | |
| return Err(Error::FeatureNotSupported("media_control_point")); | |
| } | |
| self.supported_opcodes = Some(opcodes); | |
| Ok(()) | |
| } | |
| /// 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); | |
| assert_matches!(state.set_playing_order(PlayingOrder::SingleOnce), Ok(())); | |
| assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce); | |
| // Unsupported playing order returns Err and is not applied | |
| assert_matches!( | |
| state.set_playing_order(PlayingOrder::ShuffleOnce), | |
| Err(Error::UnsupportedPlayingOrder(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)); | |
| } | |
| } | |
| #[test] | |
| fn local_state_track_position_calculation_with_playback_speed() { | |
| 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(1000); // 10s base | |
| // Double speed (2.0x): 2s elapsed should add ~4s -> ~14s | |
| state.playback_speed = Some(PlaybackSpeed::DOUBLE); | |
| 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(13800) && dur <= Duration::from_millis(14200)); | |
| } | |
| other => panic!("Expected FromStart, got {other:?}"), | |
| } | |
| // Half speed (0.5x): 4s elapsed should add ~2s -> ~12s | |
| state.playback_speed = Some(PlaybackSpeed::HALF); | |
| state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(4)); | |
| match state.current_track_position() { | |
| TrackPosition::FromStart(dur) => { | |
| assert!(dur >= Duration::from_millis(11800) && dur <= Duration::from_millis(12200)); | |
| } | |
| other => panic!("Expected FromStart, got {other:?}"), | |
| } | |
| } | |
| #[test] | |
| fn local_state_track_position_calculation_with_seeking_speed() { | |
| let mut state = McsLocalState::new(0x01, "Player"); | |
| state.track_duration = TrackDuration::from_raw_10ms(10000); // 100s | |
| state.media_state = MediaState::Seeking; | |
| state.track_position = TrackPosition::from_raw_10ms(2000); // 20s base | |
| // Fast forward (+4x): 2s elapsed should add ~8s -> ~28s | |
| state.seeking_speed = Some(SeekingSpeed::new(4)); | |
| 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(27800) && dur <= Duration::from_millis(28200)); | |
| } | |
| other => panic!("Expected FromStart, got {other:?}"), | |
| } | |
| // Fast rewind (-4x): 2s elapsed should subtract ~8s -> ~12s | |
| state.seeking_speed = Some(SeekingSpeed::new(-4)); | |
| 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(11800) && dur <= Duration::from_millis(12200)); | |
| } | |
| other => panic!("Expected FromStart, got {other:?}"), | |
| } | |
| // Fast rewind saturated at start of track (0s) | |
| state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(10)); | |
| assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(0)); | |
| // Fast forward clamped at track duration (100s) | |
| state.seeking_speed = Some(SeekingSpeed::new(4)); | |
| state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(50)); | |
| assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(10000)); | |
| } | |
| #[test] | |
| fn local_state_state_transitions_timeline_capture() { | |
| let mut state = McsLocalState::new(0x01, "Player"); | |
| state.track_duration = TrackDuration::from_raw_10ms(10000); | |
| state.set_track( | |
| "Test Track".to_string(), | |
| TrackDuration::from_raw_10ms(5000), | |
| TrackPosition::from_raw_10ms(100), | |
| ); | |
| assert_eq!(state.track_title, "Test Track"); | |
| assert_eq!(state.track_duration, TrackDuration::from_raw_10ms(5000)); | |
| assert_eq!(state.track_position, TrackPosition::from_raw_10ms(100)); | |
| // Transition to Playing | |
| state.set_media_state(MediaState::Playing); | |
| assert_eq!(state.media_state, MediaState::Playing); | |
| assert!(state.position_updated_at.is_some()); | |
| // Fast-forward time simulated: 1s elapsed | |
| state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(1)); | |
| // Transition to Paused: snaps dynamic position | |
| state.set_media_state(MediaState::Paused); | |
| assert_eq!(state.media_state, MediaState::Paused); | |
| match state.track_position { | |
| TrackPosition::FromStart(dur) => { | |
| assert!(dur >= Duration::from_millis(1900) && dur <= Duration::from_millis(2200)); | |
| } | |
| other => panic!("Expected FromStart, got {other:?}"), | |
| } | |
| // Transition to Inactive: position unavailable | |
| state.set_media_state(MediaState::Inactive); | |
| assert_eq!(state.media_state, MediaState::Inactive); | |
| assert_eq!(state.track_position, TrackPosition::Unavailable); | |
| assert!(state.position_updated_at.is_none()); | |
| // Clear track | |
| state.clear_track(); | |
| assert_eq!(state.track_title, ""); | |
| assert_eq!(state.track_duration, TrackDuration::Unknown); | |
| assert_eq!(state.track_position, TrackPosition::Unavailable); | |
| } | |
| #[test] | |
| fn local_state_optional_setters_feature_validation() { | |
| let mut state = McsLocalState::new(0x01, "Player"); | |
| // Unconfigured features return FeatureNotSupported | |
| assert_matches!( | |
| state.set_icon_url("https://example.com".to_string()), | |
| Err(Error::FeatureNotSupported("icon_url")) | |
| ); | |
| assert_matches!( | |
| state.set_playback_speed(PlaybackSpeed::NORMAL), | |
| Err(Error::FeatureNotSupported("playback_speed")) | |
| ); | |
| assert_matches!( | |
| state.set_seeking_speed(SeekingSpeed::new(1)), | |
| Err(Error::FeatureNotSupported("seeking_speed")) | |
| ); | |
| assert_matches!( | |
| state.set_playing_order(PlayingOrder::SingleOnce), | |
| Err(Error::FeatureNotSupported("playing_orders")) | |
| ); | |
| assert_matches!( | |
| state.set_supported_opcodes(SupportedOpcodes::all()), | |
| Err(Error::FeatureNotSupported("media_control_point")) | |
| ); | |
| // When configured, setters succeed and validate | |
| state.icon_url = Some(String::new()); | |
| state.playback_speed = Some(PlaybackSpeed::NORMAL); | |
| state.seeking_speed = Some(SeekingSpeed::NOT_SEEKING); | |
| state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SINGLE_ONCE)); | |
| state.supported_opcodes = Some(SupportedOpcodes::PLAY); | |
| assert_matches!(state.set_icon_url("https://example.com/icon.png".to_string()), Ok(())); | |
| assert_eq!(state.icon_url.as_deref(), Some("https://example.com/icon.png")); | |
| assert_matches!(state.set_playback_speed(PlaybackSpeed::DOUBLE), Ok(())); | |
| assert_eq!(state.playback_speed, Some(PlaybackSpeed::DOUBLE)); | |
| assert_matches!(state.set_seeking_speed(SeekingSpeed::new(5)), Ok(())); | |
| assert_eq!(state.seeking_speed, Some(SeekingSpeed::new(5))); | |
| assert_matches!(state.set_playing_order(PlayingOrder::SingleOnce), Ok(())); | |
| assert_matches!( | |
| state.set_playing_order(PlayingOrder::ShuffleOnce), | |
| Err(Error::UnsupportedPlayingOrder(PlayingOrder::ShuffleOnce)) | |
| ); | |
| assert_matches!(state.set_supported_opcodes(SupportedOpcodes::all()), Ok(())); | |
| } | |
| #[test] | |
| fn seeking_speed_min_boundary_calculation() { | |
| let mut state = McsLocalState::new(0x01, "Player"); | |
| state.set_track( | |
| "Song".to_string(), | |
| TrackDuration::from_raw_10ms(10000), | |
| TrackPosition::from_raw_10ms(5000), | |
| ); | |
| state.seeking_speed = Some(SeekingSpeed::new(-128)); | |
| state.set_media_state(MediaState::Seeking); | |
| // Verify seeking with minimum factor (-128) does not overflow or panic. | |
| let pos = state.current_track_position(); | |
| match pos { | |
| TrackPosition::FromStart(dur) => { | |
| assert!(dur <= Duration::from_millis(50000)); | |
| } | |
| other => panic!("Expected FromStart, got {other:?}"), | |
| } | |
| // Verify FromEnd with Unknown duration resolves to Unavailable per MCS | |
| // v1.0.1 Section 3.7.1. | |
| state.set_track( | |
| "Live Stream".to_string(), | |
| TrackDuration::Unknown, | |
| TrackPosition::from_end(Duration::from_secs(10)), | |
| ); | |
| assert_eq!(state.current_track_position(), TrackPosition::Unavailable); | |
| // Verify FromEnd with known duration resolves to FromStart(total - | |
| // end). | |
| state.set_media_state(MediaState::Paused); | |
| state.set_track( | |
| "Track".to_string(), | |
| TrackDuration::from_raw_10ms(5000), // 50s | |
| TrackPosition::from_end(Duration::from_secs(10)), | |
| ); | |
| assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(4000)); | |
| } | |
| } |