bt-gatt: Add ConnectedIsochronousTypes and refactor traits

Change-Id: I3d533ada59cc4f97136af07fd0db40c86a6a6964
Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3000
diff --git a/rust/bt-ascs/src/client.rs b/rust/bt-ascs/src/client.rs
index 89cfefc..caff88c 100644
--- a/rust/bt-ascs/src/client.rs
+++ b/rust/bt-ascs/src/client.rs
@@ -8,6 +8,7 @@
 use bt_common::packet_encoding::Decodable;
 use bt_common::Uuid;
 use bt_gatt::client::{CharacteristicNotification, PeerService, ServiceCharacteristic};
+use bt_gatt::connected_isochronous::{CigId, CisId, SduInterval};
 use bt_gatt::types::Handle;
 
 use crate::types::*;
@@ -661,6 +662,7 @@
     use super::*;
     use bt_common::core::CodecId;
     use bt_common::generic_audio::metadata_ltv::Metadata;
+    use bt_gatt::connected_isochronous::{Framing, MaxTransportLatency};
     use bt_gatt::test_utils::{FakePeerService, FakeTypes};
     use bt_gatt::types::{AttributePermissions, CharacteristicProperties, CharacteristicProperty};
     use bt_gatt::Characteristic;
diff --git a/rust/bt-ascs/src/debug.rs b/rust/bt-ascs/src/debug.rs
index c8f176f..d65b023 100644
--- a/rust/bt-ascs/src/debug.rs
+++ b/rust/bt-ascs/src/debug.rs
@@ -7,6 +7,7 @@
 use bt_common::gen_commandset;
 use std::sync::Mutex;
 
+use bt_gatt::connected_isochronous::{CigId, CisId, SduInterval};
 use bt_gatt::{client::PeerServiceHandle, Client};
 
 use crate::client::{AudioStreamControlServiceClient, QosConfigurationRequest};
diff --git a/rust/bt-ascs/src/server.rs b/rust/bt-ascs/src/server.rs
index 6c4ae42..fa8b3f3 100644
--- a/rust/bt-ascs/src/server.rs
+++ b/rust/bt-ascs/src/server.rs
@@ -5,6 +5,7 @@
 use bt_common::core::CodecId;
 use bt_common::generic_audio::metadata_ltv::Metadata;
 use bt_common::{PeerId, Uuid};
+use bt_gatt::connected_isochronous::{CigId, CisId, Framing, MaxTransportLatency};
 use bt_gatt::server::{LocalService, Server, ServiceDefinition, ServiceId};
 use bt_gatt::server::{ReadResponder, WriteResponder};
 use bt_gatt::types::{
diff --git a/rust/bt-ascs/src/tests.rs b/rust/bt-ascs/src/tests.rs
index 60ca82c..36941fb 100644
--- a/rust/bt-ascs/src/tests.rs
+++ b/rust/bt-ascs/src/tests.rs
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 use bt_common::{PeerId, Uuid};
+use bt_gatt::connected_isochronous::{Framing, MaxTransportLatency};
 use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeTypes};
 use bt_gatt::types::Handle;
 
diff --git a/rust/bt-ascs/src/types.rs b/rust/bt-ascs/src/types.rs
index a783bf3..ed8341f 100644
--- a/rust/bt-ascs/src/types.rs
+++ b/rust/bt-ascs/src/types.rs
@@ -12,6 +12,7 @@
 use bt_common::core::ltv::LtValue;
 use bt_common::core::CodecId;
 use bt_common::generic_audio::metadata_ltv::Metadata;
+use bt_gatt::connected_isochronous::{CigId, CisId, Framing, MaxTransportLatency, SduInterval};
 use bt_gatt::types::Error as BtGattError;
 
 /// Error type
@@ -1269,172 +1270,6 @@
     }
 }
 
