rust/bt-mcs: Handle GATT read requests for mandatory characteristics Implement GATT read request handling for the mandatory characteristics defined in the MCS spec. Maintain a local `McsLocalState` holding mandatory characteristic values initialized to spec-defined defaults. Some of these may be specified by the builder. Process GATT read requests on the MCS Server by responding with appropriate byte representations, handling long-read offsets, and returning `GattError::ReadNotPermitted` or `GattError::InvalidOffset` where appropriate. Bug: 540400364 Test: cargo test -p bt-mcs, ./presubmit.sh Change-Id: Ic4b6c989b0e82850a6e521f57a13abc1b7ee644f Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3720
diff --git a/rust/bt-mcs/src/server.rs b/rust/bt-mcs/src/server.rs index 59e5d68..f666070 100644 --- a/rust/bt-mcs/src/server.rs +++ b/rust/bt-mcs/src/server.rs
@@ -5,10 +5,12 @@ //! Implements the Media Control Service (MCS) server. use bt_common::Uuid; -use bt_gatt::server::{LocalService, Server as _, ServiceDefinition, ServiceEvent, ServiceId}; +use bt_gatt::server::{ + LocalService, ReadResponder, Server as _, ServiceDefinition, ServiceEvent, ServiceId, +}; use bt_gatt::types::{ - AttributePermissions, CharacteristicProperties, CharacteristicProperty, Handle, SecurityLevels, - ServiceKind, + AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, Handle, + SecurityLevels, ServiceKind, }; use bt_gatt::Characteristic; use futures::stream::Stream; @@ -218,16 +220,106 @@ } } +/// Local state of the mandatory 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, +} + +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, + } + } + + /// 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> { + // Track Changed is notify-only. Return Error. See MCS v1.0.1 Section 3.4. + if handle == TRACK_CHANGED_HANDLE { + return Err(GattError::ReadNotPermitted); + } + + let read_at_offset = + |bytes: &[u8]| bytes.get(offset..).map(Vec::from).ok_or(GattError::InvalidOffset); + match handle { + 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]), + _ => 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, - /// 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, + /// Local state of the characteristics in this server. + state: McsLocalState, } impl McsServerBuilder { @@ -242,7 +334,7 @@ } fn new(service_uuid: Uuid, ccid: u8, player_name: impl Into<String>) -> Self { - Self { service_uuid, ccid, player_name: player_name.into() } + Self { service_uuid, state: McsLocalState::new(ccid, player_name) } } /// Constructs the complete GATT [`ServiceDefinition`] containing all 13 @@ -253,7 +345,7 @@ // host server. Adding a duplicate characteristic will result in an // Error. let mut service_def = ServiceDefinition::new( - ServiceId::new(self.ccid.into()), + ServiceId::new(self.state.ccid.into()), self.service_uuid, ServiceKind::Primary, ); @@ -268,12 +360,7 @@ /// 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, - }) + Ok(McsServer { service_def, local_service: Default::default(), state: self.state }) } } @@ -284,8 +371,8 @@ service_def: ServiceDefinition, #[pin] local_service: LocalServiceState<T>, - ccid: u8, - player_name: String, + /// Local state of the characteristics in this server. + state: McsLocalState, } impl<T: bt_gatt::ServerTypes> McsServer<T> { @@ -315,6 +402,13 @@ 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> { @@ -325,14 +419,17 @@ 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, - }; + 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 { - // TODO(b/540400364): Add support for characteristic reads and writes + ServiceEvent::Read { peer_id: _, handle, offset, responder } => { + self.handle_read(handle, offset as usize, responder); + } + // TODO(b/540400364): Add support for characteristic writes _ => continue, } } @@ -343,7 +440,9 @@ mod tests { use super::*; - use bt_gatt::test_utils::{FakeServer, FakeTypes}; + use bt_common::PeerId; + use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeTypes}; + use bt_gatt::types::GattError; use futures::{FutureExt, StreamExt}; #[test] @@ -536,4 +635,304 @@ 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 (0x2B93) + assert_read_characteristic( + &mut server, + &fake_gatt_server, + &mut event_receiver, + MEDIA_PLAYER_NAME_HANDLE, + b"Test Player", + ); + + // Track Title (0x2B97) + assert_read_characteristic( + &mut server, + &fake_gatt_server, + &mut event_receiver, + TRACK_TITLE_HANDLE, + b"", + ); + + // Track Duration (0x2B98) + assert_read_characteristic( + &mut server, + &fake_gatt_server, + &mut event_receiver, + TRACK_DURATION_HANDLE, + &(-1i32).to_le_bytes(), + ); + + // Track Position (0x2B99) + assert_read_characteristic( + &mut server, + &fake_gatt_server, + &mut event_receiver, + TRACK_POSITION_HANDLE, + &(-1i32).to_le_bytes(), + ); + + // Media State (0x2BA3) + assert_read_characteristic( + &mut server, + &fake_gatt_server, + &mut event_receiver, + MEDIA_STATE_HANDLE, + &[MediaState::Inactive.into()], + ); + + // Content Control ID (0x2BA8) + assert_read_characteristic( + &mut server, + &fake_gatt_server, + &mut event_receiver, + CONTENT_CONTROL_ID_HANDLE, + &[0x42], + ); + } + + #[test] + fn read_string_characteristics_with_offset() { + 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, "Long Player Name")); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + // Valid offset slice + 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"); + + // 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) + )); + } + + #[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")); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + // 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) + )); + + // 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}"); + } }
diff --git a/rust/bt-mcs/src/types.rs b/rust/bt-mcs/src/types.rs index cd49de0..30043ac 100644 --- a/rust/bt-mcs/src/types.rs +++ b/rust/bt-mcs/src/types.rs
@@ -5,6 +5,7 @@ use bitflags::bitflags; use bt_common::packet_encoding::{Decodable, Encodable, Error as PacketError}; use bt_common::{decodable_enum, Uuid}; +use std::time::Duration; /// 16-bit UUID for the Media Control Service (MCS). /// Defined in MCS v1.0.1 Section 2 @@ -206,6 +207,13 @@ } } +impl Default for SupportedPlayingOrders { + /// Returns standard sequential playing order (`IN_ORDER_ONCE`) by default. + fn default() -> Self { + Self::IN_ORDER_ONCE + } +} + impl From<PlayingOrder> for SupportedPlayingOrders { fn from(order: PlayingOrder) -> Self { match order { @@ -456,6 +464,14 @@ } } +impl Default for SupportedOpcodes { + /// Returns basic media playback control opcodes (`PLAY | PAUSE | STOP`) by + /// default. + fn default() -> Self { + Self::PLAY | Self::PAUSE | Self::STOP + } +} + impl From<&MediaControlOpcode> for SupportedOpcodes { fn from(opcode: &MediaControlOpcode) -> Self { match opcode { @@ -518,6 +534,152 @@ } } +/// Total duration of the current track (MCS v1.0.1 Section 3.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrackDuration { + /// Total duration of the current track. + Duration(Duration), + /// Duration is unknown or no track is loaded. + Unknown, +} + +impl TrackDuration { + /// Size of the encoded Track Duration characteristic in bytes. + pub const SIZE_BYTES: usize = 4; + + /// Raw integer value representing unknown track duration. + pub const UNKNOWN_RAW: i32 = -1; + + /// Creates a known track duration. + pub const fn from_duration(duration: Duration) -> Self { + Self::Duration(duration) + } + + /// Creates a track duration from a raw 10-millisecond unit 32-bit signed + /// integer. + pub fn from_raw_10ms(raw: i32) -> Self { + match raw { + Self::UNKNOWN_RAW => Self::Unknown, + dur if dur >= 0 => Self::Duration(Duration::from_millis(dur as u64 * 10)), + _ => Self::Unknown, + } + } + + /// Returns the raw 32-bit signed integer in 10-millisecond units. + pub fn raw_10ms(&self) -> i32 { + match self { + Self::Unknown => Self::UNKNOWN_RAW, + Self::Duration(d) => (d.as_millis() / 10).min(i32::MAX as u128) as i32, + } + } +} + +impl Encodable for TrackDuration { + type Error = PacketError; + + fn encoded_len(&self) -> usize { + Self::SIZE_BYTES + } + + fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> { + if buf.len() < Self::SIZE_BYTES { + return Err(PacketError::BufferTooSmall); + } + buf[0..Self::SIZE_BYTES].copy_from_slice(&self.raw_10ms().to_le_bytes()); + Ok(()) + } +} + +impl Decodable for TrackDuration { + type Error = PacketError; + + fn decode(buf: &[u8]) -> (Result<Self, Self::Error>, usize) { + if buf.len() < Self::SIZE_BYTES { + return (Err(PacketError::UnexpectedDataLength), 0); + } + let raw = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + (Ok(Self::from_raw_10ms(raw)), Self::SIZE_BYTES) + } +} + +/// Current track position of the media player in 0.01-second (10 ms) resolution +/// (MCS v1.0.1 Section 3.7). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrackPosition { + /// Absolute offset from the start of the current track. + FromStart(Duration), + /// Offset relative to the end of the current track. + FromEnd(Duration), + /// Track position is unavailable, e.g. when no track is loaded or for live + /// streams. + Unavailable, +} + +impl TrackPosition { + /// Size of the encoded Track Position characteristic in bytes. + pub const SIZE_BYTES: usize = 4; + + /// Raw integer value representing unavailable position. + pub const UNAVAILABLE_RAW: i32 = -1; + + /// Creates a track position representing an absolute offset from the start. + pub const fn from_start(duration: Duration) -> Self { + Self::FromStart(duration) + } + + /// Creates a track position representing an offset relative to the end. + pub const fn from_end(duration: Duration) -> Self { + Self::FromEnd(duration) + } + + /// Creates a track position from a raw 10-millisecond unit 32-bit signed + /// integer. + pub fn from_raw_10ms(raw: i32) -> Self { + match raw { + Self::UNAVAILABLE_RAW => Self::Unavailable, + pos if pos >= 0 => Self::FromStart(Duration::from_millis(pos as u64 * 10)), + neg => Self::FromEnd(Duration::from_millis((-neg) as u64 * 10)), + } + } + + /// Returns the raw 32-bit signed integer in 10-millisecond units. + pub fn raw_10ms(&self) -> i32 { + match self { + Self::Unavailable => Self::UNAVAILABLE_RAW, + Self::FromStart(d) => (d.as_millis() / 10).min(i32::MAX as u128) as i32, + Self::FromEnd(d) => -((d.as_millis() / 10).min(i32::MAX as u128) as i32), + } + } +} + +impl Encodable for TrackPosition { + type Error = PacketError; + + fn encoded_len(&self) -> usize { + Self::SIZE_BYTES + } + + fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> { + if buf.len() < Self::SIZE_BYTES { + return Err(PacketError::BufferTooSmall); + } + buf[0..Self::SIZE_BYTES].copy_from_slice(&self.raw_10ms().to_le_bytes()); + Ok(()) + } +} + +impl Decodable for TrackPosition { + type Error = PacketError; + + fn decode(buf: &[u8]) -> (Result<Self, Self::Error>, usize) { + if buf.len() < Self::SIZE_BYTES { + return (Err(PacketError::UnexpectedDataLength), 0); + } + let raw = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + (Ok(Self::from_raw_10ms(raw)), Self::SIZE_BYTES) + } +} + #[cfg(test)] mod tests { use super::*; @@ -671,6 +833,7 @@ #[test] fn supported_playing_orders_bitmask_success() { + assert_eq!(SupportedPlayingOrders::default(), SupportedPlayingOrders::IN_ORDER_ONCE); assert_eq!(SupportedPlayingOrders::all().bits(), 0x03FF); let supported = SupportedPlayingOrders::SINGLE_ONCE @@ -825,6 +988,10 @@ #[test] fn supported_opcodes_bitmask_success() { + assert_eq!( + SupportedOpcodes::default(), + SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE | SupportedOpcodes::STOP + ); assert_eq!(SupportedOpcodes::all().bits(), 0x001F_FFFF); let opcode_flags = [ @@ -889,4 +1056,57 @@ assert_eq!(consumed, 0); assert_eq!(err.unwrap_err(), PacketError::UnexpectedDataLength); } + + #[test] + fn track_duration_encode_decode() { + assert_eq!(TrackDuration::Unknown.raw_10ms(), -1); + assert_eq!(TrackDuration::from_raw_10ms(-1), TrackDuration::Unknown); + assert_eq!(TrackDuration::from_raw_10ms(-50), TrackDuration::Unknown); + + let dur = TrackDuration::from_duration(Duration::from_secs(180)); + assert_eq!(dur.raw_10ms(), 18000); + assert_eq!(TrackDuration::from_raw_10ms(18000), dur); + + let mut buf = [0u8; 4]; + assert!(dur.encode(&mut buf).is_ok()); + assert_eq!(buf, 18000i32.to_le_bytes()); + + let (decoded, consumed) = TrackDuration::decode(&buf); + assert_eq!(consumed, 4); + assert_eq!(decoded.unwrap(), dur); + + let mut small = [0u8; 3]; + assert_eq!(dur.encode(&mut small).unwrap_err(), PacketError::BufferTooSmall); + let (err, consumed) = TrackDuration::decode(&small); + assert_eq!(consumed, 0); + assert_eq!(err.unwrap_err(), PacketError::UnexpectedDataLength); + } + + #[test] + fn track_position_encode_decode() { + assert_eq!(TrackPosition::Unavailable.raw_10ms(), -1); + assert_eq!(TrackPosition::from_raw_10ms(-1), TrackPosition::Unavailable); + + let start_pos = TrackPosition::from_start(Duration::from_millis(1500)); + assert_eq!(start_pos.raw_10ms(), 150); + assert_eq!(TrackPosition::from_raw_10ms(150), start_pos); + + let end_pos = TrackPosition::from_end(Duration::from_millis(2000)); + assert_eq!(end_pos.raw_10ms(), -200); + assert_eq!(TrackPosition::from_raw_10ms(-200), end_pos); + + let mut buf = [0u8; 4]; + assert!(start_pos.encode(&mut buf).is_ok()); + assert_eq!(buf, 150i32.to_le_bytes()); + + let (decoded, consumed) = TrackPosition::decode(&buf); + assert_eq!(consumed, 4); + assert_eq!(decoded.unwrap(), start_pos); + + let mut small = [0u8; 3]; + assert_eq!(start_pos.encode(&mut small).unwrap_err(), PacketError::BufferTooSmall); + let (err, consumed) = TrackPosition::decode(&small); + assert_eq!(consumed, 0); + assert_eq!(err.unwrap_err(), PacketError::UnexpectedDataLength); + } }