rust/bt-mcs: Add public methods for updating server state Add high-level public methods on McsServer to update track metadata, media playback state, position, identity, and optional control characteristics. Issue GATT notifications for state updates according to MCS v1.0.1: - Order Track Changed notification after track property updates - Resolve FromEnd track position offsets into non-negative values - Notify TrackPosition as Unavailable on transition to Inactive - Treat redundant Media State transitions as no-ops - Reject write notifications when state validation fails Update track position calculation to factor in playback speed and seeking speed without integer overflow. Add FakeServerEventsExt assertion helpers in bt-gatt test_utils and update the MCS server test suite to explicitly assert GATT handles and notification payloads. Bug: 540400364 Test: cargo test -p bt-gatt -p bt-mcs, ./presubmit.sh Change-Id: Ib0d9e20de601d19bce339e332d5256a3f998eb10 Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3900
diff --git a/rust/bt-gatt/src/test_utils.rs b/rust/bt-gatt/src/test_utils.rs index def7f62..ed05af5 100644 --- a/rust/bt-gatt/src/test_utils.rs +++ b/rust/bt-gatt/src/test_utils.rs
@@ -6,10 +6,11 @@ use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; use futures::future::{ready, Ready}; use futures::stream::{FusedStream, Stream}; +use futures::StreamExt; use parking_lot::Mutex; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; -use std::task::{Poll, Waker}; +use std::task::{Context, Poll, Waker}; use bt_common::{PeerId, Uuid}; @@ -802,3 +803,94 @@ .unwrap(); } } + +/// Extension trait providing assertion helpers on a `FakeServerEvent` event +/// stream. +pub trait FakeServerEventsExt { + /// Polls the receiver and expects a single event to be ready, panicking if + /// none is ready. + fn expect_event(&mut self) -> FakeServerEvent; + + /// Asserts that no event is currently pending on the receiver. + fn expect_no_event(&mut self); + + /// Expects a `FakeServerEvent::Notified` event for `expected_handle`. + /// Returns the notification payload and the list of notified peers. + fn expect_notification(&mut self, expected_handle: Handle) -> (Vec<u8>, Vec<PeerId>); + + /// Expects a `FakeServerEvent::WriteResponded` event for `expected_handle`. + /// Returns the result of the write response. + fn expect_write_response(&mut self, expected_handle: Handle) -> Result<()>; + + /// Expects a `FakeServerEvent::ReadResponded` event for `expected_handle`. + /// Returns the result of the read response. + fn expect_read_response(&mut self, expected_handle: Handle) -> Result<Vec<u8>>; +} + +impl FakeServerEventsExt for UnboundedReceiver<FakeServerEvent> { + #[track_caller] + fn expect_event(&mut self) -> FakeServerEvent { + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + match self.poll_next_unpin(&mut cx) { + Poll::Ready(Some(event)) => event, + x => panic!("Expected fake server event, got {x:?}"), + } + } + + #[track_caller] + fn expect_no_event(&mut self) { + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + match self.poll_next_unpin(&mut cx) { + Poll::Pending => {} + Poll::Ready(other) => panic!("Expected no fake server event, got {other:?}"), + } + } + + #[track_caller] + fn expect_notification(&mut self, expected_handle: Handle) -> (Vec<u8>, Vec<PeerId>) { + match self.expect_event() { + FakeServerEvent::Notified { handle, value, peers, .. } => { + assert_eq!( + handle, expected_handle, + "Expected notification for handle {expected_handle:?}, but got notification for handle {handle:?}" + ); + (value, peers) + } + other => panic!( + "Expected FakeServerEvent::Notified for handle {expected_handle:?}, got {other:?}" + ), + } + } + + #[track_caller] + fn expect_write_response(&mut self, expected_handle: Handle) -> Result<()> { + match self.expect_event() { + FakeServerEvent::WriteResponded { handle, value, .. } => { + assert_eq!( + handle, expected_handle, + "Expected write response for handle {expected_handle:?}, but got response for handle {handle:?}" + ); + value + } + other => panic!( + "Expected FakeServerEvent::WriteResponded for handle {expected_handle:?}, got {other:?}" + ), + } + } + + #[track_caller] + fn expect_read_response(&mut self, expected_handle: Handle) -> Result<Vec<u8>> { + match self.expect_event() { + FakeServerEvent::ReadResponded { handle, value, .. } => { + assert_eq!( + handle, expected_handle, + "Expected read response for handle {expected_handle:?}, but got response for handle {handle:?}" + ); + value + } + other => panic!( + "Expected FakeServerEvent::ReadResponded for handle {expected_handle:?}, got {other:?}" + ), + } + } +}
diff --git a/rust/bt-gatt/src/tests.rs b/rust/bt-gatt/src/tests.rs index 9f0bbd7..2a263bd 100644 --- a/rust/bt-gatt/src/tests.rs +++ b/rust/bt-gatt/src/tests.rs
@@ -433,3 +433,64 @@ assert_eq!(Handle(1), handle); assert_eq!(&[0, 1, 2, 3, 4, 5], value.expect("should be ok").as_slice()); } + +#[test] +fn fake_server_events_ext_helpers() { + use server::{ + LocalService, NotificationType, ReadResponder, Server, ServiceId, WriteResponder, + }; + let mut noop_cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); + + let (server, mut events) = FakeServer::new(); + events.expect_no_event(); + + let mut local_service_fut = server.prepare(example_service_definition()); + let Poll::Ready(Ok(local_service)) = local_service_fut.poll_unpin(&mut noop_cx) else { + panic!("prepare failed"); + }; + let mut service_event_stream = local_service.publish(); + + // Published event + let FakeServerEvent::Published { id, .. } = events.expect_event() else { + panic!("expected published"); + }; + assert_eq!(id, ServiceId::new(1)); + events.expect_no_event(); + + // Read response + server.incoming_read(PeerId(1), ServiceId::new(1), Handle(1), 0); + let Poll::Ready(Some(Ok(ServiceEvent::Read { responder, .. }))) = + service_event_stream.poll_next_unpin(&mut noop_cx) + else { + panic!("expected read"); + }; + responder.respond(&[10, 20, 30]); + let value = events.expect_read_response(Handle(1)).expect("read response ok"); + assert_eq!(value, vec![10, 20, 30]); + events.expect_no_event(); + + // Write response + server.incoming_write(PeerId(1), ServiceId::new(1), Handle(2), 0, vec![1, 2]); + let Poll::Ready(Some(Ok(ServiceEvent::Write { responder, .. }))) = + service_event_stream.poll_next_unpin(&mut noop_cx) + else { + panic!("expected write"); + }; + responder.acknowledge(); + events.expect_write_response(Handle(2)).expect("write response ok"); + events.expect_no_event(); + + // Notification + server.incoming_client_configuration( + PeerId(1), + ServiceId::new(1), + Handle(3), + NotificationType::Notify, + ); + let _ = service_event_stream.poll_next_unpin(&mut noop_cx); + local_service.notify(&Handle(3), &[42], &[]); + let (data, peers) = events.expect_notification(Handle(3)); + assert_eq!(data, vec![42]); + assert_eq!(peers, vec![PeerId(1)]); + events.expect_no_event(); +}
diff --git a/rust/bt-mcs/src/error.rs b/rust/bt-mcs/src/error.rs index d57a6f7..6565d15 100644 --- a/rust/bt-mcs/src/error.rs +++ b/rust/bt-mcs/src/error.rs
@@ -4,11 +4,19 @@ use thiserror::Error; +use crate::types::PlayingOrder; + #[derive(Debug, Error)] pub enum Error { #[error("Service is already published")] AlreadyPublished, + #[error("Feature not supported or configured on this server: {0}")] + FeatureNotSupported(&'static str), + + #[error("Playing order {0:?} is not supported by this server")] + UnsupportedPlayingOrder(PlayingOrder), + #[error("GATT operation error: {0}")] Gatt(#[from] bt_gatt::types::Error),
diff --git a/rust/bt-mcs/src/server.rs b/rust/bt-mcs/src/server.rs index 658631c..98e6df0 100644 --- a/rust/bt-mcs/src/server.rs +++ b/rust/bt-mcs/src/server.rs
@@ -285,18 +285,143 @@ Ok(()) } - #[cfg(test)] - pub(crate) fn set_media_state(&mut self, state: MediaState) { - self.state.set_media_state(state); + fn notify_track_changed(&mut self) { + self.local_service.notify(&TRACK_TITLE_HANDLE, self.state.track_title.as_bytes(), &[]); + self.local_service.notify( + &TRACK_DURATION_HANDLE, + &self.state.track_duration.to_notify_value(), + &[], + ); + self.local_service.notify( + &TRACK_POSITION_HANDLE, + &self.state.track_position.to_notify_value(), + &[], + ); + // Characteristic updates must precede Track Changed notification (MCS + // v1.0.1 Section 3.4.1). + self.local_service.notify(&TRACK_CHANGED_HANDLE, &[], &[]); } - #[cfg(test)] - pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) { - self.state.set_supported_opcodes(opcodes); + fn notify_current_track_position(&mut self) { + let raw_pos = self.state.current_track_position().to_notify_value(); + self.local_service.notify(&TRACK_POSITION_HANDLE, &raw_pos, &[]); + } + + /// Sets the current track metadata and dispatches track update + /// notifications. + pub fn set_track( + &mut self, + title: impl Into<String>, + duration: TrackDuration, + position: TrackPosition, + ) { + self.state.set_track(title.into(), duration, position); + self.notify_track_changed(); + } + + /// Clears the current track metadata and dispatches track update + /// notifications. + pub fn clear_track(&mut self) { + self.state.clear_track(); + self.notify_track_changed(); + } + + /// Sets the current playback state and dispatches state update + /// notifications. + pub fn set_media_state(&mut self, state: MediaState) { + if self.state.media_state == state { + return; + } + let previous_state = self.state.media_state; + self.state.set_media_state(state); + self.local_service.notify(&MEDIA_STATE_HANDLE, &[state.into()], &[]); + // Notify track position when paused from active playback or seeking, + // when entering seeking, or when transitioning to inactive (MCS + // v1.0.1 Section 3.7.1). + if (state == MediaState::Paused + && (previous_state == MediaState::Playing || previous_state == MediaState::Seeking)) + || state == MediaState::Seeking + || (state == MediaState::Inactive && previous_state != MediaState::Inactive) + { + self.notify_current_track_position(); + } + } + + /// Sets the playback position within the current track and dispatches + /// position notifications. + pub fn set_track_position(&mut self, position: TrackPosition) { + self.state.set_track_position(position); + self.local_service.notify( + &TRACK_POSITION_HANDLE, + &self.state.track_position.to_notify_value(), + &[], + ); + } + + /// Sets the media player name and dispatches name update notifications. + pub fn set_player_name(&mut self, name: impl Into<String>) { + let name_str = name.into(); + self.state.set_player_name(name_str.clone()); + self.local_service.notify(&MEDIA_PLAYER_NAME_HANDLE, name_str.as_bytes(), &[]); + } + + /// Sets the media player icon URL. + pub fn set_icon_url(&mut self, url: impl Into<String>) -> Result<(), Error> { + self.state.set_icon_url(url.into()) + } + + /// Sets the playback speed and dispatches speed update notifications. + pub fn set_playback_speed(&mut self, speed: PlaybackSpeed) -> Result<(), Error> { + self.state.set_playback_speed(speed)?; + self.local_service.notify(&PLAYBACK_SPEED_HANDLE, &[speed.into()], &[]); + // Notify track position when playback speed changes during active + // playback (MCS v1.0.1 Section 3.7.1). + if self.state.media_state == MediaState::Playing { + self.notify_current_track_position(); + } + Ok(()) + } + + /// Sets the seeking speed and dispatches seeking speed update + /// notifications. + pub fn set_seeking_speed(&mut self, speed: SeekingSpeed) -> Result<(), Error> { + self.state.set_seeking_speed(speed)?; + self.local_service.notify(&SEEKING_SPEED_HANDLE, &[speed.into()], &[]); + // Notify track position if seeking speed is updated while seeking (MCS + // v1.0.1 Section 3.7.1, 3.9). + if self.state.media_state == MediaState::Seeking { + self.notify_current_track_position(); + } + Ok(()) + } + + /// Sets the playing order and dispatches playing order update + /// notifications. + pub fn set_playing_order(&mut self, order: PlayingOrder) -> Result<(), Error> { + self.state.set_playing_order(order)?; + self.local_service.notify(&PLAYING_ORDER_HANDLE, &[order.into()], &[]); + Ok(()) + } + + /// Sets the supported media control opcodes and dispatches opcode update + /// notifications. + pub fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) -> Result<(), Error> { + self.state.set_supported_opcodes(opcodes)?; + self.local_service.notify( + &MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, + &opcodes.bits().to_le_bytes(), + &[], + ); + Ok(()) } } impl<'a, T: bt_gatt::ServerTypes> McsServerProj<'a, T> { + fn notify_current_track_position(&mut self) { + let raw_pos = self.state.current_track_position().to_notify_value(); + self.local_service.notify(&TRACK_POSITION_HANDLE, &raw_pos, &[]); + } + fn handle_read<R: ReadResponder>(&self, handle: Handle, offset: usize, responder: R) { match self.state.handle_read(handle, offset) { Ok(bytes) => responder.respond(&bytes), @@ -470,17 +595,25 @@ self.state.set_track_position(position); self.local_service.notify( &TRACK_POSITION_HANDLE, - &position.raw_10ms().to_le_bytes(), + &self.state.track_position.to_notify_value(), &[], ); } PendingWriteResponse::PlaybackSpeed(speed) => { - self.state.set_playback_speed(speed); - self.local_service.notify(&PLAYBACK_SPEED_HANDLE, &[speed.into()], &[]); + if self.state.set_playback_speed(speed).is_ok() { + self.local_service.notify(&PLAYBACK_SPEED_HANDLE, &[speed.into()], &[]); + // Notify track position when playback speed changes during + // active playback (MCS v1.0.1 Section + // 3.7.1). + if self.state.media_state == MediaState::Playing { + self.notify_current_track_position(); + } + } } PendingWriteResponse::PlayingOrder(order) => { - self.state.set_playing_order(order); - self.local_service.notify(&PLAYING_ORDER_HANDLE, &[order.into()], &[]); + if self.state.set_playing_order(order).is_ok() { + self.local_service.notify(&PLAYING_ORDER_HANDLE, &[order.into()], &[]); + } } } } @@ -544,7 +677,7 @@ use super::*; use assert_matches::assert_matches; - use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeTypes}; + use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeServerEventsExt, FakeTypes}; use bt_gatt::Characteristic; use futures::{FutureExt, StreamExt}; @@ -808,25 +941,6 @@ assert_matches!(poll_result, Poll::Ready(None)); } - #[track_caller] - fn expect_service_event( - events: &mut futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>, - ) -> FakeServerEvent { - let mut cx = Context::from_waker(futures::task::noop_waker_ref()); - match events.poll_next_unpin(&mut cx) { - Poll::Ready(Some(event)) => event, - x => panic!("Expected fake server event, got {x:?}"), - } - } - - #[track_caller] - fn expect_no_service_event( - events: &mut futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>, - ) { - let mut cx = Context::from_waker(futures::task::noop_waker_ref()); - assert_matches!(events.poll_next_unpin(&mut cx), Poll::Pending); - } - fn setup_test_server( builder: McsServerBuilder, ) -> ( @@ -839,10 +953,7 @@ 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!( - expect_service_event(&mut event_receiver), - FakeServerEvent::Published { .. } - )); + assert!(matches!(event_receiver.expect_event(), FakeServerEvent::Published { .. })); (server, fake_gatt_server, event_receiver) } @@ -866,7 +977,7 @@ bt_gatt::server::NotificationType::Notify, ); let _ = server.next().poll_unpin(&mut noop_cx); - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); (server, fake_gatt_server, event_receiver, noop_cx) } @@ -890,11 +1001,7 @@ let poll_result = server.next().poll_unpin(&mut noop_cx); // Must NOT emit any McsServerEvent. assert_matches!(poll_result, Poll::Pending); - let bt_gatt::test_utils::FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded for handle {:?}", handle); - }; + let value = event_receiver.expect_write_response(handle); assert_matches!( value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidHandle), @@ -921,7 +1028,7 @@ bt_gatt::server::NotificationType::Notify, ); let _ = server.next().poll_unpin(&mut noop_cx); - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); fake_gatt_server.incoming_write( peer, @@ -943,12 +1050,7 @@ assert_eq!(peer_id, peer); assert_eq!(position, TrackPosition::from_raw_10ms(4200)); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(TRACK_POSITION_HANDLE).unwrap(); // Upper layer confirms the set track position. responder.send(position); @@ -957,24 +1059,15 @@ // notification. let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, TRACK_POSITION_HANDLE); + let (value, peers) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, 4200i32.to_le_bytes().to_vec()); // Subsequent read reflects the updated track position fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), 4200i32.to_le_bytes()); + let value = event_receiver.expect_read_response(TRACK_POSITION_HANDLE).unwrap(); + assert_eq!(value, 4200i32.to_le_bytes()); } #[test] @@ -996,11 +1089,7 @@ ); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; + let value = event_receiver.expect_write_response(TRACK_POSITION_HANDLE); assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); // Invalid value length (3 bytes instead of 4) @@ -1013,11 +1102,7 @@ ); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; + let value = event_receiver.expect_write_response(TRACK_POSITION_HANDLE); assert_matches!( value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidAttributeValueLength) @@ -1040,7 +1125,7 @@ bt_gatt::server::NotificationType::Notify, ); let _ = server.next().poll_unpin(&mut noop_cx); - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); fake_gatt_server.incoming_write( peer, @@ -1059,35 +1144,21 @@ assert_eq!(peer_id, peer); assert_eq!(speed, PlaybackSpeed::HALF); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(PLAYBACK_SPEED_HANDLE).unwrap(); // Upper layer confirms playback speed responder.send(speed); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, PLAYBACK_SPEED_HANDLE); + let (value, peers) = event_receiver.expect_notification(PLAYBACK_SPEED_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![-64i8 as u8]); // Subsequent read reflects the updated playback speed fake_gatt_server.incoming_read(peer, service_id, PLAYBACK_SPEED_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), vec![-64i8 as u8]); + let value = event_receiver.expect_read_response(PLAYBACK_SPEED_HANDLE).unwrap(); + assert_eq!(value, vec![-64i8 as u8]); } #[test] @@ -1103,11 +1174,7 @@ fake_gatt_server.incoming_write(peer, service_id, PLAYBACK_SPEED_HANDLE, 1, vec![0x00]); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; + let value = event_receiver.expect_write_response(PLAYBACK_SPEED_HANDLE); assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); // Invalid value length (2 bytes instead of 1) @@ -1120,11 +1187,7 @@ ); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; + let value = event_receiver.expect_write_response(PLAYBACK_SPEED_HANDLE); assert_matches!( value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidAttributeValueLength) @@ -1149,7 +1212,7 @@ bt_gatt::server::NotificationType::Notify, ); let _ = server.next().poll_unpin(&mut noop_cx); - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); fake_gatt_server.incoming_write( peer, @@ -1168,35 +1231,21 @@ assert_eq!(peer_id, peer); assert_eq!(order, PlayingOrder::ShuffleOnce); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(PLAYING_ORDER_HANDLE).unwrap(); // Upper layer confirms playing order responder.send(order); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, PLAYING_ORDER_HANDLE); + let (value, peers) = event_receiver.expect_notification(PLAYING_ORDER_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![PlayingOrder::ShuffleOnce.into()]); // Subsequent read reflects the updated playing order fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), vec![PlayingOrder::ShuffleOnce.into()]); + let value = event_receiver.expect_read_response(PLAYING_ORDER_HANDLE).unwrap(); + assert_eq!(value, vec![PlayingOrder::ShuffleOnce.into()]); } #[test] @@ -1223,22 +1272,13 @@ let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(PLAYING_ORDER_HANDLE).unwrap(); // Value remains unchanged (InOrderOnce) fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), vec![PlayingOrder::InOrderOnce.into()]); + let value = event_receiver.expect_read_response(PLAYING_ORDER_HANDLE).unwrap(); + assert_eq!(value, vec![PlayingOrder::InOrderOnce.into()]); } #[test] @@ -1267,11 +1307,7 @@ fake_gatt_server.incoming_write(peer, service_id, handle, 0, value); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded for handle {:?}", handle); - }; + let value = event_receiver.expect_write_response(handle); assert_matches!( value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::WriteNotPermitted), @@ -1301,21 +1337,11 @@ assert_matches!(poll_result, Poll::Pending); // ATT write is acknowledged - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); // Immediate Media Control Point Notification sent with // OPCODE_NOT_SUPPORTED (0x02) - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + let (value, peers) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![0xEE, ControlPointResultCode::OpcodeNotSupported.into()]); } @@ -1340,21 +1366,11 @@ assert_matches!(poll_result, Poll::Pending); // ATT write is acknowledged - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); // Immediate Media Control Point Notification sent with // MEDIA_PLAYER_INACTIVE (0x03) - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + let (value, peers) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![0x01, ControlPointResultCode::MediaPlayerInactive.into()]); } @@ -1366,6 +1382,8 @@ let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = setup_test_server_with_control_point(peer); server.set_media_state(MediaState::Playing); + let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(val, vec![MediaState::Playing.into()]); // Write Pause opcode (0x02) fake_gatt_server.incoming_write( @@ -1385,12 +1403,7 @@ assert_eq!(opcode, MediaControlOpcode::Pause); // ATT write is acknowledged - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); // Upper layer executes command and responds with Success responder.send(ControlPointResultCode::Success); @@ -1398,12 +1411,7 @@ // Polling the server dispatches the notification let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + let (value, peers) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![0x02, ControlPointResultCode::Success.into()]); } @@ -1415,6 +1423,8 @@ let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = setup_test_server_with_control_point(peer); server.set_media_state(MediaState::Playing); + let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(val, vec![MediaState::Playing.into()]); // MoveRelative opcode with 4-byte offset parameter let opcode = MediaControlOpcode::MoveRelative(1500); @@ -1433,22 +1443,12 @@ }; assert_eq!(rx_opcode, opcode); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); responder.send(ControlPointResultCode::Success); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + let (value, peers) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![opcode.raw_opcode(), ControlPointResultCode::Success.into()]); } @@ -1460,6 +1460,8 @@ let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = setup_test_server_with_control_point(peer); server.set_media_state(MediaState::Playing); + let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(val, vec![MediaState::Playing.into()]); // MoveRelative requires 5 octets; provide only 2 octets let raw_op = MediaControlOpcode::MoveRelative(0).raw_opcode(); @@ -1474,19 +1476,9 @@ let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + let (value, peers) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![raw_op, ControlPointResultCode::OpcodeNotSupported.into()]); } @@ -1508,22 +1500,14 @@ ); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; + let value = event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE); assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset)); // Empty value returns InvalidAttributeValueLength fake_gatt_server.incoming_write(peer, service_id, MEDIA_CONTROL_POINT_HANDLE, 0, vec![]); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; + let value = event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE); assert_matches!( value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidAttributeValueLength) @@ -1537,7 +1521,15 @@ let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = setup_test_server_with_control_point(peer); server.set_media_state(MediaState::Playing); - server.set_supported_opcodes(SupportedOpcodes::PLAY); + let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(val, vec![MediaState::Playing.into()]); + server + .set_supported_opcodes(SupportedOpcodes::PLAY) + .expect("set_supported_opcodes succeeds"); + let (val, peers) = + event_receiver.expect_notification(MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE); + assert_eq!(val, SupportedOpcodes::PLAY.bits().to_le_bytes().to_vec()); + assert_eq!(peers, vec![peer]); // Pause is valid in spec, but not in supported_opcodes for this server // instance @@ -1552,19 +1544,9 @@ let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + let (value, peers) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!( value, @@ -1582,6 +1564,8 @@ let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = setup_test_server_with_control_point(peer); server.set_media_state(MediaState::Playing); + let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(val, vec![MediaState::Playing.into()]); // Write Stop opcode (0x05) fake_gatt_server.incoming_write( @@ -1601,12 +1585,7 @@ assert_eq!(opcode, MediaControlOpcode::Stop); // ATT write is acknowledged - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); // Responder is dropped without calling send drop(responder); @@ -1614,12 +1593,7 @@ // Polling the server dispatches the fallback notification let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + let (value, peers) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![0x05, ControlPointResultCode::CommandCannotBeCompleted.into()]); } @@ -1632,6 +1606,8 @@ let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = setup_test_server_with_control_point(peer1); server.set_media_state(MediaState::Playing); + let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(val, vec![MediaState::Playing.into()]); // Also configure notifications for peer2 fake_gatt_server.incoming_client_configuration( @@ -1641,7 +1617,7 @@ bt_gatt::server::NotificationType::Notify, ); let _ = server.next().poll_unpin(&mut noop_cx); - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); // Issue 1st command (Play from peer1) fake_gatt_server.incoming_write( @@ -1682,19 +1658,8 @@ assert_eq!(op2, MediaControlOpcode::NextTrack); // Drain write acknowledgements - let FakeServerEvent::WriteResponded { value: val1, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected 1st WriteResponded"); - }; - assert!(val1.is_ok()); - - let FakeServerEvent::WriteResponded { value: val2, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected 2nd WriteResponded"); - }; - assert!(val2.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); // Respond to both commands in a row before polling the stream resp1.send(ControlPointResultCode::Success); @@ -1706,17 +1671,10 @@ let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let n1 = expect_service_event(&mut event_receiver); - let n2 = expect_service_event(&mut event_receiver); + let (val1, peers1) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); + let (val2, peers2) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); - let mut received = Vec::new(); - for event in [n1, n2] { - let FakeServerEvent::Notified { handle, value, peers, .. } = event else { - panic!("expected Notified event, got {event:?}"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); - received.push((peers, value)); - } + let received = vec![(peers1, val1), (peers2, val2)]; assert!(received.contains(&( vec![peer1], @@ -1731,7 +1689,7 @@ ))); // No further events pending - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); } #[test] @@ -1741,6 +1699,8 @@ let (mut server, fake_gatt_server, mut event_receiver, mut noop_cx) = setup_test_server_with_control_point(peer); server.set_media_state(MediaState::Playing); + let (val, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(val, vec![MediaState::Playing.into()]); // Write Pause opcode (0x02) fake_gatt_server.incoming_write( @@ -1760,12 +1720,7 @@ assert_eq!(opcode, MediaControlOpcode::Pause); // ATT write is acknowledged - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + event_receiver.expect_write_response(MEDIA_CONTROL_POINT_HANDLE).unwrap(); // Replace local_service events stream with a custom channel and close // it. @@ -1792,12 +1747,7 @@ let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Ready(None)); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, MEDIA_CONTROL_POINT_HANDLE); + let (value, peers) = event_receiver.expect_notification(MEDIA_CONTROL_POINT_HANDLE); assert_eq!(peers, vec![peer]); assert_eq!(value, vec![0x02, ControlPointResultCode::Success.into()]); } @@ -1807,6 +1757,7 @@ let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); server.state.media_state = MediaState::Paused; + server.state.track_duration = TrackDuration::from_raw_10ms(3000); // 30s let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); let peer = PeerId(1); @@ -1819,7 +1770,7 @@ bt_gatt::server::NotificationType::Notify, ); let _ = server.next().poll_unpin(&mut noop_cx); - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); // Negative value represents offset from end of track per MCS v1.0.1 // Section 3.7.1 @@ -1843,35 +1794,26 @@ assert_eq!(peer_id, peer); assert_eq!(position, TrackPosition::from_raw_10ms(-500)); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + assert_matches!(event_receiver.expect_write_response(TRACK_POSITION_HANDLE), Ok(())); // Upper layer confirms the updated track position responder.send(position); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::Notified { handle, value, peers, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected Notified"); - }; - assert_eq!(handle, TRACK_POSITION_HANDLE); + let (value, peers) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); assert_eq!(peers, vec![peer]); - assert_eq!(value, (-500i32).to_le_bytes().to_vec()); + // MCS Section 3.7.1: The characteristic value shall be set to the + // offset from the start of the track to the new playing + // position (3000 - 500 = 2500). + assert_eq!(value, (2500i32).to_le_bytes().to_vec()); // Subsequent read reflects the updated track position fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), (-500i32).to_le_bytes()); + let value = event_receiver + .expect_read_response(TRACK_POSITION_HANDLE) + .expect("read response succeeds"); + assert_eq!(value, (2500i32).to_le_bytes()); } #[test] @@ -1894,29 +1836,22 @@ panic!("expected SetPlaybackSpeed event, got {poll_result:?}"); }; // ATT write is acknowledged - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + assert_matches!(event_receiver.expect_write_response(PLAYBACK_SPEED_HANDLE), Ok(())); // Drop responder without calling send drop(responder); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); // No notification should be emitted - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); // Playback speed remains unchanged fake_gatt_server.incoming_read(peer, service_id, PLAYBACK_SPEED_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), vec![PlaybackSpeed::NORMAL.into()]); + let value = event_receiver + .expect_read_response(PLAYBACK_SPEED_HANDLE) + .expect("read response succeeds"); + assert_eq!(value, vec![PlaybackSpeed::NORMAL.into()]); // 2. Track position write fake_gatt_server.incoming_write( @@ -1931,27 +1866,20 @@ else { panic!("expected SetTrackPosition event, got {poll_result:?}"); }; - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + assert_matches!(event_receiver.expect_write_response(TRACK_POSITION_HANDLE), Ok(())); drop(responder); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); // Track position remains unavailable fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), TrackPosition::Unavailable.raw_10ms().to_le_bytes()); + let value = event_receiver + .expect_read_response(TRACK_POSITION_HANDLE) + .expect("read response succeeds"); + assert_eq!(value, TrackPosition::Unavailable.raw_10ms().to_le_bytes()); // 3. Playing order write fake_gatt_server.incoming_write( @@ -1966,27 +1894,20 @@ else { panic!("expected SetPlayingOrder event, got {poll_result:?}"); }; - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + assert_matches!(event_receiver.expect_write_response(PLAYING_ORDER_HANDLE), Ok(())); drop(responder); let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - expect_no_service_event(&mut event_receiver); + event_receiver.expect_no_event(); // Playing order remains default InOrderOnce fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected ReadResponded"); - }; - assert_eq!(value.unwrap(), vec![PlayingOrder::InOrderOnce.into()]); + let value = event_receiver + .expect_read_response(PLAYING_ORDER_HANDLE) + .expect("read response succeeds"); + assert_eq!(value, vec![PlayingOrder::InOrderOnce.into()]); } #[test] @@ -2006,21 +1927,393 @@ let poll_result = server.next().poll_unpin(&mut noop_cx); assert_matches!(poll_result, Poll::Pending); - let FakeServerEvent::WriteResponded { value, .. } = - expect_service_event(&mut event_receiver) - else { - panic!("expected WriteResponded"); - }; - assert!(value.is_ok()); + assert_matches!(event_receiver.expect_write_response(PLAYING_ORDER_HANDLE), Ok(())); // Value remains unchanged (default InOrderOnce) fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); let _ = server.next().poll_unpin(&mut noop_cx); - let FakeServerEvent::ReadResponded { value, .. } = - expect_service_event(&mut event_receiver) + let value = event_receiver + .expect_read_response(PLAYING_ORDER_HANDLE) + .expect("read response succeeds"); + assert_eq!(value, vec![PlayingOrder::InOrderOnce.into()]); + } + + #[test] + fn pending_write_response_rejected_by_state_does_not_notify() { + let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( + McsServerBuilder::generic(0x42, "Test Player") + .with_playing_orders(SupportedPlayingOrders::SINGLE_ONCE), + ); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + PLAYING_ORDER_HANDLE, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + event_receiver.expect_no_event(); + + fake_gatt_server.incoming_write( + peer, + service_id, + PLAYING_ORDER_HANDLE, + 0, + vec![PlayingOrder::SingleOnce.into()], + ); + let poll_result = server.next().poll_unpin(&mut noop_cx); + let Poll::Ready(Some(Ok(McsServerEvent::SetPlayingOrder { responder, .. }))) = poll_result else { - panic!("expected ReadResponded"); + panic!("expected SetPlayingOrder event, got {poll_result:?}"); }; - assert_eq!(value.unwrap(), vec![PlayingOrder::InOrderOnce.into()]); + assert_matches!(event_receiver.expect_write_response(PLAYING_ORDER_HANDLE), Ok(())); + + // Upper layer attempts to set an unsupported playing order. + responder.send(PlayingOrder::ShuffleOnce); + let poll_result = server.next().poll_unpin(&mut noop_cx); + assert_matches!(poll_result, Poll::Pending); + // State rejected the update, so no notification should be emitted. + event_receiver.expect_no_event(); + + fake_gatt_server.incoming_read(peer, service_id, PLAYING_ORDER_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let value = event_receiver + .expect_read_response(PLAYING_ORDER_HANDLE) + .expect("read response succeeds"); + assert_eq!(value, vec![PlayingOrder::InOrderOnce.into()]); + } + + #[test] + fn public_api_set_and_clear_track() { + let (mut server, fake_gatt_server, mut event_receiver) = + setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + for handle in + [TRACK_CHANGED_HANDLE, TRACK_TITLE_HANDLE, TRACK_DURATION_HANDLE, TRACK_POSITION_HANDLE] + { + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + handle, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + event_receiver.expect_no_event(); + } + + // Set track + server.set_track( + "Song Title", + TrackDuration::from_raw_10ms(3000), + TrackPosition::from_raw_10ms(0), + ); + + // Verify notifications per MCS Section 3.4.1 / 3.7.1 + let (val, _) = event_receiver.expect_notification(TRACK_TITLE_HANDLE); + assert_eq!(val, b"Song Title"); + let (val, _) = event_receiver.expect_notification(TRACK_DURATION_HANDLE); + assert_eq!(val, 3000i32.to_le_bytes()); + let (val, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + assert_eq!(val, 0i32.to_le_bytes()); + let (val, _) = event_receiver.expect_notification(TRACK_CHANGED_HANDLE); + assert_eq!(val, vec![]); + event_receiver.expect_no_event(); + + // Clear track + server.clear_track(); + + let (val, _) = event_receiver.expect_notification(TRACK_TITLE_HANDLE); + assert_eq!(val, vec![]); + let (val, _) = event_receiver.expect_notification(TRACK_DURATION_HANDLE); + assert_eq!(val, TrackDuration::Unknown.raw_10ms().to_le_bytes()); + let (val, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + assert_eq!(val, TrackPosition::Unavailable.raw_10ms().to_le_bytes()); + let (val, _) = event_receiver.expect_notification(TRACK_CHANGED_HANDLE); + assert_eq!(val, vec![]); + event_receiver.expect_no_event(); + } + + #[test] + fn public_api_set_media_state_and_track_position() { + let (mut server, fake_gatt_server, mut event_receiver) = + setup_test_server(McsServerBuilder::generic(0x42, "Test Player")); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + for handle in [MEDIA_STATE_HANDLE, TRACK_POSITION_HANDLE] { + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + handle, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + event_receiver.expect_no_event(); + } + + server.set_media_state(MediaState::Playing); + let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(value, vec![MediaState::Playing.into()]); + + server.set_track_position(TrackPosition::from_raw_10ms(1500)); + let (value, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + assert_eq!(value, 1500i32.to_le_bytes().to_vec()); + + // Pausing from Playing emits both MediaState and TrackPosition + // notifications per MCS §3.7.1 + server.set_media_state(MediaState::Paused); + let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(value, vec![MediaState::Paused.into()]); + + let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + + // Setting same MediaState is a no-op and produces no notifications per + // MCS v1.0.1 Section 3.17.1. + server.set_media_state(MediaState::Paused); + event_receiver.expect_no_event(); + + // Transitioning to Inactive emits MediaState and + // TrackPosition::Unavailable per MCS Section 3.7.1. + server.set_media_state(MediaState::Inactive); + let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(value, vec![MediaState::Inactive.into()]); + + let (value, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + assert_eq!(value, TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec()); + + // Repeated transition to Inactive produces no notifications. + server.set_media_state(MediaState::Inactive); + event_receiver.expect_no_event(); + + // Transition back to Paused with a track to test FromEnd position + // resolving (MCS Section 3.7.1). + server.set_media_state(MediaState::Paused); + let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(value, vec![MediaState::Paused.into()]); + + server.set_track( + "Track", + TrackDuration::from_raw_10ms(3000), + TrackPosition::from_raw_10ms(0), + ); + let _ = event_receiver.expect_notification(TRACK_TITLE_HANDLE); + let _ = event_receiver.expect_notification(TRACK_DURATION_HANDLE); + let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + let _ = event_receiver.expect_notification(TRACK_CHANGED_HANDLE); + + server.set_track_position(TrackPosition::from_end(std::time::Duration::from_millis(5000))); + let (value, _) = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + // Duration is 3000 (30.0s), 5.0s from end is 25.0s = 2500 raw units, + // not -500. + assert_eq!(value, 2500i32.to_le_bytes().to_vec()); + } + + #[test] + fn public_api_media_identity() { + let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( + McsServerBuilder::generic(0x42, "Old Name") + .with_icon_url("https://example.com/old.png"), + ); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + MEDIA_PLAYER_NAME_HANDLE, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + event_receiver.expect_no_event(); + + server.set_player_name("New Name"); + let (value, _) = event_receiver.expect_notification(MEDIA_PLAYER_NAME_HANDLE); + assert_eq!(value, b"New Name"); + + server.set_icon_url("https://example.com/new.png").expect("set_icon_url succeeds"); + // Icon URL is Read-only, so verify value via GATT read response + fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_ICON_URL_HANDLE, 0); + let _ = server.next().poll_unpin(&mut noop_cx); + let value = event_receiver + .expect_read_response(MEDIA_PLAYER_ICON_URL_HANDLE) + .expect("read response succeeds"); + assert_eq!(value, b"https://example.com/new.png"); + + // Test server without icon URL returns FeatureNotSupported + let (mut server_no_icon, _, _) = + setup_test_server(McsServerBuilder::generic(0x43, "Player")); + assert_matches!( + server_no_icon.set_icon_url("https://example.com"), + Err(Error::FeatureNotSupported("icon_url")) + ); + } + + #[test] + fn public_api_playback_speed_and_seeking_speed() { + let (mut server, fake_gatt_server, mut event_receiver) = + setup_test_server(McsServerBuilder::generic(0x42, "Test Player").with_player_speeds()); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + for handle in + [MEDIA_STATE_HANDLE, TRACK_POSITION_HANDLE, PLAYBACK_SPEED_HANDLE, SEEKING_SPEED_HANDLE] + { + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + handle, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + event_receiver.expect_no_event(); + } + + server.set_playback_speed(PlaybackSpeed::DOUBLE).expect("set_playback_speed succeeds"); + let (value, _) = event_receiver.expect_notification(PLAYBACK_SPEED_HANDLE); + assert_eq!(value, vec![PlaybackSpeed::DOUBLE.into()]); + + // During active playback, setting playback speed emits both + // PlaybackSpeed and TrackPosition notifications (MCS v1.0.1 + // Section 3.7.1). + server.set_media_state(MediaState::Playing); + let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(value, vec![MediaState::Playing.into()]); + server.set_playback_speed(PlaybackSpeed::HALF).expect("set_playback_speed succeeds"); + let (value, _) = event_receiver.expect_notification(PLAYBACK_SPEED_HANDLE); + assert_eq!(value, vec![PlaybackSpeed::HALF.into()]); + + let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + + // Transition to Seeking emits MediaState and TrackPosition + // notifications + server.set_media_state(MediaState::Seeking); + let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(value, vec![MediaState::Seeking.into()]); + let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + + // During seeking, setting seeking speed emits both SeekingSpeed and + // TrackPosition + server.set_seeking_speed(SeekingSpeed::new(2)).expect("set_seeking_speed succeeds"); + let (value, _) = event_receiver.expect_notification(SEEKING_SPEED_HANDLE); + assert_eq!(value, vec![SeekingSpeed::new(2).into()]); + + let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + + // Pausing from Seeking emits both MediaState and TrackPosition + // notifications + server.set_media_state(MediaState::Paused); + let (value, _) = event_receiver.expect_notification(MEDIA_STATE_HANDLE); + assert_eq!(value, vec![MediaState::Paused.into()]); + let _ = event_receiver.expect_notification(TRACK_POSITION_HANDLE); + + // Test server without player speeds returns FeatureNotSupported + let (mut server_no_speeds, _, _) = + setup_test_server(McsServerBuilder::generic(0x43, "Player")); + assert_matches!( + server_no_speeds.set_playback_speed(PlaybackSpeed::DOUBLE), + Err(Error::FeatureNotSupported("playback_speed")) + ); + assert_matches!( + server_no_speeds.set_seeking_speed(SeekingSpeed::new(2)), + Err(Error::FeatureNotSupported("seeking_speed")) + ); + } + + #[test] + fn public_api_playing_order_and_supported_opcodes() { + let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server( + McsServerBuilder::generic(0x42, "Test Player") + .with_playing_orders( + SupportedPlayingOrders::IN_ORDER_ONCE | SupportedPlayingOrders::SHUFFLE_ONCE, + ) + .with_supported_operations(SupportedOpcodes::PLAY), + ); + let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref()); + + let peer = PeerId(1); + let service_id = ServiceId::new(0x42); + + for handle in [PLAYING_ORDER_HANDLE, MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE] { + fake_gatt_server.incoming_client_configuration( + peer, + service_id, + handle, + bt_gatt::server::NotificationType::Notify, + ); + let _ = server.next().poll_unpin(&mut noop_cx); + event_receiver.expect_no_event(); + } + + // Supported order succeeds and notifies + server.set_playing_order(PlayingOrder::ShuffleOnce).expect("set_playing_order succeeds"); + let (value, _) = event_receiver.expect_notification(PLAYING_ORDER_HANDLE); + assert_eq!(value, vec![PlayingOrder::ShuffleOnce.into()]); + + // Unsupported order returns Error and does not notify + assert_matches!( + server.set_playing_order(PlayingOrder::SingleRepeat), + Err(Error::UnsupportedPlayingOrder(PlayingOrder::SingleRepeat)) + ); + event_receiver.expect_no_event(); + + // Update supported opcodes + let new_opcodes = SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE; + server.set_supported_opcodes(new_opcodes).expect("set_supported_opcodes succeeds"); + let (value, _) = + event_receiver.expect_notification(MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE); + assert_eq!(value, new_opcodes.bits().to_le_bytes().to_vec()); + + // Test server without playing orders or control point returns + // FeatureNotSupported + let (mut server_no_optional, _, _) = + setup_test_server(McsServerBuilder::generic(0x43, "Player")); + assert_matches!( + server_no_optional.set_playing_order(PlayingOrder::InOrderOnce), + Err(Error::FeatureNotSupported("playing_orders")) + ); + assert_matches!( + server_no_optional.set_supported_opcodes(SupportedOpcodes::PLAY), + Err(Error::FeatureNotSupported("media_control_point")) + ); + } + + #[test] + fn public_api_unpublished_server_updates_state() { + let mut server = McsServerBuilder::generic(0x42, "Test Player") + .with_icon_url("https://example.com/old.png") + .with_player_speeds() + .with_playing_orders(SupportedPlayingOrders::all()) + .with_supported_operations(SupportedOpcodes::all()) + .build::<FakeTypes>() + .unwrap(); + + server.set_media_state(MediaState::Playing); + server.set_track( + "Title", + TrackDuration::from_raw_10ms(100), + TrackPosition::from_raw_10ms(0), + ); + server.clear_track(); + server.set_track_position(TrackPosition::from_raw_10ms(0)); + server.set_player_name("Name"); + assert_matches!(server.set_icon_url("https://example.com"), Ok(())); + assert_matches!(server.set_playback_speed(PlaybackSpeed::NORMAL), Ok(())); + assert_matches!(server.set_seeking_speed(SeekingSpeed::NOT_SEEKING), Ok(())); + assert_matches!(server.set_playing_order(PlayingOrder::InOrderOnce), Ok(())); + assert_matches!(server.set_supported_opcodes(SupportedOpcodes::PLAY), Ok(())); } }
diff --git a/rust/bt-mcs/src/server/state.rs b/rust/bt-mcs/src/server/state.rs index f802d19..46d3e2a 100644 --- a/rust/bt-mcs/src/server/state.rs +++ b/rust/bt-mcs/src/server/state.rs
@@ -10,6 +10,7 @@ use bt_gatt::Characteristic; use crate::types::*; +use crate::Error; // ============================================================================ // Characteristic Definitions & Handles (MCS v1.0.1 Section 3) @@ -170,62 +171,185 @@ 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) } + (TrackPosition::FromEnd(_), TrackDuration::Unknown) => { + return TrackPosition::Unavailable; + } _ => 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); + // Playback is paused, so position remains fixed at current offset. + if self.media_state == MediaState::Paused { + return TrackPosition::FromStart(base); } - TrackPosition::FromStart(current) + + // 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.track_position = position; - self.position_updated_at = Some(std::time::Instant::now()); + 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) { - 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; - } + 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(()) } - #[cfg(test)] - pub(crate) fn set_media_state(&mut self, state: MediaState) { - self.media_state = state; + /// 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(()) } - #[cfg(test)] - pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) { + /// 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`. @@ -286,11 +410,14 @@ 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_matches!(state.set_playing_order(PlayingOrder::SingleOnce), Ok(())); assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce); - // Unsupported playing order is not applied - state.set_playing_order(PlayingOrder::ShuffleOnce); + // 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); } @@ -511,4 +638,204 @@ 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)); + } }
diff --git a/rust/bt-mcs/src/types.rs b/rust/bt-mcs/src/types.rs index 35594c4..76fec63 100644 --- a/rust/bt-mcs/src/types.rs +++ b/rust/bt-mcs/src/types.rs
@@ -598,6 +598,11 @@ Self::Duration(d) => (d.as_millis() / 10).min(i32::MAX as u128) as i32, } } + + /// Returns the 4-byte little-endian notification payload for this duration. + pub fn to_notify_value(&self) -> [u8; 4] { + self.raw_10ms().to_le_bytes() + } } impl Encodable for TrackDuration { @@ -676,6 +681,11 @@ Self::FromEnd(d) => -((d.as_millis() / 10).min(i32::MAX as u128) as i32), } } + + /// Returns the 4-byte little-endian notification payload for this position. + pub fn to_notify_value(&self) -> [u8; 4] { + self.raw_10ms().to_le_bytes() + } } impl Encodable for TrackPosition { @@ -1301,6 +1311,8 @@ let dur = TrackDuration::from_duration(Duration::from_secs(180)); assert_eq!(dur.raw_10ms(), 18000); + assert_eq!(dur.to_notify_value(), 18000i32.to_le_bytes()); + assert_eq!(TrackDuration::Unknown.to_notify_value(), (-1i32).to_le_bytes()); assert_eq!(TrackDuration::from_raw_10ms(18000), dur); let mut buf = [0u8; 4]; @@ -1321,14 +1333,17 @@ #[test] fn track_position_encode_decode() { assert_eq!(TrackPosition::Unavailable.raw_10ms(), -1); + assert_eq!(TrackPosition::Unavailable.to_notify_value(), (-1i32).to_le_bytes()); 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!(start_pos.to_notify_value(), 150i32.to_le_bytes()); 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!(end_pos.to_notify_value(), (-200i32).to_le_bytes()); assert_eq!(TrackPosition::from_raw_10ms(-200), end_pos); let mut buf = [0u8; 4];