-#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
-pub struct CigId(u8);
-
-impl CigId {
-    const BYTE_SIZE: usize = 1;
-}
-
-impl TryFrom<u8> for CigId {
-    type Error = bt_common::packet_encoding::Error;
-
-    fn try_from(value: u8) -> Result<Self, Self::Error> {
-        if value > 0xEF {
-            Err(Self::Error::OutOfRange)
-        } else {
-            Ok(CigId(value))
-        }
-    }
-}
-
-impl From<CigId> for u8 {
-    fn from(value: CigId) -> Self {
-        value.0
-    }
-}
-
-impl Encodable for CigId {
-    type Error = bt_common::packet_encoding::Error;
-    fn encoded_len(&self) -> core::primitive::usize {
-        Self::BYTE_SIZE
-    }
-    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
-        if buf.len() < Self::BYTE_SIZE {
-            return Err(Self::Error::BufferTooSmall);
-        }
-        buf[0] = self.0;
-        Ok(())
-    }
-}
-
-#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
-pub struct CisId(u8);
-
-impl CisId {
-    const BYTE_SIZE: usize = 1;
-}
-
-impl TryFrom<u8> for CisId {
-    type Error = bt_common::packet_encoding::Error;
-
-    fn try_from(value: u8) -> Result<Self, Self::Error> {
-        if value > 0xEF {
-            Err(Self::Error::OutOfRange)
-        } else {
-            Ok(CisId(value))
-        }
-    }
-}
-
-impl From<CisId> for u8 {
-    fn from(value: CisId) -> Self {
-        value.0
-    }
-}
-
-impl Encodable for CisId {
-    type Error = bt_common::packet_encoding::Error;
-    fn encoded_len(&self) -> core::primitive::usize {
-        Self::BYTE_SIZE
-    }
-    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
-        if buf.len() < Self::BYTE_SIZE {
-            return Err(Self::Error::BufferTooSmall);
-        }
-        buf[0] = self.0;
-        Ok(())
-    }
-}
-
-/// SDU Inteval parameter
-/// This value is 24 bits long and little-endian on the wire.
-/// It is stored native-endian here.
-/// Valid range is [0x0000FF, 0x0FFFFF].
-#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
-pub struct SduInterval(u32);
-
-impl SduInterval {
-    const BYTE_SIZE: usize = 3;
-}
-
-impl TryFrom<std::time::Duration> for SduInterval {
-    type Error = bt_common::packet_encoding::Error;
-    fn try_from(value: std::time::Duration) -> Result<Self, Self::Error> {
-        let Ok(microseconds) = u32::try_from(value.as_micros()) else {
-            return Err(Self::Error::OutOfRange);
-        };
-        if microseconds < 0xFF || microseconds > 0x0FFFFF {
-            return Err(Self::Error::OutOfRange);
-        }
-        Ok(Self(microseconds))
-    }
-}
-
-impl TryFrom<u32> for SduInterval {
-    type Error = bt_common::packet_encoding::Error;
-    fn try_from(value: u32) -> Result<Self, Self::Error> {
-        if value < 0xFF || value > 0x0FFFFF {
-            return Err(Self::Error::OutOfRange);
-        }
-        Ok(Self(value))
-    }
-}
-
-impl Decodable for SduInterval {
-    type Error = bt_common::packet_encoding::Error;
-
-    fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) {
-        if buf.len() < Self::BYTE_SIZE {
-            return (Err(Self::Error::BufferTooSmall), buf.len());
-        }
-        let val = u32::from_le_bytes([buf[0], buf[1], buf[2], 0]);
-        if (val < 0xFF) || (val > 0x0FFFFF) {
-            return (Err(Self::Error::OutOfRange), Self::BYTE_SIZE);
-        }
-        (Ok(SduInterval(val)), Self::BYTE_SIZE)
-    }
-}
-
-impl Encodable for SduInterval {
-    type Error = bt_common::packet_encoding::Error;
-    fn encoded_len(&self) -> core::primitive::usize {
-        Self::BYTE_SIZE
-    }
-    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
-        if buf.len() < Self::BYTE_SIZE {
-            return Err(Self::Error::BufferTooSmall);
-        }
-        [buf[0], buf[1], buf[2], _] = self.0.to_le_bytes();
-        Ok(())
-    }
-}
-
-decodable_enum! {
-pub enum Framing<u8, bt_common::packet_encoding::Error, OutOfRange> {
-    Unframed = 0x00,
-    Framed = 0x01,
-}
-}
-
-impl Framing {
-    const BYTE_SIZE: usize = 1;
-}
-
-impl Encodable for Framing {
-    type Error = bt_common::packet_encoding::Error;
-    fn encoded_len(&self) -> core::primitive::usize {
-        Self::BYTE_SIZE
-    }
-    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
-        if buf.len() < Self::BYTE_SIZE {
-            return Err(Self::Error::BufferTooSmall);
-        }
-        buf[0] = (*self).into();
-        Ok(())
-    }
-}
-
 /// Max SDU parameter value.
 /// Valid range is 0x0000-0x0FFF
 /// Transmitted in little-endian. Stored here in native-endian.
