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);
+    }
 }