@@ -1481,59 +1316,6 @@
     }
 }
 
-/// Max Transport Latency
-/// Valid range is [0x0005, 0x0FA0].
-/// Transmitted in little-endian, Stored in native-endian.
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct MaxTransportLatency(u16);
-
-impl Decodable for MaxTransportLatency {
-    type Error = bt_common::packet_encoding::Error;
-
-    fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) {
-        if buf.len() < Self::BYTE_SIZE {
-            return (Err(Self::Error::BufferTooSmall), buf.len());
-        }
-        let val = u16::from_le_bytes([buf[0], buf[1]]);
-        if val < 0x0005 || val > 0x0FA0 {
-            return (Err(Self::Error::OutOfRange), Self::BYTE_SIZE);
-        }
-        (Ok(MaxTransportLatency(val)), Self::BYTE_SIZE)
-    }
-}
-
-impl Encodable for MaxTransportLatency {
-    type Error = bt_common::packet_encoding::Error;
-    fn encoded_len(&self) -> core::primitive::usize {
-        Self::BYTE_SIZE
-    }
-    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
-        if buf.len() < 2 {
-            return Err(Self::Error::BufferTooSmall);
-        }
-        [buf[0], buf[1]] = self.0.to_le_bytes();
-        Ok(())
-    }
-}
-
-impl TryFrom<std::time::Duration> for MaxTransportLatency {
-    type Error = bt_common::packet_encoding::Error;
-
-    fn try_from(value: std::time::Duration) -> Result<Self, Self::Error> {
-        let Ok(milliseconds) = u16::try_from(value.as_millis()) else {
-            return Err(Self::Error::OutOfRange);
-        };
-        if !(0x0005..=0x0FA0).contains(&milliseconds) {
-            return Err(Self::Error::OutOfRange);
-        }
-        Ok(Self(milliseconds))
-    }
-}
-
-impl MaxTransportLatency {
-    const BYTE_SIZE: usize = 2;
-}
-
 /// Presentation delay parameter value being requested by the client for an ASE.
 /// This value is 24 bits long (0x00FFFFFF max)
 /// Transmitted in little-endian, Stored in native-endian.
@@ -1922,7 +1704,10 @@
 
     use std::collections::HashSet;
 
+    use bt_common::core::ltv::LtValue;
     use bt_common::generic_audio::{codec_configuration, AudioLocation};
+    use bt_common::packet_encoding::Encodable;
+    use bt_gatt::connected_isochronous::{CigId, CisId};
 
     #[test]
     fn ase_control_operation_ase_ids() {
@@ -2137,14 +1922,14 @@
 
         assert_eq!(consumed, encoded.len());
         assert_eq!(qos_config.ase_id, AseId(3));
-        assert_eq!(qos_config.cig_id, CigId(0));
-        assert_eq!(qos_config.cis_id, CisId(0));
-        assert_eq!(qos_config.sdu_interval, SduInterval(10000));
+        assert_eq!(qos_config.cig_id, CigId::try_from(0).unwrap());
+        assert_eq!(qos_config.cis_id, CisId::try_from(0).unwrap());
+        assert_eq!(qos_config.sdu_interval, SduInterval::try_from(10000).unwrap());
         assert_eq!(qos_config.framing, Framing::Framed);
         assert_eq!(qos_config.phy, vec![Phy::Le2MPhy]);
         assert_eq!(qos_config.max_sdu, MaxSdu(40));
         assert_eq!(qos_config.retransmission_number, 2);
-        assert_eq!(qos_config.max_transport_latency, MaxTransportLatency(500));
+        assert_eq!(qos_config.max_transport_latency, MaxTransportLatency::try_from(500).unwrap());
         assert_eq!(qos_config.presentation_delay, PresentationDelay { microseconds: 8388632 });
     }
 
diff --git a/rust/bt-gatt/src/central.rs b/rust/bt-gatt/src/central.rs
index 0bf0802..7c9bedf 100644
--- a/rust/bt-gatt/src/central.rs
+++ b/rust/bt-gatt/src/central.rs
@@ -91,4 +91,7 @@
 
     /// Get API for periodic advertising.
     fn periodic_advertising(&self) -> crate::Result<T::PeriodicAdvertising>;
+
+    /// Get API for connected isochronous groups / streams.
+    fn connected_isochronous(&self) -> crate::Result<T::ConnectedIsochronous>;
 }
diff --git a/rust/bt-gatt/src/connected_isochronous.rs b/rust/bt-gatt/src/connected_isochronous.rs
new file mode 100644
index 0000000..c6c4ce2
--- /dev/null
+++ b/rust/bt-gatt/src/connected_isochronous.rs
@@ -0,0 +1,463 @@
+// Copyright 2026 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+//! Contains structs and traits that are used to connect and get information for
+//! Connected Isochronous Groups and Streams.
+
+use bt_common::core::CodecId;
+use bt_common::decodable_enum;
+use bt_common::packet_encoding::{Decodable, Encodable};
+use bt_common::PeerId;
+use futures::future::{ready, Ready};
+use std::future::Future;
+
+decodable_enum! {
+pub enum Framing<u8, bt_common::packet_encoding::Error, OutOfRange> {
+    Unframed = 0x00,
+    Framed = 0x01,
+}
+}
+
+impl Framing {
+    pub const BYTE_SIZE: usize = 1;
+}
+
+impl Encodable for Framing {
+    type Error = bt_common::packet_encoding::Error;
+    fn encoded_len(&self) -> core::primitive::usize {
+        Self::BYTE_SIZE
+    }
+    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
+        if buf.len() < Self::BYTE_SIZE {
+            return Err(Self::Error::BufferTooSmall);
+        }
+        buf[0] = (*self).into();
+        Ok(())
+    }
+}
+
+decodable_enum! {
+pub enum Packing<u8, bt_common::packet_encoding::Error, OutOfRange> {
+    Sequential = 0x00,
+    Interleaved = 0x01,
+}
+}
+
+impl Packing {
+    const BYTE_SIZE: usize = 1;
+}
+
+impl Encodable for Packing {
+    type Error = bt_common::packet_encoding::Error;
+    fn encoded_len(&self) -> core::primitive::usize {
+        Self::BYTE_SIZE
+    }
+    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
+        if buf.len() < Self::BYTE_SIZE {
+            return Err(Self::Error::BufferTooSmall);
+        }
+        buf[0] = (*self).into();
+        Ok(())
+    }
+}
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub struct CisId(u8);
+
+impl CisId {
+    pub const BYTE_SIZE: usize = 1;
+}
+
+impl TryFrom<u8> for CisId {
+    type Error = bt_common::packet_encoding::Error;
+
+    fn try_from(value: u8) -> Result<Self, Self::Error> {
+        if value > 0xEF {
+            Err(Self::Error::OutOfRange)
+        } else {
+            Ok(CisId(value))
+        }
+    }
+}
+
+impl From<CisId> for u8 {
+    fn from(value: CisId) -> Self {
+        value.0
+    }
+}
+
+impl Encodable for CisId {
+    type Error = bt_common::packet_encoding::Error;
+    fn encoded_len(&self) -> core::primitive::usize {
+        Self::BYTE_SIZE
+    }
+    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
+        if buf.len() < Self::BYTE_SIZE {
+            return Err(Self::Error::BufferTooSmall);
+        }
+        buf[0] = self.0;
+        Ok(())
+    }
+}
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub struct CigId(u8);
+
+impl CigId {
+    pub const BYTE_SIZE: usize = 1;
+}
+
+impl TryFrom<u8> for CigId {
+    type Error = bt_common::packet_encoding::Error;
+
+    fn try_from(value: u8) -> Result<Self, Self::Error> {
+        if value > 0xEF {
+            Err(Self::Error::OutOfRange)
+        } else {
+            Ok(CigId(value))
+        }
+    }
+}
+
+impl From<CigId> for u8 {
+    fn from(value: CigId) -> Self {
+        value.0
+    }
+}
+
+impl Encodable for CigId {
+    type Error = bt_common::packet_encoding::Error;
+    fn encoded_len(&self) -> core::primitive::usize {
+        Self::BYTE_SIZE
+    }
+    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
+        if buf.len() < Self::BYTE_SIZE {
+            return Err(Self::Error::BufferTooSmall);
+        }
+        buf[0] = self.0;
+        Ok(())
+    }
+}
+
+/// SDU Interval parameter (in milliseconds)
+/// This value is 24 bits long and little-endian on the wire.
+/// It is stored native-endian here.
+/// Valid range is [0x0000FF, 0x0FFFFF].
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
+pub struct SduInterval(u32);
+
+impl SduInterval {
+    pub const BYTE_SIZE: usize = 3;
+}
+
+impl TryFrom<std::time::Duration> for SduInterval {
+    type Error = bt_common::packet_encoding::Error;
+    fn try_from(value: std::time::Duration) -> Result<Self, Self::Error> {
+        let Ok(microseconds) = u32::try_from(value.as_micros()) else {
+            return Err(Self::Error::OutOfRange);
+        };
+        if microseconds < 0xFF || microseconds > 0x0FFFFF {
+            return Err(Self::Error::OutOfRange);
+        }
+        Ok(Self(microseconds))
+    }
+}
+
+impl TryFrom<u32> for SduInterval {
+    type Error = bt_common::packet_encoding::Error;
+    fn try_from(value: u32) -> Result<Self, Self::Error> {
+        if value < 0xFF || value > 0x0FFFFF {
+            return Err(Self::Error::OutOfRange);
+        }
+        Ok(Self(value))
+    }
+}
+
+impl Decodable for SduInterval {
+    type Error = bt_common::packet_encoding::Error;
+
+    fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) {
+        if buf.len() < Self::BYTE_SIZE {
+            return (Err(Self::Error::BufferTooSmall), buf.len());
+        }
+        let val = u32::from_le_bytes([buf[0], buf[1], buf[2], 0]);
+        if (val < 0xFF) || (val > 0x0FFFFF) {
+            return (Err(Self::Error::OutOfRange), Self::BYTE_SIZE);
+        }
+        (Ok(SduInterval(val)), Self::BYTE_SIZE)
+    }
+}
+
+impl Encodable for SduInterval {
+    type Error = bt_common::packet_encoding::Error;
+    fn encoded_len(&self) -> core::primitive::usize {
+        Self::BYTE_SIZE
+    }
+    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
+        if buf.len() < Self::BYTE_SIZE {
+            return Err(Self::Error::BufferTooSmall);
+        }
+        [buf[0], buf[1], buf[2], _] = self.0.to_le_bytes();
+        Ok(())
+    }
+}
+
+/// Max Transport Latency (in milliseconds)
+/// Valid range is [0x0005, 0x0FA0].
+/// Transmitted in little-endian, Stored in native-endian.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct MaxTransportLatency(u16);
+
+impl Decodable for MaxTransportLatency {
+    type Error = bt_common::packet_encoding::Error;
+
+    fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) {
+        if buf.len() < Self::BYTE_SIZE {
+            return (Err(Self::Error::BufferTooSmall), buf.len());
+        }
+        let val = u16::from_le_bytes([buf[0], buf[1]]);
+        if val < 0x0005 || val > 0x0FA0 {
+            return (Err(Self::Error::OutOfRange), Self::BYTE_SIZE);
+        }
+        (Ok(MaxTransportLatency(val)), Self::BYTE_SIZE)
+    }
+}
+
+impl Encodable for MaxTransportLatency {
+    type Error = bt_common::packet_encoding::Error;
+    fn encoded_len(&self) -> core::primitive::usize {
+        Self::BYTE_SIZE
+    }
+    fn encode(&self, buf: &mut [u8]) -> core::result::Result<(), Self::Error> {
+        if buf.len() < 2 {
+            return Err(Self::Error::BufferTooSmall);
+        }
+        [buf[0], buf[1]] = self.0.to_le_bytes();
+        Ok(())
+    }
+}
+
+impl TryFrom<std::time::Duration> for MaxTransportLatency {
+    type Error = bt_common::packet_encoding::Error;
+
+    fn try_from(value: std::time::Duration) -> Result<Self, Self::Error> {
+        let Ok(milliseconds) = u16::try_from(value.as_millis()) else {
+            return Err(Self::Error::OutOfRange);
+        };
+        Self::try_from(milliseconds)
+    }
+}
+
+impl TryFrom<u16> for MaxTransportLatency {
+    type Error = bt_common::packet_encoding::Error;
+
+    fn try_from(value: u16) -> Result<Self, Self::Error> {
+        if !(0x0005..=0x0FA0).contains(&value) {
+            return Err(Self::Error::OutOfRange);
+        }
+        Ok(Self(value))
+    }
+}
+
+impl MaxTransportLatency {
+    pub const BYTE_SIZE: usize = 2;
+}
+
+pub struct CigParameters {
+    pub sdu_interval_central_to_peripheral: SduInterval,
+    pub sdu_interval_peripheral_to_central: SduInterval,
+    pub packing: Packing,
+    pub framing: Framing,
+    pub max_transport_latency_central_to_peripheral: MaxTransportLatency,
+    pub max_transport_latency_peripheral_to_central: MaxTransportLatency,
+    /// The expected or intended peers to join this CIG, if known. This may be
+    /// used to optimize parameters, but may limit the set of peers that can
+    /// connect to the group.
+    pub expected_peers: Vec<PeerId>,
+}
+
+/// Data Direction
+/// This is always represented in relation to the controller, so:
+/// Input is data that goes from the Host to the Controller (to the peer)
+/// Output is data that goes from the Controller to the Host (from the peer)
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub enum DataDirection {
+    Input,
+    Output,
+}
+
+/// Data Path Id
+/// Non-HCI Data Paths are vendor defined
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub enum DataPathId {
+    Hci,
+    Vendor(core::num::NonZeroU8),
+}
+
+impl DataPathId {
+    pub fn hci() -> Self {
+        Self::Hci
+    }
+
+    pub fn vendor(path_id: core::num::NonZeroU8) -> Self {
+        Self::Vendor(path_id)
+    }
+}
+
+/// Controller Delay
+/// Stored in microseconds, only valid up to 4 seconds.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct ControllerDelay {
+    microseconds: u32,
+}
+
+impl TryFrom<std::time::Duration> for ControllerDelay {
+    type Error = bt_common::packet_encoding::Error;
+
+    fn try_from(duration: std::time::Duration) -> Result<Self, Self::Error> {
+        if duration > Self::MAX_ALLOWED {
+            return Err(bt_common::packet_encoding::Error::OutOfRange);
+        }
+        Ok(Self { microseconds: duration.as_micros().try_into().unwrap() })
+    }
+}
+
+impl From<ControllerDelay> for std::time::Duration {
+    fn from(delay: ControllerDelay) -> Self {
+        Self::from_micros(delay.microseconds.into())
+    }
+}
+
+impl ControllerDelay {
+    // Maximum allowed delay, from Core Spec  Vol 4, Part E 7.8.109
+    const MAX_ALLOWED: std::time::Duration = std::time::Duration::from_secs(4);
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct CisRequestedParameters {
+    pub cis_id: CisId,
+    pub max_sdu_size_outgoing: u16,
+    pub max_sdu_size_incoming: u16,
+}
+
+#[derive(Debug)]
+pub enum ConnectedIsochronousError {
+    NotSupported,
+}
+
+pub trait ConnectedIsochronous: Sized {
+    type Group: ConnectedIsochronousGroup;
+    type InitializeCigFut: Future<
+        Output = std::result::Result<Self::Group, ConnectedIsochronousError>,
+    >;
+
+    /// Initialize an isochronous group with the specified parameters.
+    /// Dropping a ConnectedIsochronous has no side-effects, notably,
+    /// initialized groups are not disconnected.
+    fn initialize(
+        &self,
+        group_parameters: CigParameters,
+        streams_requested: &[CisRequestedParameters],
+    ) -> Self::InitializeCigFut;
+}
+
+pub trait ConnectedIsochronousGroup: Sized {
+    type ConnectedIsochronousStream: ConnectedIsochronousStream;
+    type CreateCisFut: Future<
+        Output = std::result::Result<
+            Vec<Self::ConnectedIsochronousStream>,
+            ConnectedIsochronousError,
+        >,
+    >;
+    type RemoveGroupFut: Future<Output = ()>;
+
+    /// Get the CIG ID that has been assigned to this group. Returns None if the
+    /// Group has not been created yet.
+    fn cig_id(&self) -> CigId;
+
+    /// Create CISes using existing connections to the listed peers.
+    /// Returns a set of streams which are established and ready for data.
+    fn create_streams(&self, peers: &[(CisId, PeerId)]) -> Self::CreateCisFut;
+
+    /// Remove this group. All connected streams will be closed and any
+    /// remaining calls to `create` will fail.  This future will complete
+    /// when the group has been removed and all streams are disconnected.
+    fn remove(&self) -> Self::RemoveGroupFut;
+}
+
+/// Represents a connected isochronous stream.
+/// Streams may be readable and writable using platform-specific methods.
+pub trait ConnectedIsochronousStream: Sized {
+    type SetupDataPathFut: Future<Output = std::result::Result<(), ConnectedIsochronousError>>;
+
+    /// Get the IDs associated with this stream.
+    fn id(&self) -> (CigId, CisId);
+
+    /// Setup a data path for this stream.  This command may be called multiple
+    /// times for each DataDirection.  If it is called multiple times with
+    /// the same direction, the previous data path will be removed before
+    /// the new data path is set up.
+    fn setup_data_path(
+        &self,
+        direction: DataDirection,
+        data_path: DataPathId,
+        codec: CodecId,
+        codec_configuration: &[u8],
+        controller_delay: ControllerDelay,
+    ) -> Self::SetupDataPathFut;
+}
+
+pub struct NotSupported;
+
+impl ConnectedIsochronous for NotSupported {
+    type Group = NotSupported;
+    type InitializeCigFut = Ready<std::result::Result<Self::Group, ConnectedIsochronousError>>;
+
+    fn initialize(
+        &self,
+        _group_parameters: CigParameters,
+        _streams_requested: &[CisRequestedParameters],
+    ) -> Self::InitializeCigFut {
+        ready(Err(ConnectedIsochronousError::NotSupported))
+    }
+}
+
+impl ConnectedIsochronousGroup for NotSupported {
+    type ConnectedIsochronousStream = NotSupported;
+    type CreateCisFut = Ready<
+        std::result::Result<Vec<Self::ConnectedIsochronousStream>, ConnectedIsochronousError>,
+    >;
+    type RemoveGroupFut = Ready<()>;
+
+    fn create_streams(&self, _peers: &[(CisId, PeerId)]) -> Self::CreateCisFut {
+        ready(Err(ConnectedIsochronousError::NotSupported))
+    }
+
+    fn remove(&self) -> Self::RemoveGroupFut {
+        ready(())
+    }
+
+    fn cig_id(&self) -> CigId {
+        unreachable!();
+    }
+}
+
+impl ConnectedIsochronousStream for NotSupported {
+    type SetupDataPathFut = Ready<std::result::Result<(), ConnectedIsochronousError>>;
+
+    fn id(&self) -> (CigId, CisId) {
+        unreachable!();
+    }
+
+    fn setup_data_path(
+        &self,
+        _direction: DataDirection,
+        _data_path: DataPathId,
+        _codec: CodecId,
+        _codec_configuration: &[u8],
+        _controller_delay: ControllerDelay,
+    ) -> Self::SetupDataPathFut {
+        ready(Err(ConnectedIsochronousError::NotSupported))
+    }
+}
diff --git a/rust/bt-gatt/src/lib.rs b/rust/bt-gatt/src/lib.rs
index 3ca4b1a..8163976 100644
--- a/rust/bt-gatt/src/lib.rs
+++ b/rust/bt-gatt/src/lib.rs
@@ -17,6 +17,9 @@
 pub mod periodic_advertising;
 pub mod pii;
 
+pub mod connected_isochronous;
+pub use connected_isochronous::{ConnectedIsochronousGroup, ConnectedIsochronousStream};
+
 #[cfg(any(test, feature = "test-utils"))]
 pub mod test_utils;
 
@@ -61,6 +64,10 @@
     type WriteFut<'a>: Future<Output = Result<()>> + 'a;
     /// The implementation of periodic advertising for this GATT implementation.
     type PeriodicAdvertising: PeriodicAdvertising;
+    /// The implementation of connected isochronous groups.
+    /// Note: The provided `NotSupported` can be used here to indicate that it
+    /// is not supported.
+    type ConnectedIsochronous: connected_isochronous::ConnectedIsochronous;
 }
 
 /// Servers and services are defined with respect to ServerTypes.
diff --git a/rust/bt-gatt/src/test_utils.rs b/rust/bt-gatt/src/test_utils.rs
index 858362b..b767e2a 100644
--- a/rust/bt-gatt/src/test_utils.rs
+++ b/rust/bt-gatt/src/test_utils.rs
@@ -16,6 +16,7 @@
 use crate::central::ScanResult;
 use crate::client::CharacteristicNotification;
 
+use crate::connected_isochronous::CigId;
 use crate::periodic_advertising::{PeriodicAdvertising, SyncReport};
 use crate::pii::GetPeerAddr;
 use crate::server::{
@@ -320,6 +321,7 @@
     type ReadFut<'a> = Ready<Result<(usize, bool)>>;
     type WriteFut<'a> = Ready<Result<()>>;
     type PeriodicAdvertising = FakePeriodicAdvertising;
+    type ConnectedIsochronous = FakeConnectedIsochronous;
 }
 
 impl ServerTypes for FakeTypes {
@@ -369,6 +371,88 @@
     }
 }
 
+pub struct FakeConnectedIsochronousStream {
+    cig_id: crate::connected_isochronous::CigId,
+    cis_id: crate::connected_isochronous::CisId,
+}
+
+impl crate::connected_isochronous::ConnectedIsochronousStream for FakeConnectedIsochronousStream {
+    type SetupDataPathFut =
+        Ready<std::result::Result<(), crate::connected_isochronous::ConnectedIsochronousError>>;
+    fn id(&self) -> (crate::connected_isochronous::CigId, crate::connected_isochronous::CisId) {
+        (self.cig_id, self.cis_id)
+    }
+
+    fn setup_data_path(
+        &self,
+        _direction: crate::connected_isochronous::DataDirection,
+        _data_path: crate::connected_isochronous::DataPathId,
+        _codec: bt_common::core::CodecId,
+        _codec_configuration: &[u8],
+        _controller_delay: crate::connected_isochronous::ControllerDelay,
+    ) -> Ready<std::result::Result<(), crate::connected_isochronous::ConnectedIsochronousError>>
+    {
+        ready(Ok(()))
+    }
+}
+
+pub struct FakeConnectedIsochronous {}
+
+impl crate::connected_isochronous::ConnectedIsochronous for FakeConnectedIsochronous {
+    type Group = FakeConnectedIsochronousGroup;
+    type InitializeCigFut = Ready<
+        std::result::Result<Self::Group, crate::connected_isochronous::ConnectedIsochronousError>,
+    >;
+
+    fn initialize(
+        &self,
+        _group_parameters: crate::connected_isochronous::CigParameters,
+        _streams_requested: &[crate::connected_isochronous::CisRequestedParameters],
+    ) -> Self::InitializeCigFut {
+        ready(Ok(FakeConnectedIsochronousGroup {}))
+    }
+}
+
+pub struct FakeConnectedIsochronousGroup {}
+
+impl crate::connected_isochronous::ConnectedIsochronousGroup for FakeConnectedIsochronousGroup {
+    type ConnectedIsochronousStream = FakeConnectedIsochronousStream;
+    type CreateCisFut = Ready<
+        std::result::Result<
+            Vec<Self::ConnectedIsochronousStream>,
+            crate::connected_isochronous::ConnectedIsochronousError,
+        >,
+    >;
+    type RemoveGroupFut = Ready<()>;
+
+    fn cig_id(&self) -> CigId {
+        CigId::try_from(1).unwrap()
+    }
+
+    fn create_streams(
+        &self,
+        peers: &[(crate::connected_isochronous::CisId, PeerId)],
+    ) -> Ready<
+        std::result::Result<
+            Vec<FakeConnectedIsochronousStream>,
+            crate::connected_isochronous::ConnectedIsochronousError,
+        >,
+    > {
+        let streams = peers
+            .iter()
+            .map(|(cis_id, _peer_id)| FakeConnectedIsochronousStream {
+                cig_id: self.cig_id(),
+                cis_id: *cis_id,
+            })
+            .collect();
+        ready(Ok(streams))
+    }
+
+    fn remove(&self) -> Ready<()> {
+        ready(())
+    }
+}
+
 #[derive(Default)]
 pub struct FakeCentralInner {
     clients: HashMap<PeerId, FakeClient>,
@@ -407,6 +491,12 @@
     fn periodic_advertising(&self) -> Result<<FakeTypes as GattTypes>::PeriodicAdvertising> {
         Ok(self.inner.lock().periodic_advertising.clone())
     }
+
+    fn connected_isochronous(
+        &self,
+    ) -> crate::Result<<FakeTypes as GattTypes>::ConnectedIsochronous> {
+        Ok(FakeConnectedIsochronous {})
+    }
 }
 
 #[derive(Debug)]