Merge "Merge remote-tracking branch 'aosp/upstream-main'" into main
diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml
index 021b1e4..d65c650 100644
--- a/.github/workflows/code-check.yml
+++ b/.github/workflows/code-check.yml
@@ -29,7 +29,7 @@
     - name: Set up Python
       uses: actions/setup-python@v3
       with:
-        python-version: '3.10'
+        python-version: ${{ matrix.python-version }}
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml
index 4cc3e73..f1a8105 100644
--- a/.github/workflows/python-build-test.yml
+++ b/.github/workflows/python-build-test.yml
@@ -47,7 +47,7 @@
     strategy:
       matrix:
         python-version: [ "3.8", "3.9", "3.10", "3.11" ]
-        rust-version: [ "1.70.0", "stable" ]
+        rust-version: [ "1.76.0", "stable" ]
       fail-fast: false
     steps:
       - name: Check out from Git
@@ -56,7 +56,7 @@
         uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python-version }}
-      - name: Install dependencies
+      - name: Install Python dependencies
         run: |
           python -m pip install --upgrade pip
           python -m pip install ".[build,test,development,documentation]"
@@ -65,15 +65,17 @@
         with:
           components: clippy,rustfmt
           toolchain: ${{ matrix.rust-version }}
+      - name: Install Rust dependencies
+        run: cargo install cargo-all-features # allows building/testing combinations of features
       - name: Check License Headers
         run: cd rust && cargo run --features dev-tools --bin file-header check-all
       - name: Rust Build
-        run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
+        run: cd rust && cargo build --all-targets && cargo build-all-features --all-targets
       # Lints after build so what clippy needs is already built
       - name: Rust Lints
         run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
       - name: Rust Tests
-        run: cd rust && cargo test
+        run: cd rust && cargo test-all-features
       # At some point, hook up publishing the binary. For now, just make sure it builds.
       # Once we're ready to publish binaries, this should be built with `--release`.
       - name: Build Bumble CLI
diff --git a/.gitignore b/.gitignore
index 97dc64d..1a5fb9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,9 @@
 # generated by setuptools_scm
 bumble/_version.py
 .vscode/launch.json
+.vscode/settings.json
 /.idea
+venv/
+.venv/
+# snoop logs
+out/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 57e682a..b535ada 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,7 +12,9 @@
         "ASHA",
         "asyncio",
         "ATRAC",
+        "avctp",
         "avdtp",
+        "avrcp",
         "bitpool",
         "bitstruct",
         "BSCP",
@@ -21,7 +23,10 @@
         "cccds",
         "cmac",
         "CONNECTIONLESS",
+        "csip",
+        "csis",
         "csrcs",
+        "CVSD",
         "datagram",
         "DATALINK",
         "delayreport",
@@ -29,6 +34,8 @@
         "deregistration",
         "dhkey",
         "diversifier",
+        "endianness",
+        "ESCO",
         "Fitbit",
         "GATTLINK",
         "HANDSFREE",
@@ -38,15 +45,18 @@
         "libc",
         "libusb",
         "MITM",
+        "MSBC",
         "NDIS",
         "netsim",
         "NONBLOCK",
         "NONCONN",
         "OXIMETER",
         "popleft",
+        "PRAND",
         "protobuf",
         "psms",
         "pyee",
+        "Pyodide",
         "pyusb",
         "rfcomm",
         "ROHC",
@@ -54,6 +64,7 @@
         "SEID",
         "seids",
         "SERV",
+        "SIRK",
         "ssrc",
         "strerror",
         "subband",
@@ -63,6 +74,8 @@
         "substates",
         "tobytes",
         "tsep",
+        "UNMUTE",
+        "unmuted",
         "usbmodem",
         "vhci",
         "websockets",
diff --git a/apps/bench.py b/apps/bench.py
index 8b37883..83625f0 100644
--- a/apps/bench.py
+++ b/apps/bench.py
@@ -77,14 +77,17 @@
 SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53'
 SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D'
 
-DEFAULT_L2CAP_PSM = 1234
+DEFAULT_RFCOMM_UUID = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
+DEFAULT_L2CAP_PSM = 128
 DEFAULT_L2CAP_MAX_CREDITS = 128
-DEFAULT_L2CAP_MTU = 1022
+DEFAULT_L2CAP_MTU = 1024
 DEFAULT_L2CAP_MPS = 1024
 
 DEFAULT_LINGER_TIME = 1.0
+DEFAULT_POST_CONNECTION_WAIT_TIME = 1.0
 
 DEFAULT_RFCOMM_CHANNEL = 8
+DEFAULT_RFCOMM_MTU = 2048
 
 
 # -----------------------------------------------------------------------------
@@ -92,7 +95,7 @@
 # -----------------------------------------------------------------------------
 def parse_packet(packet):
     if len(packet) < 1:
-        print(
+        logging.info(
             color(f'!!! Packet too short (got {len(packet)} bytes, need >= 1)', 'red')
         )
         raise ValueError('packet too short')
@@ -100,7 +103,7 @@
     try:
         packet_type = PacketType(packet[0])
     except ValueError:
-        print(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red'))
+        logging.info(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red'))
         raise
 
     return (packet_type, packet[1:])
@@ -108,7 +111,7 @@
 
 def parse_packet_sequence(packet_data):
     if len(packet_data) < 5:
-        print(
+        logging.info(
             color(
                 f'!!!Packet too short (got {len(packet_data)} bytes, need >= 5)',
                 'red',
@@ -128,11 +131,16 @@
     if connection.transport == BT_LE_TRANSPORT:
         phy_state = (
             'PHY='
-            f'RX:{le_phy_name(connection.phy.rx_phy)}/'
-            f'TX:{le_phy_name(connection.phy.tx_phy)}'
+            f'TX:{le_phy_name(connection.phy.tx_phy)}/'
+            f'RX:{le_phy_name(connection.phy.rx_phy)}'
         )
 
-        data_length = f'DL={connection.data_length}'
+        data_length = (
+            'DL=('
+            f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
+            f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
+            ')'
+        )
         connection_parameters = (
             'Parameters='
             f'{connection.parameters.connection_interval * 1.25:.2f}/'
@@ -147,7 +155,7 @@
 
     mtu = connection.att_mtu
 
-    print(
+    logging.info(
         f'{color("@@@ Connection:", "yellow")} '
         f'{connection_parameters} '
         f'{data_length} '
@@ -169,9 +177,7 @@
             ),
             ServiceAttribute(
                 SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
-                DataElement.sequence(
-                    [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
-                ),
+                DataElement.sequence([DataElement.uuid(UUID(DEFAULT_RFCOMM_UUID))]),
             ),
             ServiceAttribute(
                 SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -191,6 +197,23 @@
     }
 
 
+def log_stats(title, stats):
+    stats_min = min(stats)
+    stats_max = max(stats)
+    stats_avg = sum(stats) / len(stats)
+    logging.info(
+        color(
+            (
+                f'### {title} stats: '
+                f'min={stats_min:.2f}, '
+                f'max={stats_max:.2f}, '
+                f'average={stats_avg:.2f}'
+            ),
+            'cyan',
+        )
+    )
+
+
 class PacketType(enum.IntEnum):
     RESET = 0
     SEQUENCE = 1
@@ -204,45 +227,88 @@
 # Sender
 # -----------------------------------------------------------------------------
 class Sender:
-    def __init__(self, packet_io, start_delay, packet_size, packet_count):
+    def __init__(
+        self,
+        packet_io,
+        start_delay,
+        repeat,
+        repeat_delay,
+        pace,
+        packet_size,
+        packet_count,
+    ):
         self.tx_start_delay = start_delay
         self.tx_packet_size = packet_size
         self.tx_packet_count = packet_count
         self.packet_io = packet_io
         self.packet_io.packet_listener = self
+        self.repeat = repeat
+        self.repeat_delay = repeat_delay
+        self.pace = pace
         self.start_time = 0
         self.bytes_sent = 0
+        self.stats = []
         self.done = asyncio.Event()
 
     def reset(self):
         pass
 
     async def run(self):
-        print(color('--- Waiting for I/O to be ready...', 'blue'))
+        logging.info(color('--- Waiting for I/O to be ready...', 'blue'))
         await self.packet_io.ready.wait()
-        print(color('--- Go!', 'blue'))
+        logging.info(color('--- Go!', 'blue'))
 
-        if self.tx_start_delay:
-            print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
-            await asyncio.sleep(self.tx_start_delay)  # FIXME
+        for run in range(self.repeat + 1):
+            self.done.clear()
 
-        print(color('=== Sending RESET', 'magenta'))
-        await self.packet_io.send_packet(bytes([PacketType.RESET]))
-        self.start_time = time.time()
-        for tx_i in range(self.tx_packet_count):
-            packet_flags = PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0
-            packet = struct.pack(
-                '>bbI',
-                PacketType.SEQUENCE,
-                packet_flags,
-                tx_i,
-            ) + bytes(self.tx_packet_size - 6)
-            print(color(f'Sending packet {tx_i}: {len(packet)} bytes', 'yellow'))
-            self.bytes_sent += len(packet)
-            await self.packet_io.send_packet(packet)
+            if run > 0 and self.repeat and self.repeat_delay:
+                logging.info(color(f'*** Repeat delay: {self.repeat_delay}', 'green'))
+                await asyncio.sleep(self.repeat_delay)
 
-        await self.done.wait()
-        print(color('=== Done!', 'magenta'))
+            if self.tx_start_delay:
+                logging.info(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
+                await asyncio.sleep(self.tx_start_delay)
+
+            logging.info(color('=== Sending RESET', 'magenta'))
+            await self.packet_io.send_packet(bytes([PacketType.RESET]))
+            self.start_time = time.time()
+            self.bytes_sent = 0
+            for tx_i in range(self.tx_packet_count):
+                packet_flags = (
+                    PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0
+                )
+                packet = struct.pack(
+                    '>bbI',
+                    PacketType.SEQUENCE,
+                    packet_flags,
+                    tx_i,
+                ) + bytes(self.tx_packet_size - 6 - self.packet_io.overhead_size)
+                logging.info(
+                    color(
+                        f'Sending packet {tx_i}: {self.tx_packet_size} bytes', 'yellow'
+                    )
+                )
+                self.bytes_sent += len(packet)
+                await self.packet_io.send_packet(packet)
+
+                if self.pace is None:
+                    continue
+
+                if self.pace > 0:
+                    await asyncio.sleep(self.pace / 1000)
+                else:
+                    await self.packet_io.drain()
+
+            await self.done.wait()
+
+            run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
+            logging.info(color(f'=== {run_counter} Done!', 'magenta'))
+
+            if self.repeat:
+                log_stats('Run', self.stats)
+
+        if self.repeat:
+            logging.info(color('--- End of runs', 'blue'))
 
     def on_packet_received(self, packet):
         try:
@@ -253,7 +319,8 @@
         if packet_type == PacketType.ACK:
             elapsed = time.time() - self.start_time
             average_tx_speed = self.bytes_sent / elapsed
-            print(
+            self.stats.append(average_tx_speed)
+            logging.info(
                 color(
                     f'@@@ Received ACK. Speed: average={average_tx_speed:.4f}'
                     f' ({self.bytes_sent} bytes in {elapsed:.2f} seconds)',
@@ -267,17 +334,21 @@
 # Receiver
 # -----------------------------------------------------------------------------
 class Receiver:
-    def __init__(self, packet_io):
+    expected_packet_index: int
+    start_timestamp: float
+    last_timestamp: float
+
+    def __init__(self, packet_io, linger):
         self.reset()
         self.packet_io = packet_io
         self.packet_io.packet_listener = self
+        self.linger = linger
         self.done = asyncio.Event()
 
     def reset(self):
         self.expected_packet_index = 0
-        self.start_timestamp = 0.0
-        self.last_timestamp = 0.0
-        self.bytes_received = 0
+        self.measurements = [(time.time(), 0)]
+        self.total_bytes_received = 0
 
     def on_packet_received(self, packet):
         try:
@@ -285,44 +356,50 @@
         except ValueError:
             return
 
-        now = time.time()
-
         if packet_type == PacketType.RESET:
-            print(color('=== Received RESET', 'magenta'))
+            logging.info(color('=== Received RESET', 'magenta'))
             self.reset()
-            self.start_timestamp = now
             return
 
         try:
             packet_flags, packet_index = parse_packet_sequence(packet_data)
         except ValueError:
             return
-        print(
+        logging.info(
             f'<<< Received packet {packet_index}: '
-            f'flags=0x{packet_flags:02X}, {len(packet)} bytes'
+            f'flags=0x{packet_flags:02X}, '
+            f'{len(packet) + self.packet_io.overhead_size} bytes'
         )
 
         if packet_index != self.expected_packet_index:
-            print(
+            logging.info(
                 color(
                     f'!!! Unexpected packet, expected {self.expected_packet_index} '
                     f'but received {packet_index}'
                 )
             )
 
-        elapsed_since_start = now - self.start_timestamp
-        elapsed_since_last = now - self.last_timestamp
-        self.bytes_received += len(packet)
+        now = time.time()
+        elapsed_since_start = now - self.measurements[0][0]
+        elapsed_since_last = now - self.measurements[-1][0]
+        self.measurements.append((now, len(packet)))
+        self.total_bytes_received += len(packet)
         instant_rx_speed = len(packet) / elapsed_since_last
-        average_rx_speed = self.bytes_received / elapsed_since_start
-        print(
+        average_rx_speed = self.total_bytes_received / elapsed_since_start
+        window = self.measurements[-64:]
+        windowed_rx_speed = sum(measurement[1] for measurement in window[1:]) / (
+            window[-1][0] - window[0][0]
+        )
+        logging.info(
             color(
-                f'Speed: instant={instant_rx_speed:.4f}, average={average_rx_speed:.4f}',
+                'Speed: '
+                f'instant={instant_rx_speed:.4f}, '
+                f'windowed={windowed_rx_speed:.4f}, '
+                f'average={average_rx_speed:.4f}',
                 'yellow',
             )
         )
 
-        self.last_timestamp = now
         self.expected_packet_index = packet_index + 1
 
         if packet_flags & PACKET_FLAG_LAST:
@@ -331,52 +408,104 @@
                     struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index)
                 )
             )
-            print(color('@@@ Received last packet', 'green'))
-            self.done.set()
+            logging.info(color('@@@ Received last packet', 'green'))
+            if not self.linger:
+                self.done.set()
 
     async def run(self):
         await self.done.wait()
-        print(color('=== Done!', 'magenta'))
+        logging.info(color('=== Done!', 'magenta'))
 
 
 # -----------------------------------------------------------------------------
 # Ping
 # -----------------------------------------------------------------------------
 class Ping:
-    def __init__(self, packet_io, start_delay, packet_size, packet_count):
+    def __init__(
+        self,
+        packet_io,
+        start_delay,
+        repeat,
+        repeat_delay,
+        pace,
+        packet_size,
+        packet_count,
+    ):
         self.tx_start_delay = start_delay
         self.tx_packet_size = packet_size
         self.tx_packet_count = packet_count
         self.packet_io = packet_io
         self.packet_io.packet_listener = self
+        self.repeat = repeat
+        self.repeat_delay = repeat_delay
+        self.pace = pace
         self.done = asyncio.Event()
         self.current_packet_index = 0
         self.ping_sent_time = 0.0
         self.latencies = []
+        self.min_stats = []
+        self.max_stats = []
+        self.avg_stats = []
 
     def reset(self):
         pass
 
     async def run(self):
-        print(color('--- Waiting for I/O to be ready...', 'blue'))
+        logging.info(color('--- Waiting for I/O to be ready...', 'blue'))
         await self.packet_io.ready.wait()
-        print(color('--- Go!', 'blue'))
+        logging.info(color('--- Go!', 'blue'))
 
-        if self.tx_start_delay:
-            print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
-            await asyncio.sleep(self.tx_start_delay)  # FIXME
+        for run in range(self.repeat + 1):
+            self.done.clear()
 
-        print(color('=== Sending RESET', 'magenta'))
-        await self.packet_io.send_packet(bytes([PacketType.RESET]))
+            if run > 0 and self.repeat and self.repeat_delay:
+                logging.info(color(f'*** Repeat delay: {self.repeat_delay}', 'green'))
+                await asyncio.sleep(self.repeat_delay)
 
-        await self.send_next_ping()
+            if self.tx_start_delay:
+                logging.info(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
+                await asyncio.sleep(self.tx_start_delay)
 
-        await self.done.wait()
-        average_latency = sum(self.latencies) / len(self.latencies)
-        print(color(f'@@@ Average latency: {average_latency:.2f}'))
-        print(color('=== Done!', 'magenta'))
+            logging.info(color('=== Sending RESET', 'magenta'))
+            await self.packet_io.send_packet(bytes([PacketType.RESET]))
+
+            self.current_packet_index = 0
+            self.latencies = []
+            await self.send_next_ping()
+
+            await self.done.wait()
+
+            min_latency = min(self.latencies)
+            max_latency = max(self.latencies)
+            avg_latency = sum(self.latencies) / len(self.latencies)
+            logging.info(
+                color(
+                    '@@@ Latencies: '
+                    f'min={min_latency:.2f}, '
+                    f'max={max_latency:.2f}, '
+                    f'average={avg_latency:.2f}'
+                )
+            )
+
+            self.min_stats.append(min_latency)
+            self.max_stats.append(max_latency)
+            self.avg_stats.append(avg_latency)
+
+            run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
+            logging.info(color(f'=== {run_counter} Done!', 'magenta'))
+
+            if self.repeat:
+                log_stats('Min Latency', self.min_stats)
+                log_stats('Max Latency', self.max_stats)
+                log_stats('Average Latency', self.avg_stats)
+
+        if self.repeat:
+            logging.info(color('--- End of runs', 'blue'))
 
     async def send_next_ping(self):
+        if self.pace:
+            await asyncio.sleep(self.pace / 1000)
+
         packet = struct.pack(
             '>bbI',
             PacketType.SEQUENCE,
@@ -385,7 +514,7 @@
             else 0,
             self.current_packet_index,
         ) + bytes(self.tx_packet_size - 6)
-        print(color(f'Sending packet {self.current_packet_index}', 'yellow'))
+        logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
         self.ping_sent_time = time.time()
         await self.packet_io.send_packet(packet)
 
@@ -405,7 +534,7 @@
         if packet_type == PacketType.ACK:
             latency = elapsed * 1000
             self.latencies.append(latency)
-            print(
+            logging.info(
                 color(
                     f'<<< Received ACK [{packet_index}], latency={latency:.2f}ms',
                     'green',
@@ -415,7 +544,7 @@
             if packet_index == self.current_packet_index:
                 self.current_packet_index += 1
             else:
-                print(
+                logging.info(
                     color(
                         f'!!! Unexpected packet, expected {self.current_packet_index} '
                         f'but received {packet_index}'
@@ -433,10 +562,13 @@
 # Pong
 # -----------------------------------------------------------------------------
 class Pong:
-    def __init__(self, packet_io):
+    expected_packet_index: int
+
+    def __init__(self, packet_io, linger):
         self.reset()
         self.packet_io = packet_io
         self.packet_io.packet_listener = self
+        self.linger = linger
         self.done = asyncio.Event()
 
     def reset(self):
@@ -449,7 +581,7 @@
             return
 
         if packet_type == PacketType.RESET:
-            print(color('=== Received RESET', 'magenta'))
+            logging.info(color('=== Received RESET', 'magenta'))
             self.reset()
             return
 
@@ -457,7 +589,7 @@
             packet_flags, packet_index = parse_packet_sequence(packet_data)
         except ValueError:
             return
-        print(
+        logging.info(
             color(
                 f'<<< Received packet {packet_index}: '
                 f'flags=0x{packet_flags:02X}, {len(packet)} bytes',
@@ -466,7 +598,7 @@
         )
 
         if packet_index != self.expected_packet_index:
-            print(
+            logging.info(
                 color(
                     f'!!! Unexpected packet, expected {self.expected_packet_index} '
                     f'but received {packet_index}'
@@ -481,12 +613,12 @@
             )
         )
 
-        if packet_flags & PACKET_FLAG_LAST:
+        if packet_flags & PACKET_FLAG_LAST and not self.linger:
             self.done.set()
 
     async def run(self):
         await self.done.wait()
-        print(color('=== Done!', 'magenta'))
+        logging.info(color('=== Done!', 'magenta'))
 
 
 # -----------------------------------------------------------------------------
@@ -499,41 +631,42 @@
         self.speed_tx = None
         self.packet_listener = None
         self.ready = asyncio.Event()
+        self.overhead_size = 0
 
     async def on_connection(self, connection):
         peer = Peer(connection)
 
         if self.att_mtu:
-            print(color(f'*** Requesting MTU update: {self.att_mtu}', 'blue'))
+            logging.info(color(f'*** Requesting MTU update: {self.att_mtu}', 'blue'))
             await peer.request_mtu(self.att_mtu)
 
-        print(color('*** Discovering services...', 'blue'))
+        logging.info(color('*** Discovering services...', 'blue'))
         await peer.discover_services()
 
         speed_services = peer.get_services_by_uuid(SPEED_SERVICE_UUID)
         if not speed_services:
-            print(color('!!! Speed Service not found', 'red'))
+            logging.info(color('!!! Speed Service not found', 'red'))
             return
         speed_service = speed_services[0]
-        print(color('*** Discovering characteristics...', 'blue'))
+        logging.info(color('*** Discovering characteristics...', 'blue'))
         await speed_service.discover_characteristics()
 
         speed_txs = speed_service.get_characteristics_by_uuid(SPEED_TX_UUID)
         if not speed_txs:
-            print(color('!!! Speed TX not found', 'red'))
+            logging.info(color('!!! Speed TX not found', 'red'))
             return
         self.speed_tx = speed_txs[0]
 
         speed_rxs = speed_service.get_characteristics_by_uuid(SPEED_RX_UUID)
         if not speed_rxs:
-            print(color('!!! Speed RX not found', 'red'))
+            logging.info(color('!!! Speed RX not found', 'red'))
             return
         self.speed_rx = speed_rxs[0]
 
-        print(color('*** Subscribing to RX', 'blue'))
+        logging.info(color('*** Subscribing to RX', 'blue'))
         await self.speed_rx.subscribe(self.on_packet_received)
 
-        print(color('*** Discovery complete', 'blue'))
+        logging.info(color('*** Discovery complete', 'blue'))
 
         connection.on('disconnection', self.on_disconnection)
         self.ready.set()
@@ -548,6 +681,9 @@
     async def send_packet(self, packet):
         await self.speed_tx.write_value(packet)
 
+    async def drain(self):
+        pass
+
 
 # -----------------------------------------------------------------------------
 # GattServer
@@ -557,6 +693,7 @@
         self.device = device
         self.packet_listener = None
         self.ready = asyncio.Event()
+        self.overhead_size = 0
 
         # Setup the GATT service
         self.speed_tx = Characteristic(
@@ -585,10 +722,10 @@
 
     def on_rx_subscription(self, _connection, notify_enabled, _indicate_enabled):
         if notify_enabled:
-            print(color('*** RX subscription', 'blue'))
+            logging.info(color('*** RX subscription', 'blue'))
             self.ready.set()
         else:
-            print(color('*** RX un-subscription', 'blue'))
+            logging.info(color('*** RX un-subscription', 'blue'))
             self.ready.clear()
 
     def on_tx_write(self, _, value):
@@ -598,6 +735,9 @@
     async def send_packet(self, packet):
         await self.device.notify_subscribers(self.speed_rx, packet)
 
+    async def drain(self):
+        pass
+
 
 # -----------------------------------------------------------------------------
 # StreamedPacketIO
@@ -609,6 +749,7 @@
         self.rx_packet = b''
         self.rx_packet_header = b''
         self.rx_packet_need = 0
+        self.overhead_size = 2
 
     def on_packet(self, packet):
         while packet:
@@ -636,7 +777,7 @@
 
     async def send_packet(self, packet):
         if not self.io_sink:
-            print(color('!!! No sink, dropping packet', 'red'))
+            logging.info(color('!!! No sink, dropping packet', 'red'))
             return
 
         # pylint: disable-next=not-callable
@@ -660,13 +801,14 @@
         self.max_credits = max_credits
         self.mtu = mtu
         self.mps = mps
+        self.l2cap_channel = None
         self.ready = asyncio.Event()
 
     async def on_connection(self, connection: Connection) -> None:
         connection.on('disconnection', self.on_disconnection)
 
         # Connect a new L2CAP channel
-        print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
+        logging.info(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
         try:
             l2cap_channel = await connection.create_l2cap_channel(
                 spec=l2cap.LeCreditBasedChannelSpec(
@@ -676,14 +818,15 @@
                     mps=self.mps,
                 )
             )
-            print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
+            logging.info(color(f'*** L2CAP channel: {l2cap_channel}', 'cyan'))
         except Exception as error:
-            print(color(f'!!! Connection failed: {error}', 'red'))
+            logging.info(color(f'!!! Connection failed: {error}', 'red'))
             return
 
-        l2cap_channel.sink = self.on_packet
-        l2cap_channel.on('close', self.on_l2cap_close)
         self.io_sink = l2cap_channel.write
+        self.l2cap_channel = l2cap_channel
+        l2cap_channel.on('close', self.on_l2cap_close)
+        l2cap_channel.sink = self.on_packet
 
         self.ready.set()
 
@@ -691,7 +834,11 @@
         pass
 
     def on_l2cap_close(self):
-        print(color('*** L2CAP channel closed', 'red'))
+        logging.info(color('*** L2CAP channel closed', 'red'))
+
+    async def drain(self):
+        assert self.l2cap_channel
+        await self.l2cap_channel.drain()
 
 
 # -----------------------------------------------------------------------------
@@ -710,14 +857,16 @@
         self.l2cap_channel = None
         self.ready = asyncio.Event()
 
-        # Listen for incoming L2CAP CoC connections
+        # Listen for incoming L2CAP connections
         device.create_l2cap_server(
             spec=l2cap.LeCreditBasedChannelSpec(
                 psm=psm, mtu=mtu, mps=mps, max_credits=max_credits
             ),
             handler=self.on_l2cap_channel,
         )
-        print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow'))
+        logging.info(
+            color(f'### Listening for L2CAP connection on PSM {psm}', 'yellow')
+        )
 
     async def on_connection(self, connection):
         connection.on('disconnection', self.on_disconnection)
@@ -726,74 +875,116 @@
         pass
 
     def on_l2cap_channel(self, l2cap_channel):
-        print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
+        logging.info(color(f'*** L2CAP channel: {l2cap_channel}', 'cyan'))
 
         self.io_sink = l2cap_channel.write
+        self.l2cap_channel = l2cap_channel
         l2cap_channel.on('close', self.on_l2cap_close)
         l2cap_channel.sink = self.on_packet
 
         self.ready.set()
 
     def on_l2cap_close(self):
-        print(color('*** L2CAP channel closed', 'red'))
+        logging.info(color('*** L2CAP channel closed', 'red'))
         self.l2cap_channel = None
 
+    async def drain(self):
+        assert self.l2cap_channel
+        await self.l2cap_channel.drain()
+
 
 # -----------------------------------------------------------------------------
 # RfcommClient
 # -----------------------------------------------------------------------------
 class RfcommClient(StreamedPacketIO):
-    def __init__(self, device):
+    def __init__(self, device, channel, uuid, l2cap_mtu, max_frame_size, window_size):
         super().__init__()
         self.device = device
+        self.channel = channel
+        self.uuid = uuid
+        self.l2cap_mtu = l2cap_mtu
+        self.max_frame_size = max_frame_size
+        self.window_size = window_size
+        self.rfcomm_session = None
         self.ready = asyncio.Event()
 
     async def on_connection(self, connection):
         connection.on('disconnection', self.on_disconnection)
 
-        # Create a client and start it
-        print(color('*** Starting RFCOMM client...', 'blue'))
-        rfcomm_client = bumble.rfcomm.Client(self.device, connection)
-        rfcomm_mux = await rfcomm_client.start()
-        print(color('*** Started', 'blue'))
+        # Find the channel number if not specified
+        channel = self.channel
+        if channel == 0:
+            logging.info(
+                color(f'@@@ Discovering channel number from UUID {self.uuid}', 'cyan')
+            )
+            channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
+                connection, self.uuid
+            )
+            logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
+            if channel == 0:
+                logging.info(color('!!! No RFComm service with this UUID found', 'red'))
+                await connection.disconnect()
+                return
 
-        channel = DEFAULT_RFCOMM_CHANNEL
-        print(color(f'### Opening session for channel {channel}...', 'yellow'))
+        # Create a client and start it
+        logging.info(color('*** Starting RFCOMM client...', 'blue'))
+        rfcomm_options = {}
+        if self.l2cap_mtu:
+            rfcomm_options['l2cap_mtu'] = self.l2cap_mtu
+        rfcomm_client = bumble.rfcomm.Client(connection, **rfcomm_options)
+        rfcomm_mux = await rfcomm_client.start()
+        logging.info(color('*** Started', 'blue'))
+
+        logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
         try:
-            rfcomm_session = await rfcomm_mux.open_dlc(channel)
-            print(color('### Session open', 'yellow'), rfcomm_session)
+            dlc_options = {}
+            if self.max_frame_size:
+                dlc_options['max_frame_size'] = self.max_frame_size
+            if self.window_size:
+                dlc_options['window_size'] = self.window_size
+            rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
+            logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
         except bumble.core.ConnectionError as error:
-            print(color(f'!!! Session open failed: {error}', 'red'))
+            logging.info(color(f'!!! Session open failed: {error}', 'red'))
             await rfcomm_mux.disconnect()
             return
 
         rfcomm_session.sink = self.on_packet
         self.io_sink = rfcomm_session.write
+        self.rfcomm_session = rfcomm_session
 
         self.ready.set()
 
     def on_disconnection(self, _):
         pass
 
+    async def drain(self):
+        assert self.rfcomm_session
+        await self.rfcomm_session.drain()
+
 
 # -----------------------------------------------------------------------------
 # RfcommServer
 # -----------------------------------------------------------------------------
 class RfcommServer(StreamedPacketIO):
-    def __init__(self, device):
+    def __init__(self, device, channel, l2cap_mtu):
         super().__init__()
+        self.dlc = None
         self.ready = asyncio.Event()
 
         # Create and register a server
-        rfcomm_server = bumble.rfcomm.Server(device)
+        server_options = {}
+        if l2cap_mtu:
+            server_options['l2cap_mtu'] = l2cap_mtu
+        rfcomm_server = bumble.rfcomm.Server(device, **server_options)
 
         # Listen for incoming DLC connections
-        channel_number = rfcomm_server.listen(self.on_dlc, DEFAULT_RFCOMM_CHANNEL)
+        channel_number = rfcomm_server.listen(self.on_dlc, channel)
 
         # Setup the SDP to advertise this channel
         device.sdp_service_records = make_sdp_records(channel_number)
 
-        print(
+        logging.info(
             color(
                 f'### Listening for RFComm connection on channel {channel_number}',
                 'yellow',
@@ -807,9 +998,14 @@
         pass
 
     def on_dlc(self, dlc):
-        print(color('*** DLC connected:', 'blue'), dlc)
+        logging.info(color(f'*** DLC connected: {dlc}', 'blue'))
         dlc.sink = self.on_packet
         self.io_sink = dlc.write
+        self.dlc = dlc
+
+    async def drain(self):
+        assert self.dlc
+        await self.dlc.drain()
 
 
 # -----------------------------------------------------------------------------
@@ -825,6 +1021,9 @@
         mode_factory,
         connection_interval,
         phy,
+        authenticate,
+        encrypt,
+        extended_data_length,
     ):
         super().__init__()
         self.transport = transport
@@ -832,6 +1031,9 @@
         self.classic = classic
         self.role_factory = role_factory
         self.mode_factory = mode_factory
+        self.authenticate = authenticate
+        self.encrypt = encrypt or authenticate
+        self.extended_data_length = extended_data_length
         self.device = None
         self.connection = None
 
@@ -867,12 +1069,12 @@
             self.connection_parameter_preferences = None
 
     async def run(self):
-        print(color('>>> Connecting to HCI...', 'green'))
+        logging.info(color('>>> Connecting to HCI...', 'green'))
         async with await open_transport_or_link(self.transport) as (
             hci_source,
             hci_sink,
         ):
-            print(color('>>> Connected', 'green'))
+            logging.info(color('>>> Connected', 'green'))
 
             central_address = DEFAULT_CENTRAL_ADDRESS
             self.device = Device.with_hci(
@@ -884,7 +1086,13 @@
 
             await self.device.power_on()
 
-            print(color(f'### Connecting to {self.peripheral_address}...', 'cyan'))
+            if self.classic:
+                await self.device.set_discoverable(False)
+                await self.device.set_connectable(False)
+
+            logging.info(
+                color(f'### Connecting to {self.peripheral_address}...', 'cyan')
+            )
             try:
                 self.connection = await self.device.connect(
                     self.peripheral_address,
@@ -892,19 +1100,43 @@
                     transport=BT_BR_EDR_TRANSPORT if self.classic else BT_LE_TRANSPORT,
                 )
             except CommandTimeoutError:
-                print(color('!!! Connection timed out', 'red'))
+                logging.info(color('!!! Connection timed out', 'red'))
                 return
             except bumble.core.ConnectionError as error:
-                print(color(f'!!! Connection error: {error}', 'red'))
+                logging.info(color(f'!!! Connection error: {error}', 'red'))
                 return
             except HCI_StatusError as error:
-                print(color(f'!!! Connection failed: {error.error_name}'))
+                logging.info(color(f'!!! Connection failed: {error.error_name}'))
                 return
-            print(color('### Connected', 'cyan'))
+            logging.info(color('### Connected', 'cyan'))
             self.connection.listener = self
             print_connection(self.connection)
 
-            await mode.on_connection(self.connection)
+            # Wait a bit after the connection, some controllers aren't very good when
+            # we start sending data right away while some connection parameters are
+            # updated post connection
+            await asyncio.sleep(DEFAULT_POST_CONNECTION_WAIT_TIME)
+
+            # Request a new data length if requested
+            if self.extended_data_length:
+                logging.info(color('+++ Requesting extended data length', 'cyan'))
+                await self.connection.set_data_length(
+                    self.extended_data_length[0], self.extended_data_length[1]
+                )
+
+            # Authenticate if requested
+            if self.authenticate:
+                # Request authentication
+                logging.info(color('*** Authenticating...', 'cyan'))
+                await self.connection.authenticate()
+                logging.info(color('*** Authenticated', 'cyan'))
+
+            # Encrypt if requested
+            if self.encrypt:
+                # Enable encryption
+                logging.info(color('*** Enabling encryption...', 'cyan'))
+                await self.connection.encrypt()
+                logging.info(color('*** Encryption on', 'cyan'))
 
             # Set the PHY if requested
             if self.phy is not None:
@@ -913,17 +1145,20 @@
                         tx_phys=[self.phy], rx_phys=[self.phy]
                     )
                 except HCI_Error as error:
-                    print(
+                    logging.info(
                         color(
                             f'!!! Unable to set the PHY: {error.error_name}', 'yellow'
                         )
                     )
 
+            await mode.on_connection(self.connection)
+
             await role.run()
             await asyncio.sleep(DEFAULT_LINGER_TIME)
+            await self.connection.disconnect()
 
     def on_disconnection(self, reason):
-        print(color(f'!!! Disconnection: reason={reason}', 'red'))
+        logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
         self.connection = None
 
     def on_connection_parameters_update(self):
@@ -943,9 +1178,12 @@
 # Peripheral
 # -----------------------------------------------------------------------------
 class Peripheral(Device.Listener, Connection.Listener):
-    def __init__(self, transport, classic, role_factory, mode_factory):
+    def __init__(
+        self, transport, classic, extended_data_length, role_factory, mode_factory
+    ):
         self.transport = transport
         self.classic = classic
+        self.extended_data_length = extended_data_length
         self.role_factory = role_factory
         self.role = None
         self.mode_factory = mode_factory
@@ -955,12 +1193,12 @@
         self.connected = asyncio.Event()
 
     async def run(self):
-        print(color('>>> Connecting to HCI...', 'green'))
+        logging.info(color('>>> Connecting to HCI...', 'green'))
         async with await open_transport_or_link(self.transport) as (
             hci_source,
             hci_sink,
         ):
-            print(color('>>> Connected', 'green'))
+            logging.info(color('>>> Connected', 'green'))
 
             peripheral_address = DEFAULT_PERIPHERAL_ADDRESS
             self.device = Device.with_hci(
@@ -980,7 +1218,7 @@
                 await self.device.start_advertising(auto_restart=True)
 
             if self.classic:
-                print(
+                logging.info(
                     color(
                         '### Waiting for connection on'
                         f' {self.device.public_address}...',
@@ -988,14 +1226,14 @@
                     )
                 )
             else:
-                print(
+                logging.info(
                     color(
                         f'### Waiting for connection on {peripheral_address}...',
                         'cyan',
                     )
                 )
             await self.connected.wait()
-            print(color('### Connected', 'cyan'))
+            logging.info(color('### Connected', 'cyan'))
 
             await self.mode.on_connection(self.connection)
             await self.role.run()
@@ -1006,11 +1244,29 @@
         self.connection = connection
         self.connected.set()
 
+        # Stop being discoverable and connectable
+        if self.classic:
+            AsyncRunner.spawn(self.device.set_discoverable(False))
+            AsyncRunner.spawn(self.device.set_connectable(False))
+
+        # Request a new data length if needed
+        if self.extended_data_length:
+            logging.info("+++ Requesting extended data length")
+            AsyncRunner.spawn(
+                connection.set_data_length(
+                    self.extended_data_length[0], self.extended_data_length[1]
+                )
+            )
+
     def on_disconnection(self, reason):
-        print(color(f'!!! Disconnection: reason={reason}', 'red'))
+        logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
         self.connection = None
         self.role.reset()
 
+        if self.classic:
+            AsyncRunner.spawn(self.device.set_discoverable(True))
+            AsyncRunner.spawn(self.device.set_connectable(True))
+
     def on_connection_parameters_update(self):
         print_connection(self.connection)
 
@@ -1038,16 +1294,39 @@
             return GattServer(device)
 
         if mode == 'l2cap-client':
-            return L2capClient(device)
+            return L2capClient(
+                device,
+                psm=ctx.obj['l2cap_psm'],
+                mtu=ctx.obj['l2cap_mtu'],
+                mps=ctx.obj['l2cap_mps'],
+                max_credits=ctx.obj['l2cap_max_credits'],
+            )
 
         if mode == 'l2cap-server':
-            return L2capServer(device)
+            return L2capServer(
+                device,
+                psm=ctx.obj['l2cap_psm'],
+                mtu=ctx.obj['l2cap_mtu'],
+                mps=ctx.obj['l2cap_mps'],
+                max_credits=ctx.obj['l2cap_max_credits'],
+            )
 
         if mode == 'rfcomm-client':
-            return RfcommClient(device)
+            return RfcommClient(
+                device,
+                channel=ctx.obj['rfcomm_channel'],
+                uuid=ctx.obj['rfcomm_uuid'],
+                l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
+                max_frame_size=ctx.obj['rfcomm_max_frame_size'],
+                window_size=ctx.obj['rfcomm_window_size'],
+            )
 
         if mode == 'rfcomm-server':
-            return RfcommServer(device)
+            return RfcommServer(
+                device,
+                channel=ctx.obj['rfcomm_channel'],
+                l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
+            )
 
         raise ValueError('invalid mode')
 
@@ -1065,23 +1344,29 @@
             return Sender(
                 packet_io,
                 start_delay=ctx.obj['start_delay'],
+                repeat=ctx.obj['repeat'],
+                repeat_delay=ctx.obj['repeat_delay'],
+                pace=ctx.obj['pace'],
                 packet_size=ctx.obj['packet_size'],
                 packet_count=ctx.obj['packet_count'],
             )
 
         if role == 'receiver':
-            return Receiver(packet_io)
+            return Receiver(packet_io, ctx.obj['linger'])
 
         if role == 'ping':
             return Ping(
                 packet_io,
                 start_delay=ctx.obj['start_delay'],
+                repeat=ctx.obj['repeat'],
+                repeat_delay=ctx.obj['repeat_delay'],
+                pace=ctx.obj['pace'],
                 packet_size=ctx.obj['packet_size'],
                 packet_count=ctx.obj['packet_count'],
             )
 
         if role == 'pong':
-            return Pong(packet_io)
+            return Pong(packet_io, ctx.obj['linger'])
 
         raise ValueError('invalid role')
 
@@ -1114,12 +1399,66 @@
     help='GATT MTU (gatt-client mode)',
 )
 @click.option(
+    '--extended-data-length',
+    help='Request a data length upon connection, specified as tx_octets/tx_time',
+)
+@click.option(
+    '--rfcomm-channel',
+    type=int,
+    default=DEFAULT_RFCOMM_CHANNEL,
+    help='RFComm channel to use',
+)
+@click.option(
+    '--rfcomm-uuid',
+    default=DEFAULT_RFCOMM_UUID,
+    help='RFComm service UUID to use (ignored if --rfcomm-channel is not 0)',
+)
+@click.option(
+    '--rfcomm-l2cap-mtu',
+    type=int,
+    help='RFComm L2CAP MTU',
+)
+@click.option(
+    '--rfcomm-max-frame-size',
+    type=int,
+    help='RFComm maximum frame size',
+)
+@click.option(
+    '--rfcomm-window-size',
+    type=int,
+    help='RFComm window size',
+)
+@click.option(
+    '--l2cap-psm',
+    type=int,
+    default=DEFAULT_L2CAP_PSM,
+    help='L2CAP PSM to use',
+)
+@click.option(
+    '--l2cap-mtu',
+    type=int,
+    default=DEFAULT_L2CAP_MTU,
+    help='L2CAP MTU to use',
+)
+@click.option(
+    '--l2cap-mps',
+    type=int,
+    default=DEFAULT_L2CAP_MPS,
+    help='L2CAP MPS to use',
+)
+@click.option(
+    '--l2cap-max-credits',
+    type=int,
+    default=DEFAULT_L2CAP_MAX_CREDITS,
+    help='L2CAP maximum number of credits allowed for the peer',
+)
+@click.option(
     '--packet-size',
     '-s',
     metavar='SIZE',
     type=click.IntRange(8, 4096),
     default=500,
-    help='Packet size (server role)',
+    help='Packet size (client or ping role)',
 )
 @click.option(
     '--packet-count',
@@ -1127,7 +1466,7 @@
     metavar='COUNT',
     type=int,
     default=10,
-    help='Packet count (server role)',
+    help='Packet count (client or ping role)',
 )
 @click.option(
     '--start-delay',
@@ -1135,21 +1474,92 @@
     metavar='SECONDS',
     type=int,
     default=1,
-    help='Start delay (server role)',
+    help='Start delay (client or ping role)',
+)
+@click.option(
+    '--repeat',
+    metavar='N',
+    type=int,
+    default=0,
+    help=(
+        'Repeat the run N times (client and ping roles)'
+        '(0, which is the fault, to run just once) '
+    ),
+)
+@click.option(
+    '--repeat-delay',
+    metavar='SECONDS',
+    type=int,
+    default=1,
+    help=('Delay, in seconds, between repeats'),
+)
+@click.option(
+    '--pace',
+    metavar='MILLISECONDS',
+    type=int,
+    default=0,
+    help=(
+        'Wait N milliseconds between packets '
+        '(0, which is the fault, to send as fast as possible) '
+    ),
+)
+@click.option(
+    '--linger',
+    is_flag=True,
+    help="Don't exit at the end of a run (server and pong roles)",
 )
 @click.pass_context
 def bench(
-    ctx, device_config, role, mode, att_mtu, packet_size, packet_count, start_delay
+    ctx,
+    device_config,
+    role,
+    mode,
+    att_mtu,
+    extended_data_length,
+    packet_size,
+    packet_count,
+    start_delay,
+    repeat,
+    repeat_delay,
+    pace,
+    linger,
+    rfcomm_channel,
+    rfcomm_uuid,
+    rfcomm_l2cap_mtu,
+    rfcomm_max_frame_size,
+    rfcomm_window_size,
+    l2cap_psm,
+    l2cap_mtu,
+    l2cap_mps,
+    l2cap_max_credits,
 ):
     ctx.ensure_object(dict)
     ctx.obj['device_config'] = device_config
     ctx.obj['role'] = role
     ctx.obj['mode'] = mode
     ctx.obj['att_mtu'] = att_mtu
+    ctx.obj['rfcomm_channel'] = rfcomm_channel
+    ctx.obj['rfcomm_uuid'] = rfcomm_uuid
+    ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
+    ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
+    ctx.obj['rfcomm_window_size'] = rfcomm_window_size
+    ctx.obj['l2cap_psm'] = l2cap_psm
+    ctx.obj['l2cap_mtu'] = l2cap_mtu
+    ctx.obj['l2cap_mps'] = l2cap_mps
+    ctx.obj['l2cap_max_credits'] = l2cap_max_credits
     ctx.obj['packet_size'] = packet_size
     ctx.obj['packet_count'] = packet_count
     ctx.obj['start_delay'] = start_delay
+    ctx.obj['repeat'] = repeat
+    ctx.obj['repeat_delay'] = repeat_delay
+    ctx.obj['pace'] = pace
+    ctx.obj['linger'] = linger
 
+    ctx.obj['extended_data_length'] = (
+        [int(x) for x in extended_data_length.split('/')]
+        if extended_data_length
+        else None
+    )
     ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
 
 
@@ -1170,8 +1580,12 @@
     help='Connection interval (in ms)',
 )
 @click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use')
+@click.option('--authenticate', is_flag=True, help='Authenticate (RFComm only)')
+@click.option('--encrypt', is_flag=True, help='Encrypt the connection (RFComm only)')
 @click.pass_context
-def central(ctx, transport, peripheral_address, connection_interval, phy):
+def central(
+    ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
+):
     """Run as a central (initiates the connection)"""
     role_factory = create_role_factory(ctx, 'sender')
     mode_factory = create_mode_factory(ctx, 'gatt-client')
@@ -1186,6 +1600,9 @@
             mode_factory,
             connection_interval,
             phy,
+            authenticate,
+            encrypt or authenticate,
+            ctx.obj['extended_data_length'],
         ).run()
     )
 
@@ -1199,7 +1616,13 @@
     mode_factory = create_mode_factory(ctx, 'gatt-server')
 
     asyncio.run(
-        Peripheral(transport, ctx.obj['classic'], role_factory, mode_factory).run()
+        Peripheral(
+            transport,
+            ctx.obj['classic'],
+            ctx.obj['extended_data_length'],
+            role_factory,
+            mode_factory,
+        ).run()
     )
 
 
diff --git a/apps/ble_rpa_tool.py b/apps/ble_rpa_tool.py
new file mode 100644
index 0000000..07d89a3
--- /dev/null
+++ b/apps/ble_rpa_tool.py
@@ -0,0 +1,63 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import click
+from bumble.colors import color
+from bumble.hci import Address
+from bumble.helpers import generate_irk, verify_rpa_with_irk
+
+
+@click.group()
+def cli():
+    '''
+    This is a tool for generating IRK, RPA,
+    and verifying IRK/RPA pairs
+    '''
+
+
+@click.command()
+def gen_irk() -> None:
+    print(generate_irk().hex())
+
+
+@click.command()
+@click.argument("irk", type=str)
+def gen_rpa(irk: str) -> None:
+    irk_bytes = bytes.fromhex(irk)
+    rpa = Address.generate_private_address(irk_bytes)
+    print(rpa.to_string(with_type_qualifier=False))
+
+
+@click.command()
+@click.argument("irk", type=str)
+@click.argument("rpa", type=str)
+def verify_rpa(irk: str, rpa: str) -> None:
+    address = Address(rpa)
+    irk_bytes = bytes.fromhex(irk)
+    if verify_rpa_with_irk(address, irk_bytes):
+        print(color("Verified", "green"))
+    else:
+        print(color("Not Verified", "red"))
+
+
+def main():
+    cli.add_command(gen_irk)
+    cli.add_command(gen_rpa)
+    cli.add_command(verify_rpa)
+    cli()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    main()
diff --git a/apps/console.py b/apps/console.py
index 9a529dd..5d04636 100644
--- a/apps/console.py
+++ b/apps/console.py
@@ -777,7 +777,7 @@
                 if not service:
                     continue
                 values = [
-                    attribute.read_value(connection)
+                    await attribute.read_value(connection)
                     for connection in self.device.connections.values()
                 ]
                 if not values:
@@ -796,11 +796,11 @@
                 if not characteristic:
                     continue
                 values = [
-                    attribute.read_value(connection)
+                    await attribute.read_value(connection)
                     for connection in self.device.connections.values()
                 ]
                 if not values:
-                    values = [attribute.read_value(None)]
+                    values = [await attribute.read_value(None)]
 
                 # TODO: future optimization: convert CCCD value to human readable string
 
@@ -944,7 +944,7 @@
 
         # send data to any subscribers
         if isinstance(attribute, Characteristic):
-            attribute.write_value(None, value)
+            await attribute.write_value(None, value)
             if attribute.has_properties(Characteristic.NOTIFY):
                 await self.device.gatt_server.notify_subscribers(attribute)
             if attribute.has_properties(Characteristic.INDICATE):
diff --git a/apps/controller_info.py b/apps/controller_info.py
index 5be4f3d..83ac3bb 100644
--- a/apps/controller_info.py
+++ b/apps/controller_info.py
@@ -18,30 +18,39 @@
 import asyncio
 import os
 import logging
-import click
-from bumble.company_ids import COMPANY_IDENTIFIERS
+import time
 
+import click
+
+from bumble.company_ids import COMPANY_IDENTIFIERS
 from bumble.colors import color
 from bumble.core import name_or_number
 from bumble.hci import (
     map_null_terminated_utf8_string,
+    LeFeatureMask,
     HCI_SUCCESS,
-    HCI_LE_SUPPORTED_FEATURES_NAMES,
     HCI_VERSION_NAMES,
     LMP_VERSION_NAMES,
     HCI_Command,
     HCI_Command_Complete_Event,
     HCI_Command_Status_Event,
+    HCI_READ_BUFFER_SIZE_COMMAND,
+    HCI_Read_Buffer_Size_Command,
     HCI_READ_BD_ADDR_COMMAND,
     HCI_Read_BD_ADDR_Command,
     HCI_READ_LOCAL_NAME_COMMAND,
     HCI_Read_Local_Name_Command,
+    HCI_LE_READ_BUFFER_SIZE_COMMAND,
+    HCI_LE_Read_Buffer_Size_Command,
     HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
     HCI_LE_Read_Maximum_Data_Length_Command,
     HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
     HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
     HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
     HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
+    HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
+    HCI_LE_Read_Suggested_Default_Data_Length_Command,
+    HCI_Read_Local_Version_Information_Command,
 )
 from bumble.host import Host
 from bumble.transport import open_transport_or_link
@@ -57,7 +66,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def get_classic_info(host):
+async def get_classic_info(host: Host) -> None:
     if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
         response = await host.send_command(HCI_Read_BD_ADDR_Command())
         if command_succeeded(response):
@@ -78,7 +87,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def get_le_info(host):
+async def get_le_info(host: Host) -> None:
     print()
 
     if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
@@ -117,13 +126,50 @@
                 '\n',
             )
 
+    if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
+        response = await host.send_command(
+            HCI_LE_Read_Suggested_Default_Data_Length_Command()
+        )
+        if command_succeeded(response):
+            print(
+                color('Suggested Default Data Length:', 'yellow'),
+                f'{response.return_parameters.suggested_max_tx_octets}/'
+                f'{response.return_parameters.suggested_max_tx_time}',
+                '\n',
+            )
+
     print(color('LE Features:', 'yellow'))
     for feature in host.supported_le_features:
-        print('  ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
+        print(LeFeatureMask(feature).name)
 
 
 # -----------------------------------------------------------------------------
-async def async_main(transport):
+async def get_acl_flow_control_info(host: Host) -> None:
+    print()
+
+    if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
+        response = await host.send_command(
+            HCI_Read_Buffer_Size_Command(), check_result=True
+        )
+        print(
+            color('ACL Flow Control:', 'yellow'),
+            f'{response.return_parameters.hc_total_num_acl_data_packets} '
+            f'packets of size {response.return_parameters.hc_acl_data_packet_length}',
+        )
+
+    if host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
+        response = await host.send_command(
+            HCI_LE_Read_Buffer_Size_Command(), check_result=True
+        )
+        print(
+            color('LE ACL Flow Control:', 'yellow'),
+            f'{response.return_parameters.hc_total_num_le_acl_data_packets} '
+            f'packets of size {response.return_parameters.hc_le_acl_data_packet_length}',
+        )
+
+
+# -----------------------------------------------------------------------------
+async def async_main(latency_probes, transport):
     print('<<< connecting to HCI...')
     async with await open_transport_or_link(transport) as (hci_source, hci_sink):
         print('<<< connected')
@@ -131,6 +177,23 @@
         host = Host(hci_source, hci_sink)
         await host.reset()
 
+        # Measure the latency if requested
+        latencies = []
+        if latency_probes:
+            for _ in range(latency_probes):
+                start = time.time()
+                await host.send_command(HCI_Read_Local_Version_Information_Command())
+                latencies.append(1000 * (time.time() - start))
+            print(
+                color('HCI Command Latency:', 'yellow'),
+                (
+                    f'min={min(latencies):.2f}, '
+                    f'max={max(latencies):.2f}, '
+                    f'average={sum(latencies)/len(latencies):.2f}'
+                ),
+                '\n',
+            )
+
         # Print version
         print(color('Version:', 'yellow'))
         print(
@@ -154,6 +217,9 @@
         # Get the LE info
         await get_le_info(host)
 
+        # Print the ACL flow control info
+        await get_acl_flow_control_info(host)
+
         # Print the list of commands supported by the controller
         print()
         print(color('Supported Commands:', 'yellow'))
@@ -163,10 +229,16 @@
 
 # -----------------------------------------------------------------------------
 @click.command()
+@click.option(
+    '--latency-probes',
+    metavar='N',
+    type=int,
+    help='Send N commands to measure HCI transport latency statistics',
+)
 @click.argument('transport')
-def main(transport):
+def main(latency_probes, transport):
     logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
-    asyncio.run(async_main(transport))
+    asyncio.run(async_main(latency_probes, transport))
 
 
 # -----------------------------------------------------------------------------
diff --git a/apps/controller_loopback.py b/apps/controller_loopback.py
new file mode 100644
index 0000000..2d16bb9
--- /dev/null
+++ b/apps/controller_loopback.py
@@ -0,0 +1,205 @@
+# Copyright 2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import os
+import time
+from typing import Optional
+from bumble.colors import color
+from bumble.hci import (
+    HCI_READ_LOOPBACK_MODE_COMMAND,
+    HCI_Read_Loopback_Mode_Command,
+    HCI_WRITE_LOOPBACK_MODE_COMMAND,
+    HCI_Write_Loopback_Mode_Command,
+    LoopbackMode,
+)
+from bumble.host import Host
+from bumble.transport import open_transport_or_link
+import click
+
+
+class Loopback:
+    """Send and receive ACL data packets in local loopback mode"""
+
+    def __init__(self, packet_size: int, packet_count: int, transport: str):
+        self.transport = transport
+        self.packet_size = packet_size
+        self.packet_count = packet_count
+        self.connection_handle: Optional[int] = None
+        self.connection_event = asyncio.Event()
+        self.done = asyncio.Event()
+        self.expected_cid = 0
+        self.bytes_received = 0
+        self.start_timestamp = 0.0
+        self.last_timestamp = 0.0
+
+    def on_connection(self, connection_handle: int, *args):
+        """Retrieve connection handle from new connection event"""
+        if not self.connection_event.is_set():
+            # save first connection handle for ACL
+            # subsequent connections are SCO
+            self.connection_handle = connection_handle
+            self.connection_event.set()
+
+    def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes):
+        """Calculate packet receive speed"""
+        now = time.time()
+        print(f'<<< Received packet {cid}: {len(pdu)} bytes')
+        assert connection_handle == self.connection_handle
+        assert cid == self.expected_cid
+        self.expected_cid += 1
+        if cid == 0:
+            self.start_timestamp = now
+        else:
+            elapsed_since_start = now - self.start_timestamp
+            elapsed_since_last = now - self.last_timestamp
+            self.bytes_received += len(pdu)
+            instant_rx_speed = len(pdu) / elapsed_since_last
+            average_rx_speed = self.bytes_received / elapsed_since_start
+            print(
+                color(
+                    f'@@@ RX speed: instant={instant_rx_speed:.4f},'
+                    f' average={average_rx_speed:.4f}',
+                    'cyan',
+                )
+            )
+
+        self.last_timestamp = now
+
+        if self.expected_cid == self.packet_count:
+            print(color('@@@ Received last packet', 'green'))
+            self.done.set()
+
+    async def run(self):
+        """Run a loopback throughput test"""
+        print(color('>>> Connecting to HCI...', 'green'))
+        async with await open_transport_or_link(self.transport) as (
+            hci_source,
+            hci_sink,
+        ):
+            print(color('>>> Connected', 'green'))
+
+            host = Host(hci_source, hci_sink)
+            await host.reset()
+
+            # make sure data can fit in one l2cap pdu
+            l2cap_header_size = 4
+
+            max_packet_size = (
+                host.acl_packet_queue
+                if host.acl_packet_queue
+                else host.le_acl_packet_queue
+            ).max_packet_size - l2cap_header_size
+            if self.packet_size > max_packet_size:
+                print(
+                    color(
+                        f'!!! Packet size ({self.packet_size}) larger than max supported'
+                        f' size ({max_packet_size})',
+                        'red',
+                    )
+                )
+                return
+
+            if not host.supports_command(
+                HCI_WRITE_LOOPBACK_MODE_COMMAND
+            ) or not host.supports_command(HCI_READ_LOOPBACK_MODE_COMMAND):
+                print(color('!!! Loopback mode not supported', 'red'))
+                return
+
+            # set event callbacks
+            host.on('connection', self.on_connection)
+            host.on('l2cap_pdu', self.on_l2cap_pdu)
+
+            loopback_mode = LoopbackMode.LOCAL
+
+            print(color('### Setting loopback mode', 'blue'))
+            await host.send_command(
+                HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL),
+                check_result=True,
+            )
+
+            print(color('### Checking loopback mode', 'blue'))
+            response = await host.send_command(
+                HCI_Read_Loopback_Mode_Command(), check_result=True
+            )
+            if response.return_parameters.loopback_mode != loopback_mode:
+                print(color('!!! Loopback mode mismatch', 'red'))
+                return
+
+            await self.connection_event.wait()
+            print(color('### Connected', 'cyan'))
+
+            print(color('=== Start sending', 'magenta'))
+            start_time = time.time()
+            bytes_sent = 0
+            for cid in range(0, self.packet_count):
+                # using the cid as an incremental index
+                host.send_l2cap_pdu(
+                    self.connection_handle, cid, bytes(self.packet_size)
+                )
+                print(
+                    color(
+                        f'>>> Sending packet {cid}: {self.packet_size} bytes', 'yellow'
+                    )
+                )
+                bytes_sent += self.packet_size  # don't count L2CAP or HCI header sizes
+                await asyncio.sleep(0)  # yield to allow packet receive
+
+            await self.done.wait()
+            print(color('=== Done!', 'magenta'))
+
+            elapsed = time.time() - start_time
+            average_tx_speed = bytes_sent / elapsed
+            print(
+                color(
+                    f'@@@ TX speed: average={average_tx_speed:.4f} ({bytes_sent} bytes'
+                    f' in {elapsed:.2f} seconds)',
+                    'green',
+                )
+            )
+
+
+# -----------------------------------------------------------------------------
+@click.command()
+@click.option(
+    '--packet-size',
+    '-s',
+    metavar='SIZE',
+    type=click.IntRange(8, 4096),
+    default=500,
+    help='Packet size',
+)
+@click.option(
+    '--packet-count',
+    '-c',
+    metavar='COUNT',
+    type=click.IntRange(1, 65535),
+    default=10,
+    help='Packet count',
+)
+@click.argument('transport')
+def main(packet_size, packet_count, transport):
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
+
+    loopback = Loopback(packet_size, packet_count, transport)
+    asyncio.run(loopback.run())
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    main()
diff --git a/apps/l2cap_bridge.py b/apps/l2cap_bridge.py
index 14bd759..7d744bc 100644
--- a/apps/l2cap_bridge.py
+++ b/apps/l2cap_bridge.py
@@ -49,14 +49,16 @@
         self.tcp_port = tcp_port
 
     async def start(self, device: Device) -> None:
-        # Listen for incoming L2CAP CoC connections
+        # Listen for incoming L2CAP channel connections
         device.create_l2cap_server(
             spec=l2cap.LeCreditBasedChannelSpec(
                 psm=self.psm, mtu=self.mtu, mps=self.mps, max_credits=self.max_credits
             ),
-            handler=self.on_coc,
+            handler=self.on_channel,
         )
-        print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
+        print(
+            color(f'### Listening for channel connection on PSM {self.psm}', 'yellow')
+        )
 
         def on_ble_connection(connection):
             def on_ble_disconnection(reason):
@@ -73,7 +75,7 @@
         await device.start_advertising(auto_restart=True)
 
     # Called when a new L2CAP connection is established
-    def on_coc(self, l2cap_channel):
+    def on_channel(self, l2cap_channel):
         print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
 
         class Pipe:
@@ -83,7 +85,7 @@
                 self.l2cap_channel = l2cap_channel
 
                 l2cap_channel.on('close', self.on_l2cap_close)
-                l2cap_channel.sink = self.on_coc_sdu
+                l2cap_channel.sink = self.on_channel_sdu
 
             async def connect_to_tcp(self):
                 # Connect to the TCP server
@@ -128,7 +130,7 @@
                 if self.tcp_transport is not None:
                     self.tcp_transport.close()
 
-            def on_coc_sdu(self, sdu):
+            def on_channel_sdu(self, sdu):
                 print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
                 if self.tcp_transport is None:
                     print(color('!!! TCP socket not open, dropping', 'red'))
@@ -183,7 +185,7 @@
             peer_name = writer.get_extra_info('peer_name')
             print(color(f'<<< TCP connection from {peer_name}', 'magenta'))
 
-            def on_coc_sdu(sdu):
+            def on_channel_sdu(sdu):
                 print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
                 l2cap_to_tcp_pipe.write(sdu)
 
@@ -209,7 +211,7 @@
                 writer.close()
                 return
 
-            l2cap_channel.sink = on_coc_sdu
+            l2cap_channel.sink = on_channel_sdu
             l2cap_channel.on('close', on_l2cap_close)
 
             # Start a flow control pipe from L2CAP to TCP
@@ -274,23 +276,29 @@
 @click.pass_context
 @click.option('--device-config', help='Device configuration file', required=True)
 @click.option('--hci-transport', help='HCI transport', required=True)
-@click.option('--psm', help='PSM for L2CAP CoC', type=int, default=1234)
+@click.option('--psm', help='PSM for L2CAP', type=int, default=1234)
 @click.option(
-    '--l2cap-coc-max-credits',
-    help='Maximum L2CAP CoC Credits',
+    '--l2cap-max-credits',
+    help='Maximum L2CAP Credits',
     type=click.IntRange(1, 65535),
     default=128,
 )
 @click.option(
-    '--l2cap-coc-mtu',
-    help='L2CAP CoC MTU',
-    type=click.IntRange(23, 65535),
-    default=1022,
+    '--l2cap-mtu',
+    help='L2CAP MTU',
+    type=click.IntRange(
+        l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU,
+        l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU,
+    ),
+    default=1024,
 )
 @click.option(
-    '--l2cap-coc-mps',
-    help='L2CAP CoC MPS',
-    type=click.IntRange(23, 65533),
+    '--l2cap-mps',
+    help='L2CAP MPS',
+    type=click.IntRange(
+        l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS,
+        l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS,
+    ),
     default=1024,
 )
 def cli(
@@ -298,17 +306,17 @@
     device_config,
     hci_transport,
     psm,
-    l2cap_coc_max_credits,
-    l2cap_coc_mtu,
-    l2cap_coc_mps,
+    l2cap_max_credits,
+    l2cap_mtu,
+    l2cap_mps,
 ):
     context.ensure_object(dict)
     context.obj['device_config'] = device_config
     context.obj['hci_transport'] = hci_transport
     context.obj['psm'] = psm
-    context.obj['max_credits'] = l2cap_coc_max_credits
-    context.obj['mtu'] = l2cap_coc_mtu
-    context.obj['mps'] = l2cap_coc_mps
+    context.obj['max_credits'] = l2cap_max_credits
+    context.obj['mtu'] = l2cap_mtu
+    context.obj['mps'] = l2cap_mps
 
 
 # -----------------------------------------------------------------------------
diff --git a/apps/pair.py b/apps/pair.py
index 39ee4fe..c1ea332 100644
--- a/apps/pair.py
+++ b/apps/pair.py
@@ -24,10 +24,16 @@
 from bumble.colors import color
 from bumble.device import Device, Peer
 from bumble.transport import open_transport_or_link
-from bumble.pairing import PairingDelegate, PairingConfig
+from bumble.pairing import OobData, PairingDelegate, PairingConfig
+from bumble.smp import OobContext, OobLegacyContext
 from bumble.smp import error_name as smp_error_name
 from bumble.keys import JsonKeyStore
-from bumble.core import ProtocolError
+from bumble.core import (
+    AdvertisingData,
+    ProtocolError,
+    BT_LE_TRANSPORT,
+    BT_BR_EDR_TRANSPORT,
+)
 from bumble.gatt import (
     GATT_DEVICE_NAME_CHARACTERISTIC,
     GATT_GENERIC_ACCESS_SERVICE,
@@ -46,11 +52,13 @@
 class Waiter:
     instance = None
 
-    def __init__(self):
+    def __init__(self, linger=False):
         self.done = asyncio.get_running_loop().create_future()
+        self.linger = linger
 
     def terminate(self):
-        self.done.set_result(None)
+        if not self.linger:
+            self.done.set_result(None)
 
     async def wait_until_terminated(self):
         return await self.done
@@ -60,7 +68,7 @@
 class Delegate(PairingDelegate):
     def __init__(self, mode, connection, capability_string, do_prompt):
         super().__init__(
-            {
+            io_capability={
                 'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
                 'display': PairingDelegate.DISPLAY_OUTPUT_ONLY,
                 'display+keyboard': PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
@@ -285,7 +293,9 @@
     mitm,
     bond,
     ctkd,
+    linger,
     io,
+    oob,
     prompt,
     request,
     print_keys,
@@ -294,7 +304,7 @@
     hci_transport,
     address_or_name,
 ):
-    Waiter.instance = Waiter()
+    Waiter.instance = Waiter(linger=linger)
 
     print('<<< connecting to HCI...')
     async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
@@ -343,16 +353,51 @@
             await device.keystore.print(prefix=color('@@@ ', 'blue'))
             print(color('@@@-----------------------------------', 'blue'))
 
+        # Create an OOB context if needed
+        if oob:
+            our_oob_context = OobContext()
+            shared_data = (
+                None
+                if oob == '-'
+                else OobData.from_ad(AdvertisingData.from_bytes(bytes.fromhex(oob)))
+            )
+            legacy_context = OobLegacyContext()
+            oob_contexts = PairingConfig.OobConfig(
+                our_context=our_oob_context,
+                peer_data=shared_data,
+                legacy_context=legacy_context,
+            )
+            oob_data = OobData(
+                address=device.random_address,
+                shared_data=shared_data,
+                legacy_context=legacy_context,
+            )
+            print(color('@@@-----------------------------------', 'yellow'))
+            print(color('@@@ OOB Data:', 'yellow'))
+            print(color(f'@@@   {our_oob_context.share()}', 'yellow'))
+            print(color(f'@@@   TK={legacy_context.tk.hex()}', 'yellow'))
+            print(color(f'@@@   HEX: ({bytes(oob_data.to_ad()).hex()})', 'yellow'))
+            print(color('@@@-----------------------------------', 'yellow'))
+        else:
+            oob_contexts = None
+
         # Set up a pairing config factory
         device.pairing_config_factory = lambda connection: PairingConfig(
-            sc, mitm, bond, Delegate(mode, connection, io, prompt)
+            sc=sc,
+            mitm=mitm,
+            bonding=bond,
+            oob=oob_contexts,
+            delegate=Delegate(mode, connection, io, prompt),
         )
 
         # Connect to a peer or wait for a connection
         device.on('connection', lambda connection: on_connection(connection, request))
         if address_or_name is not None:
             print(color(f'=== Connecting to {address_or_name}...', 'green'))
-            connection = await device.connect(address_or_name)
+            connection = await device.connect(
+                address_or_name,
+                transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
+            )
 
             if not request:
                 try:
@@ -360,10 +405,9 @@
                         await connection.pair()
                     else:
                         await connection.authenticate()
-                    return
                 except ProtocolError as error:
                     print(color(f'Pairing failed: {error}', 'red'))
-                    return
+
         else:
             if mode == 'le':
                 # Advertise so that peers can find us and connect
@@ -413,6 +457,7 @@
     help='Enable CTKD',
     show_default=True,
 )
+@click.option('--linger', default=False, is_flag=True, help='Linger after pairing')
 @click.option(
     '--io',
     type=click.Choice(
@@ -421,6 +466,14 @@
     default='display+keyboard',
     show_default=True,
 )
+@click.option(
+    '--oob',
+    metavar='<oob-data-hex>',
+    help=(
+        'Use OOB pairing with this data from the peer '
+        '(use "-" to enable OOB without peer data)'
+    ),
+)
 @click.option('--prompt', is_flag=True, help='Prompt to accept/reject pairing request')
 @click.option(
     '--request', is_flag=True, help='Request that the connecting peer initiate pairing'
@@ -440,7 +493,9 @@
     mitm,
     bond,
     ctkd,
+    linger,
     io,
+    oob,
     prompt,
     request,
     print_keys,
@@ -463,7 +518,9 @@
             mitm,
             bond,
             ctkd,
+            linger,
             io,
+            oob,
             prompt,
             request,
             print_keys,
diff --git a/apps/scan.py b/apps/scan.py
index 268912f..9780551 100644
--- a/apps/scan.py
+++ b/apps/scan.py
@@ -26,7 +26,7 @@
 from bumble.keys import JsonKeyStore
 from bumble.smp import AddressResolver
 from bumble.device import Advertisement
-from bumble.hci import HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
+from bumble.hci import Address, HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
 
 
 # -----------------------------------------------------------------------------
@@ -66,10 +66,15 @@
         address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
             address.address_type
         ]
-        if address.is_public:
-            type_color = 'cyan'
+        if address.address_type in (
+            Address.RANDOM_IDENTITY_ADDRESS,
+            Address.PUBLIC_IDENTITY_ADDRESS,
+        ):
+            type_color = 'yellow'
         else:
-            if address.is_static:
+            if address.is_public:
+                type_color = 'cyan'
+            elif address.is_static:
                 type_color = 'green'
                 address_qualifier = '(static)'
             elif address.is_resolvable:
@@ -116,6 +121,7 @@
     phy,
     filter_duplicates,
     raw,
+    irks,
     keystore_file,
     device_config,
     transport,
@@ -140,9 +146,21 @@
 
         if device.keystore:
             resolving_keys = await device.keystore.get_resolving_keys()
-            resolver = AddressResolver(resolving_keys)
         else:
-            resolver = None
+            resolving_keys = []
+
+        for irk_and_address in irks:
+            if ':' not in irk_and_address:
+                raise ValueError('invalid IRK:ADDRESS value')
+            irk_hex, address_str = irk_and_address.split(':', 1)
+            resolving_keys.append(
+                (
+                    bytes.fromhex(irk_hex),
+                    Address(address_str, Address.RANDOM_DEVICE_ADDRESS),
+                )
+            )
+
+        resolver = AddressResolver(resolving_keys) if resolving_keys else None
 
         printer = AdvertisementPrinter(min_rssi, resolver)
         if raw:
@@ -187,8 +205,24 @@
     default=False,
     help='Listen for raw advertising reports instead of processed ones',
 )
-@click.option('--keystore-file', help='Keystore file to use when resolving addresses')
-@click.option('--device-config', help='Device config file for the scanning device')
+@click.option(
+    '--irk',
+    metavar='<IRK_HEX>:<ADDRESS>',
+    help=(
+        'Use this IRK for resolving private addresses ' '(may be used more than once)'
+    ),
+    multiple=True,
+)
+@click.option(
+    '--keystore-file',
+    metavar='FILE_PATH',
+    help='Keystore file to use when resolving addresses',
+)
+@click.option(
+    '--device-config',
+    metavar='FILE_PATH',
+    help='Device config file for the scanning device',
+)
 @click.argument('transport')
 def main(
     min_rssi,
@@ -198,6 +232,7 @@
     phy,
     filter_duplicates,
     raw,
+    irk,
     keystore_file,
     device_config,
     transport,
@@ -212,6 +247,7 @@
             phy,
             filter_duplicates,
             raw,
+            irk,
             keystore_file,
             device_config,
             transport,
diff --git a/apps/show.py b/apps/show.py
index f849e3a..97640a3 100644
--- a/apps/show.py
+++ b/apps/show.py
@@ -15,7 +15,11 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+import datetime
+import logging
+import os
 import struct
+
 import click
 
 from bumble.colors import color
@@ -25,6 +29,14 @@
 
 
 # -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
 class SnoopPacketReader:
     '''
     Reader that reads HCI packets from a "snoop" file (based on RFC 1761, but not
@@ -36,12 +48,18 @@
     DATALINK_BSCP = 1003
     DATALINK_H5 = 1004
 
+    IDENTIFICATION_PATTERN = b'btsnoop\0'
+    TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1)
+    TIMESTAMP_DELTA = 0x00E03AB44A676000
+    ONE_MICROSECOND = datetime.timedelta(microseconds=1)
+
     def __init__(self, source):
         self.source = source
+        self.at_end = False
 
         # Read the header
         identification_pattern = source.read(8)
-        if identification_pattern.hex().lower() != '6274736e6f6f7000':
+        if identification_pattern != self.IDENTIFICATION_PATTERN:
             raise ValueError(
                 'not a valid snoop file, unexpected identification pattern'
             )
@@ -55,19 +73,32 @@
         # Read the record header
         header = self.source.read(24)
         if len(header) < 24:
-            return (0, None)
+            self.at_end = True
+            return (None, 0, None)
+
+        # Parse the header
         (
             original_length,
             included_length,
             packet_flags,
             _cumulative_drops,
-            _timestamp_seconds,
-            _timestamp_microsecond,
-        ) = struct.unpack('>IIIIII', header)
+            timestamp,
+        ) = struct.unpack('>IIIIQ', header)
 
-        # Abort on truncated packets
+        # Skip truncated packets
         if original_length != included_length:
-            return (0, None)
+            print(
+                color(
+                    f"!!! truncated packet ({included_length}/{original_length})", "red"
+                )
+            )
+            self.source.read(included_length)
+            return (None, 0, None)
+
+        # Convert the timestamp to a datetime object.
+        ts_dt = self.TIMESTAMP_ANCHOR + datetime.timedelta(
+            microseconds=timestamp - self.TIMESTAMP_DELTA
+        )
 
         if self.data_link_type == self.DATALINK_H1:
             # The packet is un-encapsulated, look at the flags to figure out its type
@@ -89,7 +120,17 @@
                 bytes([packet_type]) + self.source.read(included_length),
             )
 
-        return (packet_flags & 1, self.source.read(included_length))
+        return (ts_dt, packet_flags & 1, self.source.read(included_length))
+
+
+# -----------------------------------------------------------------------------
+class Printer:
+    def __init__(self):
+        self.index = 0
+
+    def print(self, message: str) -> None:
+        self.index += 1
+        print(f"[{self.index:8}]{message}")
 
 
 # -----------------------------------------------------------------------------
@@ -122,24 +163,28 @@
         packet_reader = PacketReader(input)
 
         def read_next_packet():
-            return (0, packet_reader.next_packet())
+            return (None, 0, packet_reader.next_packet())
 
     else:
         packet_reader = SnoopPacketReader(input)
         read_next_packet = packet_reader.next_packet
 
-    tracer = PacketTracer(emit_message=print)
+    printer = Printer()
+    tracer = PacketTracer(emit_message=printer.print)
 
-    while True:
+    while not packet_reader.at_end:
         try:
-            (direction, packet) = read_next_packet()
-            if packet is None:
-                break
-            tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
+            (timestamp, direction, packet) = read_next_packet()
+            if packet:
+                tracer.trace(hci.HCI_Packet.from_bytes(packet), direction, timestamp)
+            else:
+                printer.print(color("[TRUNCATED]", "red"))
         except Exception as error:
+            logger.exception()
             print(color(f'!!! {error}', 'red'))
 
 
 # -----------------------------------------------------------------------------
 if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
     main()  # pylint: disable=no-value-for-parameter
diff --git a/bumble/a2dp.py b/bumble/a2dp.py
index eeecb1e..653a042 100644
--- a/bumble/a2dp.py
+++ b/bumble/a2dp.py
@@ -15,9 +15,13 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
+
+import dataclasses
 import struct
 import logging
-from collections import namedtuple
+from collections.abc import AsyncGenerator
+from typing import List, Callable, Awaitable
 
 from .company_ids import COMPANY_IDENTIFIERS
 from .sdp import (
@@ -180,8 +184,12 @@
             SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
             DataElement.sequence(
                 [
-                    DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
-                    DataElement.unsigned_integer_16(version_int),
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
+                            DataElement.unsigned_integer_16(version_int),
+                        ]
+                    )
                 ]
             ),
         ),
@@ -230,8 +238,12 @@
             SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
             DataElement.sequence(
                 [
-                    DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
-                    DataElement.unsigned_integer_16(version_int),
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
+                            DataElement.unsigned_integer_16(version_int),
+                        ]
+                    )
                 ]
             ),
         ),
@@ -239,24 +251,20 @@
 
 
 # -----------------------------------------------------------------------------
-class SbcMediaCodecInformation(
-    namedtuple(
-        'SbcMediaCodecInformation',
-        [
-            'sampling_frequency',
-            'channel_mode',
-            'block_length',
-            'subbands',
-            'allocation_method',
-            'minimum_bitpool_value',
-            'maximum_bitpool_value',
-        ],
-    )
-):
+@dataclasses.dataclass
+class SbcMediaCodecInformation:
     '''
     A2DP spec - 4.3.2 Codec Specific Information Elements
     '''
 
+    sampling_frequency: int
+    channel_mode: int
+    block_length: int
+    subbands: int
+    allocation_method: int
+    minimum_bitpool_value: int
+    maximum_bitpool_value: int
+
     SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
     CHANNEL_MODE_BITS = {
         SBC_MONO_CHANNEL_MODE: 1 << 3,
@@ -272,7 +280,7 @@
     }
 
     @staticmethod
-    def from_bytes(data: bytes) -> 'SbcMediaCodecInformation':
+    def from_bytes(data: bytes) -> SbcMediaCodecInformation:
         sampling_frequency = (data[0] >> 4) & 0x0F
         channel_mode = (data[0] >> 0) & 0x0F
         block_length = (data[1] >> 4) & 0x0F
@@ -293,14 +301,14 @@
     @classmethod
     def from_discrete_values(
         cls,
-        sampling_frequency,
-        channel_mode,
-        block_length,
-        subbands,
-        allocation_method,
-        minimum_bitpool_value,
-        maximum_bitpool_value,
-    ):
+        sampling_frequency: int,
+        channel_mode: int,
+        block_length: int,
+        subbands: int,
+        allocation_method: int,
+        minimum_bitpool_value: int,
+        maximum_bitpool_value: int,
+    ) -> SbcMediaCodecInformation:
         return SbcMediaCodecInformation(
             sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
             channel_mode=cls.CHANNEL_MODE_BITS[channel_mode],
@@ -314,14 +322,14 @@
     @classmethod
     def from_lists(
         cls,
-        sampling_frequencies,
-        channel_modes,
-        block_lengths,
-        subbands,
-        allocation_methods,
-        minimum_bitpool_value,
-        maximum_bitpool_value,
-    ):
+        sampling_frequencies: List[int],
+        channel_modes: List[int],
+        block_lengths: List[int],
+        subbands: List[int],
+        allocation_methods: List[int],
+        minimum_bitpool_value: int,
+        maximum_bitpool_value: int,
+    ) -> SbcMediaCodecInformation:
         return SbcMediaCodecInformation(
             sampling_frequency=sum(
                 cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
@@ -348,7 +356,7 @@
             ]
         )
 
-    def __str__(self):
+    def __str__(self) -> str:
         channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
         allocation_methods = ['SNR', 'Loudness']
         return '\n'.join(
@@ -367,16 +375,19 @@
 
 
 # -----------------------------------------------------------------------------
-class AacMediaCodecInformation(
-    namedtuple(
-        'AacMediaCodecInformation',
-        ['object_type', 'sampling_frequency', 'channels', 'rfa', 'vbr', 'bitrate'],
-    )
-):
+@dataclasses.dataclass
+class AacMediaCodecInformation:
     '''
     A2DP spec - 4.5.2 Codec Specific Information Elements
     '''
 
+    object_type: int
+    sampling_frequency: int
+    channels: int
+    rfa: int
+    vbr: int
+    bitrate: int
+
     OBJECT_TYPE_BITS = {
         MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
         MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
@@ -400,7 +411,7 @@
     CHANNELS_BITS = {1: 1 << 1, 2: 1}
 
     @staticmethod
-    def from_bytes(data: bytes) -> 'AacMediaCodecInformation':
+    def from_bytes(data: bytes) -> AacMediaCodecInformation:
         object_type = data[0]
         sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
         channels = (data[2] >> 2) & 0x03
@@ -413,8 +424,13 @@
 
     @classmethod
     def from_discrete_values(
-        cls, object_type, sampling_frequency, channels, vbr, bitrate
-    ):
+        cls,
+        object_type: int,
+        sampling_frequency: int,
+        channels: int,
+        vbr: int,
+        bitrate: int,
+    ) -> AacMediaCodecInformation:
         return AacMediaCodecInformation(
             object_type=cls.OBJECT_TYPE_BITS[object_type],
             sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
@@ -425,7 +441,14 @@
         )
 
     @classmethod
-    def from_lists(cls, object_types, sampling_frequencies, channels, vbr, bitrate):
+    def from_lists(
+        cls,
+        object_types: List[int],
+        sampling_frequencies: List[int],
+        channels: List[int],
+        vbr: int,
+        bitrate: int,
+    ) -> AacMediaCodecInformation:
         return AacMediaCodecInformation(
             object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
             sampling_frequency=sum(
@@ -449,7 +472,7 @@
             ]
         )
 
-    def __str__(self):
+    def __str__(self) -> str:
         object_types = [
             'MPEG_2_AAC_LC',
             'MPEG_4_AAC_LC',
@@ -474,26 +497,26 @@
         )
 
 
+@dataclasses.dataclass
 # -----------------------------------------------------------------------------
 class VendorSpecificMediaCodecInformation:
     '''
     A2DP spec - 4.7.2 Codec Specific Information Elements
     '''
 
+    vendor_id: int
+    codec_id: int
+    value: bytes
+
     @staticmethod
-    def from_bytes(data):
+    def from_bytes(data: bytes) -> VendorSpecificMediaCodecInformation:
         (vendor_id, codec_id) = struct.unpack_from('<IH', data, 0)
         return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
 
-    def __init__(self, vendor_id, codec_id, value):
-        self.vendor_id = vendor_id
-        self.codec_id = codec_id
-        self.value = value
-
-    def __bytes__(self):
+    def __bytes__(self) -> bytes:
         return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
 
-    def __str__(self):
+    def __str__(self) -> str:
         # pylint: disable=line-too-long
         return '\n'.join(
             [
@@ -506,29 +529,27 @@
 
 
 # -----------------------------------------------------------------------------
+@dataclasses.dataclass
 class SbcFrame:
-    def __init__(
-        self, sampling_frequency, block_count, channel_mode, subband_count, payload
-    ):
-        self.sampling_frequency = sampling_frequency
-        self.block_count = block_count
-        self.channel_mode = channel_mode
-        self.subband_count = subband_count
-        self.payload = payload
+    sampling_frequency: int
+    block_count: int
+    channel_mode: int
+    subband_count: int
+    payload: bytes
 
     @property
-    def sample_count(self):
+    def sample_count(self) -> int:
         return self.subband_count * self.block_count
 
     @property
-    def bitrate(self):
+    def bitrate(self) -> int:
         return 8 * ((len(self.payload) * self.sampling_frequency) // self.sample_count)
 
     @property
-    def duration(self):
+    def duration(self) -> float:
         return self.sample_count / self.sampling_frequency
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'SBC(sf={self.sampling_frequency},'
             f'cm={self.channel_mode},'
@@ -540,12 +561,12 @@
 
 # -----------------------------------------------------------------------------
 class SbcParser:
-    def __init__(self, read):
+    def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
         self.read = read
 
     @property
-    def frames(self):
-        async def generate_frames():
+    def frames(self) -> AsyncGenerator[SbcFrame, None]:
+        async def generate_frames() -> AsyncGenerator[SbcFrame, None]:
             while True:
                 # Read 4 bytes of header
                 header = await self.read(4)
@@ -589,7 +610,9 @@
 
 # -----------------------------------------------------------------------------
 class SbcPacketSource:
-    def __init__(self, read, mtu, codec_capabilities):
+    def __init__(
+        self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
+    ) -> None:
         self.read = read
         self.mtu = mtu
         self.codec_capabilities = codec_capabilities
diff --git a/bumble/att.py b/bumble/att.py
index db8d2ba..2bec4ea 100644
--- a/bumble/att.py
+++ b/bumble/att.py
@@ -25,9 +25,21 @@
 from __future__ import annotations
 import enum
 import functools
+import inspect
 import struct
+from typing import (
+    Any,
+    Awaitable,
+    Callable,
+    Dict,
+    List,
+    Optional,
+    Type,
+    Union,
+    TYPE_CHECKING,
+)
+
 from pyee import EventEmitter
-from typing import Dict, Type, List, Protocol, Union, Optional, Any, TYPE_CHECKING
 
 from bumble.core import UUID, name_or_number, ProtocolError
 from bumble.hci import HCI_Object, key_with_value
@@ -722,12 +734,38 @@
 
 
 # -----------------------------------------------------------------------------
-class ConnectionValue(Protocol):
-    def read(self, connection) -> bytes:
-        ...
+class AttributeValue:
+    '''
+    Attribute value where reading and/or writing is delegated to functions
+    passed as arguments to the constructor.
+    '''
 
-    def write(self, connection, value: bytes) -> None:
-        ...
+    def __init__(
+        self,
+        read: Union[
+            Callable[[Optional[Connection]], bytes],
+            Callable[[Optional[Connection]], Awaitable[bytes]],
+            None,
+        ] = None,
+        write: Union[
+            Callable[[Optional[Connection], bytes], None],
+            Callable[[Optional[Connection], bytes], Awaitable[None]],
+            None,
+        ] = None,
+    ):
+        self._read = read
+        self._write = write
+
+    def read(self, connection: Optional[Connection]) -> Union[bytes, Awaitable[bytes]]:
+        return self._read(connection) if self._read else b''
+
+    def write(
+        self, connection: Optional[Connection], value: bytes
+    ) -> Union[Awaitable[None], None]:
+        if self._write:
+            return self._write(connection, value)
+
+        return None
 
 
 # -----------------------------------------------------------------------------
@@ -770,13 +808,13 @@
     READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
     WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
 
-    value: Union[str, bytes, ConnectionValue]
+    value: Union[bytes, AttributeValue]
 
     def __init__(
         self,
         attribute_type: Union[str, bytes, UUID],
         permissions: Union[str, Attribute.Permissions],
-        value: Union[str, bytes, ConnectionValue] = b'',
+        value: Union[str, bytes, AttributeValue] = b'',
     ) -> None:
         EventEmitter.__init__(self)
         self.handle = 0
@@ -806,7 +844,7 @@
     def decode_value(self, value_bytes: bytes) -> Any:
         return value_bytes
 
-    def read_value(self, connection: Optional[Connection]) -> bytes:
+    async def read_value(self, connection: Optional[Connection]) -> bytes:
         if (
             (self.permissions & self.READ_REQUIRES_ENCRYPTION)
             and connection is not None
@@ -832,6 +870,8 @@
         if hasattr(self.value, 'read'):
             try:
                 value = self.value.read(connection)
+                if inspect.isawaitable(value):
+                    value = await value
             except ATT_Error as error:
                 raise ATT_Error(
                     error_code=error.error_code, att_handle=self.handle
@@ -841,7 +881,7 @@
 
         return self.encode_value(value)
 
-    def write_value(self, connection: Connection, value_bytes: bytes) -> None:
+    async def write_value(self, connection: Connection, value_bytes: bytes) -> None:
         if (
             self.permissions & self.WRITE_REQUIRES_ENCRYPTION
         ) and not connection.encryption:
@@ -864,7 +904,9 @@
 
         if hasattr(self.value, 'write'):
             try:
-                self.value.write(connection, value)  # pylint: disable=not-callable
+                result = self.value.write(connection, value)
+                if inspect.isawaitable(result):
+                    await result
             except ATT_Error as error:
                 raise ATT_Error(
                     error_code=error.error_code, att_handle=self.handle
diff --git a/bumble/avc.py b/bumble/avc.py
new file mode 100644
index 0000000..1d0a7dc
--- /dev/null
+++ b/bumble/avc.py
@@ -0,0 +1,520 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import enum
+import struct
+from typing import Dict, Type, Union, Tuple
+
+from bumble.utils import OpenIntEnum
+
+
+# -----------------------------------------------------------------------------
+class Frame:
+    class SubunitType(enum.IntEnum):
+        # AV/C Digital Interface Command Set General Specification Version 4.1
+        # Table 7.4
+        MONITOR = 0x00
+        AUDIO = 0x01
+        PRINTER = 0x02
+        DISC = 0x03
+        TAPE_RECORDER_OR_PLAYER = 0x04
+        TUNER = 0x05
+        CA = 0x06
+        CAMERA = 0x07
+        PANEL = 0x09
+        BULLETIN_BOARD = 0x0A
+        VENDOR_UNIQUE = 0x1C
+        EXTENDED = 0x1E
+        UNIT = 0x1F
+
+    class OperationCode(OpenIntEnum):
+        # 0x00 - 0x0F: Unit and subunit commands
+        VENDOR_DEPENDENT = 0x00
+        RESERVE = 0x01
+        PLUG_INFO = 0x02
+
+        # 0x10 - 0x3F: Unit commands
+        DIGITAL_OUTPUT = 0x10
+        DIGITAL_INPUT = 0x11
+        CHANNEL_USAGE = 0x12
+        OUTPUT_PLUG_SIGNAL_FORMAT = 0x18
+        INPUT_PLUG_SIGNAL_FORMAT = 0x19
+        GENERAL_BUS_SETUP = 0x1F
+        CONNECT_AV = 0x20
+        DISCONNECT_AV = 0x21
+        CONNECTIONS = 0x22
+        CONNECT = 0x24
+        DISCONNECT = 0x25
+        UNIT_INFO = 0x30
+        SUBUNIT_INFO = 0x31
+
+        # 0x40 - 0x7F: Subunit commands
+        PASS_THROUGH = 0x7C
+        GUI_UPDATE = 0x7D
+        PUSH_GUI_DATA = 0x7E
+        USER_ACTION = 0x7F
+
+        # 0xA0 - 0xBF: Unit and subunit commands
+        VERSION = 0xB0
+        POWER = 0xB2
+
+    subunit_type: SubunitType
+    subunit_id: int
+    opcode: OperationCode
+    operands: bytes
+
+    @staticmethod
+    def subclass(subclass):
+        # Infer the opcode from the class name
+        if subclass.__name__.endswith("CommandFrame"):
+            short_name = subclass.__name__.replace("CommandFrame", "")
+            category_class = CommandFrame
+        elif subclass.__name__.endswith("ResponseFrame"):
+            short_name = subclass.__name__.replace("ResponseFrame", "")
+            category_class = ResponseFrame
+        else:
+            raise ValueError(f"invalid subclass name {subclass.__name__}")
+
+        uppercase_indexes = [
+            i for i in range(len(short_name)) if short_name[i].isupper()
+        ]
+        uppercase_indexes.append(len(short_name))
+        words = [
+            short_name[uppercase_indexes[i] : uppercase_indexes[i + 1]].upper()
+            for i in range(len(uppercase_indexes) - 1)
+        ]
+        opcode_name = "_".join(words)
+        opcode = Frame.OperationCode[opcode_name]
+        category_class.subclasses[opcode] = subclass
+        return subclass
+
+    @staticmethod
+    def from_bytes(data: bytes) -> Frame:
+        if data[0] >> 4 != 0:
+            raise ValueError("first 4 bits must be 0s")
+
+        ctype_or_response = data[0] & 0xF
+        subunit_type = Frame.SubunitType(data[1] >> 3)
+        subunit_id = data[1] & 7
+
+        if subunit_type == Frame.SubunitType.EXTENDED:
+            # Not supported
+            raise NotImplementedError("extended subunit types not supported")
+
+        if subunit_id < 5:
+            opcode_offset = 2
+        elif subunit_id == 5:
+            # Extended to the next byte
+            extension = data[2]
+            if extension == 0:
+                raise ValueError("extended subunit ID value reserved")
+            if extension == 0xFF:
+                subunit_id = 5 + 254 + data[3]
+                opcode_offset = 4
+            else:
+                subunit_id = 5 + extension
+                opcode_offset = 3
+
+        elif subunit_id == 6:
+            raise ValueError("reserved subunit ID")
+
+        opcode = Frame.OperationCode(data[opcode_offset])
+        operands = data[opcode_offset + 1 :]
+
+        # Look for a registered subclass
+        if ctype_or_response < 8:
+            # Command
+            ctype = CommandFrame.CommandType(ctype_or_response)
+            if c_subclass := CommandFrame.subclasses.get(opcode):
+                return c_subclass(
+                    ctype,
+                    subunit_type,
+                    subunit_id,
+                    *c_subclass.parse_operands(operands),
+                )
+            return CommandFrame(ctype, subunit_type, subunit_id, opcode, operands)
+        else:
+            # Response
+            response = ResponseFrame.ResponseCode(ctype_or_response)
+            if r_subclass := ResponseFrame.subclasses.get(opcode):
+                return r_subclass(
+                    response,
+                    subunit_type,
+                    subunit_id,
+                    *r_subclass.parse_operands(operands),
+                )
+            return ResponseFrame(response, subunit_type, subunit_id, opcode, operands)
+
+    def to_bytes(
+        self,
+        ctype_or_response: Union[CommandFrame.CommandType, ResponseFrame.ResponseCode],
+    ) -> bytes:
+        # TODO: support extended subunit types and ids.
+        return (
+            bytes(
+                [
+                    ctype_or_response,
+                    self.subunit_type << 3 | self.subunit_id,
+                    self.opcode,
+                ]
+            )
+            + self.operands
+        )
+
+    def to_string(self, extra: str) -> str:
+        return (
+            f"{self.__class__.__name__}({extra}"
+            f"subunit_type={self.subunit_type.name}, "
+            f"subunit_id=0x{self.subunit_id:02X}, "
+            f"opcode={self.opcode.name}, "
+            f"operands={self.operands.hex()})"
+        )
+
+    def __init__(
+        self,
+        subunit_type: SubunitType,
+        subunit_id: int,
+        opcode: OperationCode,
+        operands: bytes,
+    ) -> None:
+        self.subunit_type = subunit_type
+        self.subunit_id = subunit_id
+        self.opcode = opcode
+        self.operands = operands
+
+
+# -----------------------------------------------------------------------------
+class CommandFrame(Frame):
+    class CommandType(OpenIntEnum):
+        # AV/C Digital Interface Command Set General Specification Version 4.1
+        # Table 7.1
+        CONTROL = 0x00
+        STATUS = 0x01
+        SPECIFIC_INQUIRY = 0x02
+        NOTIFY = 0x03
+        GENERAL_INQUIRY = 0x04
+
+    subclasses: Dict[Frame.OperationCode, Type[CommandFrame]] = {}
+    ctype: CommandType
+
+    @staticmethod
+    def parse_operands(operands: bytes) -> Tuple:
+        raise NotImplementedError
+
+    def __init__(
+        self,
+        ctype: CommandType,
+        subunit_type: Frame.SubunitType,
+        subunit_id: int,
+        opcode: Frame.OperationCode,
+        operands: bytes,
+    ) -> None:
+        super().__init__(subunit_type, subunit_id, opcode, operands)
+        self.ctype = ctype
+
+    def __bytes__(self):
+        return self.to_bytes(self.ctype)
+
+    def __str__(self):
+        return self.to_string(f"ctype={self.ctype.name}, ")
+
+
+# -----------------------------------------------------------------------------
+class ResponseFrame(Frame):
+    class ResponseCode(OpenIntEnum):
+        # AV/C Digital Interface Command Set General Specification Version 4.1
+        # Table 7.2
+        NOT_IMPLEMENTED = 0x08
+        ACCEPTED = 0x09
+        REJECTED = 0x0A
+        IN_TRANSITION = 0x0B
+        IMPLEMENTED_OR_STABLE = 0x0C
+        CHANGED = 0x0D
+        INTERIM = 0x0F
+
+    subclasses: Dict[Frame.OperationCode, Type[ResponseFrame]] = {}
+    response: ResponseCode
+
+    @staticmethod
+    def parse_operands(operands: bytes) -> Tuple:
+        raise NotImplementedError
+
+    def __init__(
+        self,
+        response: ResponseCode,
+        subunit_type: Frame.SubunitType,
+        subunit_id: int,
+        opcode: Frame.OperationCode,
+        operands: bytes,
+    ) -> None:
+        super().__init__(subunit_type, subunit_id, opcode, operands)
+        self.response = response
+
+    def __bytes__(self):
+        return self.to_bytes(self.response)
+
+    def __str__(self):
+        return self.to_string(f"response={self.response.name}, ")
+
+
+# -----------------------------------------------------------------------------
+class VendorDependentFrame:
+    company_id: int
+    vendor_dependent_data: bytes
+
+    @staticmethod
+    def parse_operands(operands: bytes) -> Tuple:
+        return (
+            struct.unpack(">I", b"\x00" + operands[:3])[0],
+            operands[3:],
+        )
+
+    def make_operands(self) -> bytes:
+        return struct.pack(">I", self.company_id)[1:] + self.vendor_dependent_data
+
+    def __init__(self, company_id: int, vendor_dependent_data: bytes):
+        self.company_id = company_id
+        self.vendor_dependent_data = vendor_dependent_data
+
+
+# -----------------------------------------------------------------------------
+@Frame.subclass
+class VendorDependentCommandFrame(VendorDependentFrame, CommandFrame):
+    def __init__(
+        self,
+        ctype: CommandFrame.CommandType,
+        subunit_type: Frame.SubunitType,
+        subunit_id: int,
+        company_id: int,
+        vendor_dependent_data: bytes,
+    ) -> None:
+        VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
+        CommandFrame.__init__(
+            self,
+            ctype,
+            subunit_type,
+            subunit_id,
+            Frame.OperationCode.VENDOR_DEPENDENT,
+            self.make_operands(),
+        )
+
+    def __str__(self):
+        return (
+            f"VendorDependentCommandFrame(ctype={self.ctype.name}, "
+            f"subunit_type={self.subunit_type.name}, "
+            f"subunit_id=0x{self.subunit_id:02X}, "
+            f"company_id=0x{self.company_id:06X}, "
+            f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
+        )
+
+
+# -----------------------------------------------------------------------------
+@Frame.subclass
+class VendorDependentResponseFrame(VendorDependentFrame, ResponseFrame):
+    def __init__(
+        self,
+        response: ResponseFrame.ResponseCode,
+        subunit_type: Frame.SubunitType,
+        subunit_id: int,
+        company_id: int,
+        vendor_dependent_data: bytes,
+    ) -> None:
+        VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
+        ResponseFrame.__init__(
+            self,
+            response,
+            subunit_type,
+            subunit_id,
+            Frame.OperationCode.VENDOR_DEPENDENT,
+            self.make_operands(),
+        )
+
+    def __str__(self):
+        return (
+            f"VendorDependentResponseFrame(response={self.response.name}, "
+            f"subunit_type={self.subunit_type.name}, "
+            f"subunit_id=0x{self.subunit_id:02X}, "
+            f"company_id=0x{self.company_id:06X}, "
+            f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
+        )
+
+
+# -----------------------------------------------------------------------------
+class PassThroughFrame:
+    """
+    See AV/C Panel Subunit Specification 1.1 - 9.4 PASS THROUGH control command
+    """
+
+    class StateFlag(enum.IntEnum):
+        PRESSED = 0
+        RELEASED = 1
+
+    class OperationId(OpenIntEnum):
+        SELECT = 0x00
+        UP = 0x01
+        DOWN = 0x01
+        LEFT = 0x03
+        RIGHT = 0x04
+        RIGHT_UP = 0x05
+        RIGHT_DOWN = 0x06
+        LEFT_UP = 0x07
+        LEFT_DOWN = 0x08
+        ROOT_MENU = 0x09
+        SETUP_MENU = 0x0A
+        CONTENTS_MENU = 0x0B
+        FAVORITE_MENU = 0x0C
+        EXIT = 0x0D
+        NUMBER_0 = 0x20
+        NUMBER_1 = 0x21
+        NUMBER_2 = 0x22
+        NUMBER_3 = 0x23
+        NUMBER_4 = 0x24
+        NUMBER_5 = 0x25
+        NUMBER_6 = 0x26
+        NUMBER_7 = 0x27
+        NUMBER_8 = 0x28
+        NUMBER_9 = 0x29
+        DOT = 0x2A
+        ENTER = 0x2B
+        CLEAR = 0x2C
+        CHANNEL_UP = 0x30
+        CHANNEL_DOWN = 0x31
+        PREVIOUS_CHANNEL = 0x32
+        SOUND_SELECT = 0x33
+        INPUT_SELECT = 0x34
+        DISPLAY_INFORMATION = 0x35
+        HELP = 0x36
+        PAGE_UP = 0x37
+        PAGE_DOWN = 0x38
+        POWER = 0x40
+        VOLUME_UP = 0x41
+        VOLUME_DOWN = 0x42
+        MUTE = 0x43
+        PLAY = 0x44
+        STOP = 0x45
+        PAUSE = 0x46
+        RECORD = 0x47
+        REWIND = 0x48
+        FAST_FORWARD = 0x49
+        EJECT = 0x4A
+        FORWARD = 0x4B
+        BACKWARD = 0x4C
+        ANGLE = 0x50
+        SUBPICTURE = 0x51
+        F1 = 0x71
+        F2 = 0x72
+        F3 = 0x73
+        F4 = 0x74
+        F5 = 0x75
+        VENDOR_UNIQUE = 0x7E
+
+    state_flag: StateFlag
+    operation_id: OperationId
+    operation_data: bytes
+
+    @staticmethod
+    def parse_operands(operands: bytes) -> Tuple:
+        return (
+            PassThroughFrame.StateFlag(operands[0] >> 7),
+            PassThroughFrame.OperationId(operands[0] & 0x7F),
+            operands[1 : 1 + operands[1]],
+        )
+
+    def make_operands(self):
+        return (
+            bytes([self.state_flag << 7 | self.operation_id, len(self.operation_data)])
+            + self.operation_data
+        )
+
+    def __init__(
+        self,
+        state_flag: StateFlag,
+        operation_id: OperationId,
+        operation_data: bytes,
+    ) -> None:
+        if len(operation_data) > 255:
+            raise ValueError("operation data must be <= 255 bytes")
+        self.state_flag = state_flag
+        self.operation_id = operation_id
+        self.operation_data = operation_data
+
+
+# -----------------------------------------------------------------------------
+@Frame.subclass
+class PassThroughCommandFrame(PassThroughFrame, CommandFrame):
+    def __init__(
+        self,
+        ctype: CommandFrame.CommandType,
+        subunit_type: Frame.SubunitType,
+        subunit_id: int,
+        state_flag: PassThroughFrame.StateFlag,
+        operation_id: PassThroughFrame.OperationId,
+        operation_data: bytes,
+    ) -> None:
+        PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
+        CommandFrame.__init__(
+            self,
+            ctype,
+            subunit_type,
+            subunit_id,
+            Frame.OperationCode.PASS_THROUGH,
+            self.make_operands(),
+        )
+
+    def __str__(self):
+        return (
+            f"PassThroughCommandFrame(ctype={self.ctype.name}, "
+            f"subunit_type={self.subunit_type.name}, "
+            f"subunit_id=0x{self.subunit_id:02X}, "
+            f"state_flag={self.state_flag.name}, "
+            f"operation_id={self.operation_id.name}, "
+            f"operation_data={self.operation_data.hex()})"
+        )
+
+
+# -----------------------------------------------------------------------------
+@Frame.subclass
+class PassThroughResponseFrame(PassThroughFrame, ResponseFrame):
+    def __init__(
+        self,
+        response: ResponseFrame.ResponseCode,
+        subunit_type: Frame.SubunitType,
+        subunit_id: int,
+        state_flag: PassThroughFrame.StateFlag,
+        operation_id: PassThroughFrame.OperationId,
+        operation_data: bytes,
+    ) -> None:
+        PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
+        ResponseFrame.__init__(
+            self,
+            response,
+            subunit_type,
+            subunit_id,
+            Frame.OperationCode.PASS_THROUGH,
+            self.make_operands(),
+        )
+
+    def __str__(self):
+        return (
+            f"PassThroughResponseFrame(response={self.response.name}, "
+            f"subunit_type={self.subunit_type.name}, "
+            f"subunit_id=0x{self.subunit_id:02X}, "
+            f"state_flag={self.state_flag.name}, "
+            f"operation_id={self.operation_id.name}, "
+            f"operation_data={self.operation_data.hex()})"
+        )
diff --git a/bumble/avctp.py b/bumble/avctp.py
new file mode 100644
index 0000000..2271324
--- /dev/null
+++ b/bumble/avctp.py
@@ -0,0 +1,291 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+from enum import IntEnum
+import logging
+import struct
+from typing import Callable, cast, Dict, Optional
+
+from bumble.colors import color
+from bumble import avc
+from bumble import l2cap
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+AVCTP_PSM = 0x0017
+AVCTP_BROWSING_PSM = 0x001B
+
+
+# -----------------------------------------------------------------------------
+class MessageAssembler:
+    Callback = Callable[[int, bool, bool, int, bytes], None]
+
+    transaction_label: int
+    pid: int
+    c_r: int
+    ipid: int
+    payload: bytes
+    number_of_packets: int
+    packets_received: int
+
+    def __init__(self, callback: Callback) -> None:
+        self.callback = callback
+        self.reset()
+
+    def reset(self) -> None:
+        self.packets_received = 0
+        self.transaction_label = -1
+        self.pid = -1
+        self.c_r = -1
+        self.ipid = -1
+        self.payload = b''
+        self.number_of_packets = 0
+        self.packet_count = 0
+
+    def on_pdu(self, pdu: bytes) -> None:
+        self.packets_received += 1
+
+        transaction_label = pdu[0] >> 4
+        packet_type = Protocol.PacketType((pdu[0] >> 2) & 3)
+        c_r = (pdu[0] >> 1) & 1
+        ipid = pdu[0] & 1
+
+        if c_r == 0 and ipid != 0:
+            logger.warning("invalid IPID in command frame")
+            self.reset()
+            return
+
+        pid_offset = 1
+        if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.START):
+            if self.transaction_label >= 0:
+                # We are already in a transaction
+                logger.warning("received START or SINGLE fragment while in transaction")
+                self.reset()
+                self.packets_received = 1
+
+            if packet_type == Protocol.PacketType.START:
+                self.number_of_packets = pdu[1]
+                pid_offset = 2
+
+        pid = struct.unpack_from(">H", pdu, pid_offset)[0]
+        self.payload += pdu[pid_offset + 2 :]
+
+        if packet_type in (Protocol.PacketType.CONTINUE, Protocol.PacketType.END):
+            if transaction_label != self.transaction_label:
+                logger.warning("transaction label does not match")
+                self.reset()
+                return
+
+            if pid != self.pid:
+                logger.warning("PID does not match")
+                self.reset()
+                return
+
+            if c_r != self.c_r:
+                logger.warning("C/R does not match")
+                self.reset()
+                return
+
+            if self.packets_received > self.number_of_packets:
+                logger.warning("too many fragments in transaction")
+                self.reset()
+                return
+
+            if packet_type == Protocol.PacketType.END:
+                if self.packets_received != self.number_of_packets:
+                    logger.warning("premature END")
+                    self.reset()
+                    return
+        else:
+            self.transaction_label = transaction_label
+            self.c_r = c_r
+            self.ipid = ipid
+            self.pid = pid
+
+        if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.END):
+            self.on_message_complete()
+
+    def on_message_complete(self):
+        try:
+            self.callback(
+                self.transaction_label,
+                self.c_r == 0,
+                self.ipid != 0,
+                self.pid,
+                self.payload,
+            )
+        except Exception as error:
+            logger.exception(color(f"!!! exception in callback: {error}", "red"))
+
+        self.reset()
+
+
+# -----------------------------------------------------------------------------
+class Protocol:
+    CommandHandler = Callable[[int, avc.CommandFrame], None]
+    command_handlers: Dict[int, CommandHandler]  # Command handlers, by PID
+    ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
+    response_handlers: Dict[int, ResponseHandler]  # Response handlers, by PID
+    next_transaction_label: int
+    message_assembler: MessageAssembler
+
+    class PacketType(IntEnum):
+        SINGLE = 0b00
+        START = 0b01
+        CONTINUE = 0b10
+        END = 0b11
+
+    def __init__(self, l2cap_channel: l2cap.ClassicChannel) -> None:
+        self.command_handlers = {}
+        self.response_handlers = {}
+        self.l2cap_channel = l2cap_channel
+        self.message_assembler = MessageAssembler(self.on_message)
+
+        # Register to receive PDUs from the channel
+        l2cap_channel.sink = self.on_pdu
+        l2cap_channel.on("open", self.on_l2cap_channel_open)
+        l2cap_channel.on("close", self.on_l2cap_channel_close)
+
+    def on_l2cap_channel_open(self):
+        logger.debug(color("<<< AVCTP channel open", "magenta"))
+
+    def on_l2cap_channel_close(self):
+        logger.debug(color("<<< AVCTP channel closed", "magenta"))
+
+    def on_pdu(self, pdu: bytes) -> None:
+        self.message_assembler.on_pdu(pdu)
+
+    def on_message(
+        self,
+        transaction_label: int,
+        is_command: bool,
+        ipid: bool,
+        pid: int,
+        payload: bytes,
+    ) -> None:
+        logger.debug(
+            f"<<< AVCTP Message: pid={pid}, "
+            f"transaction_label={transaction_label}, "
+            f"is_command={is_command}, "
+            f"ipid={ipid}, "
+            f"payload={payload.hex()}"
+        )
+
+        # Check for invalid PID responses.
+        if ipid:
+            logger.debug(f"received IPID for PID={pid}")
+
+        # Find the appropriate handler.
+        if is_command:
+            if pid not in self.command_handlers:
+                logger.warning(f"no command handler for PID {pid}")
+                self.send_ipid(transaction_label, pid)
+                return
+
+            command_frame = cast(avc.CommandFrame, avc.Frame.from_bytes(payload))
+            self.command_handlers[pid](transaction_label, command_frame)
+        else:
+            if pid not in self.response_handlers:
+                logger.warning(f"no response handler for PID {pid}")
+                return
+
+            # By convention, for an ipid, send a None payload to the response handler.
+            if ipid:
+                response_frame = None
+            else:
+                response_frame = cast(avc.ResponseFrame, avc.Frame.from_bytes(payload))
+
+            self.response_handlers[pid](transaction_label, response_frame)
+
+    def send_message(
+        self,
+        transaction_label: int,
+        is_command: bool,
+        ipid: bool,
+        pid: int,
+        payload: bytes,
+    ):
+        # TODO: fragment large messages
+        packet_type = Protocol.PacketType.SINGLE
+        pdu = (
+            struct.pack(
+                ">BH",
+                transaction_label << 4
+                | packet_type << 2
+                | (0 if is_command else 1) << 1
+                | (1 if ipid else 0),
+                pid,
+            )
+            + payload
+        )
+        self.l2cap_channel.send_pdu(pdu)
+
+    def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
+        logger.debug(
+            ">>> AVCTP command: "
+            f"transaction_label={transaction_label}, "
+            f"pid={pid}, "
+            f"payload={payload.hex()}"
+        )
+        self.send_message(transaction_label, True, False, pid, payload)
+
+    def send_response(self, transaction_label: int, pid: int, payload: bytes):
+        logger.debug(
+            ">>> AVCTP response: "
+            f"transaction_label={transaction_label}, "
+            f"pid={pid}, "
+            f"payload={payload.hex()}"
+        )
+        self.send_message(transaction_label, False, False, pid, payload)
+
+    def send_ipid(self, transaction_label: int, pid: int) -> None:
+        logger.debug(
+            ">>> AVCTP ipid: " f"transaction_label={transaction_label}, " f"pid={pid}"
+        )
+        self.send_message(transaction_label, False, True, pid, b'')
+
+    def register_command_handler(
+        self, pid: int, handler: Protocol.CommandHandler
+    ) -> None:
+        self.command_handlers[pid] = handler
+
+    def unregister_command_handler(
+        self, pid: int, handler: Protocol.CommandHandler
+    ) -> None:
+        if pid not in self.command_handlers or self.command_handlers[pid] != handler:
+            raise ValueError("command handler not registered")
+        del self.command_handlers[pid]
+
+    def register_response_handler(
+        self, pid: int, handler: Protocol.ResponseHandler
+    ) -> None:
+        self.response_handlers[pid] = handler
+
+    def unregister_response_handler(
+        self, pid: int, handler: Protocol.ResponseHandler
+    ) -> None:
+        if pid not in self.response_handlers or self.response_handlers[pid] != handler:
+            raise ValueError("response handler not registered")
+        del self.response_handlers[pid]
diff --git a/bumble/avdtp.py b/bumble/avdtp.py
index 9a332f4..f785109 100644
--- a/bumble/avdtp.py
+++ b/bumble/avdtp.py
@@ -241,7 +241,10 @@
         )
         if profile_descriptor_list:
             for profile_descriptor in profile_descriptor_list.value:
-                if len(profile_descriptor.value) >= 2:
+                if (
+                    profile_descriptor.type == sdp.DataElement.SEQUENCE
+                    and len(profile_descriptor.value) >= 2
+                ):
                     avdtp_version_major = profile_descriptor.value[1].value >> 8
                     avdtp_version_minor = profile_descriptor.value[1].value & 0xFF
                     return (avdtp_version_major, avdtp_version_minor)
@@ -250,15 +253,15 @@
 
 # -----------------------------------------------------------------------------
 async def find_avdtp_service_with_connection(
-    device: device.Device, connection: device.Connection
+    connection: device.Connection,
 ) -> Optional[Tuple[int, int]]:
     '''
     Find an AVDTP service, for a connection, and return its version,
     or None if none is found
     '''
 
-    sdp_client = sdp.Client(device)
-    await sdp_client.connect(connection)
+    sdp_client = sdp.Client(connection)
+    await sdp_client.connect()
     service_version = await find_avdtp_service_with_sdp_client(sdp_client)
     await sdp_client.disconnect()
 
@@ -511,7 +514,8 @@
         try:
             self.callback(self.transaction_label, message)
         except Exception as error:
-            logger.warning(color(f'!!! exception in callback: {error}'))
+            logger.exception(color(f'!!! exception in callback: {error}', 'red'))
+
         self.reset()
 
 
@@ -1466,10 +1470,10 @@
             f'[{transaction_label}] {message}'
         )
         max_fragment_size = (
-            self.l2cap_channel.mtu - 3
+            self.l2cap_channel.peer_mtu - 3
         )  # Enough space for a 3-byte start packet header
         payload = message.payload
-        if len(payload) + 2 <= self.l2cap_channel.mtu:
+        if len(payload) + 2 <= self.l2cap_channel.peer_mtu:
             # Fits in a single packet
             packet_type = self.PacketType.SINGLE_PACKET
         else:
diff --git a/bumble/avrcp.py b/bumble/avrcp.py
new file mode 100644
index 0000000..fec2b2c
--- /dev/null
+++ b/bumble/avrcp.py
@@ -0,0 +1,1916 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import asyncio
+from dataclasses import dataclass
+import enum
+import logging
+import struct
+from typing import (
+    AsyncIterator,
+    Awaitable,
+    Callable,
+    cast,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Sequence,
+    SupportsBytes,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+)
+
+import pyee
+
+from bumble.colors import color
+from bumble.device import Device, Connection
+from bumble.sdp import (
+    SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+    SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+    SDP_PUBLIC_BROWSE_ROOT,
+    SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+    SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+    SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+    SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+    DataElement,
+    ServiceAttribute,
+)
+from bumble.utils import AsyncRunner, OpenIntEnum
+from bumble.core import (
+    ProtocolError,
+    BT_L2CAP_PROTOCOL_ID,
+    BT_AVCTP_PROTOCOL_ID,
+    BT_AV_REMOTE_CONTROL_SERVICE,
+    BT_AV_REMOTE_CONTROL_CONTROLLER_SERVICE,
+    BT_AV_REMOTE_CONTROL_TARGET_SERVICE,
+)
+from bumble import l2cap
+from bumble import avc
+from bumble import avctp
+from bumble import utils
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+AVRCP_PID = 0x110E
+AVRCP_BLUETOOTH_SIG_COMPANY_ID = 0x001958
+
+
+# -----------------------------------------------------------------------------
+def make_controller_service_sdp_records(
+    service_record_handle: int,
+    avctp_version: Tuple[int, int] = (1, 4),
+    avrcp_version: Tuple[int, int] = (1, 6),
+    supported_features: int = 1,
+) -> List[ServiceAttribute]:
+    # TODO: support a way to compute the supported features from a feature list
+    avctp_version_int = avctp_version[0] << 8 | avctp_version[1]
+    avrcp_version_int = avrcp_version[0] << 8 | avrcp_version[1]
+
+    return [
+        ServiceAttribute(
+            SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+            DataElement.unsigned_integer_32(service_record_handle),
+        ),
+        ServiceAttribute(
+            SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+            DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+        ),
+        ServiceAttribute(
+            SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
+                    DataElement.uuid(BT_AV_REMOTE_CONTROL_CONTROLLER_SERVICE),
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
+                            DataElement.unsigned_integer_16(avctp.AVCTP_PSM),
+                        ]
+                    ),
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_AVCTP_PROTOCOL_ID),
+                            DataElement.unsigned_integer_16(avctp_version_int),
+                        ]
+                    ),
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
+                            DataElement.unsigned_integer_16(avrcp_version_int),
+                        ]
+                    ),
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+            DataElement.unsigned_integer_16(supported_features),
+        ),
+    ]
+
+
+# -----------------------------------------------------------------------------
+def make_target_service_sdp_records(
+    service_record_handle: int,
+    avctp_version: Tuple[int, int] = (1, 4),
+    avrcp_version: Tuple[int, int] = (1, 6),
+    supported_features: int = 0x23,
+) -> List[ServiceAttribute]:
+    # TODO: support a way to compute the supported features from a feature list
+    avctp_version_int = avctp_version[0] << 8 | avctp_version[1]
+    avrcp_version_int = avrcp_version[0] << 8 | avrcp_version[1]
+
+    return [
+        ServiceAttribute(
+            SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+            DataElement.unsigned_integer_32(service_record_handle),
+        ),
+        ServiceAttribute(
+            SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+            DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+        ),
+        ServiceAttribute(
+            SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.uuid(BT_AV_REMOTE_CONTROL_TARGET_SERVICE),
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
+                            DataElement.unsigned_integer_16(avctp.AVCTP_PSM),
+                        ]
+                    ),
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_AVCTP_PROTOCOL_ID),
+                            DataElement.unsigned_integer_16(avctp_version_int),
+                        ]
+                    ),
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
+                            DataElement.unsigned_integer_16(avrcp_version_int),
+                        ]
+                    ),
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+            DataElement.unsigned_integer_16(supported_features),
+        ),
+    ]
+
+
+# -----------------------------------------------------------------------------
+def _decode_attribute_value(value: bytes, character_set: CharacterSetId) -> str:
+    try:
+        if character_set == CharacterSetId.UTF_8:
+            return value.decode("utf-8")
+        return value.decode("ascii")
+    except UnicodeDecodeError:
+        logger.warning(f"cannot decode string with bytes: {value.hex()}")
+        return ""
+
+
+# -----------------------------------------------------------------------------
+class PduAssembler:
+    """
+    PDU Assembler to support fragmented PDUs are defined in:
+    Audio/Video Remote Control / Profile Specification
+    6.3.1 AVRCP specific AV//C commands
+    """
+
+    pdu_id: Optional[Protocol.PduId]
+    payload: bytes
+
+    def __init__(self, callback: Callable[[Protocol.PduId, bytes], None]) -> None:
+        self.callback = callback
+        self.reset()
+
+    def reset(self) -> None:
+        self.pdu_id = None
+        self.parameter = b''
+
+    def on_pdu(self, pdu: bytes) -> None:
+        pdu_id = Protocol.PduId(pdu[0])
+        packet_type = Protocol.PacketType(pdu[1] & 3)
+        parameter_length = struct.unpack_from('>H', pdu, 2)[0]
+        parameter = pdu[4 : 4 + parameter_length]
+        if len(parameter) != parameter_length:
+            logger.warning("parameter length exceeds pdu size")
+            self.reset()
+            return
+
+        if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.START):
+            if self.pdu_id is not None:
+                # We are already in a PDU
+                logger.warning("received START or SINGLE fragment while in pdu")
+                self.reset()
+
+        if packet_type in (Protocol.PacketType.CONTINUE, Protocol.PacketType.END):
+            if pdu_id != self.pdu_id:
+                logger.warning("PID does not match")
+                self.reset()
+                return
+        else:
+            self.pdu_id = pdu_id
+
+        self.parameter += parameter
+
+        if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.END):
+            self.on_pdu_complete()
+
+    def on_pdu_complete(self) -> None:
+        assert self.pdu_id is not None
+        try:
+            self.callback(self.pdu_id, self.parameter)
+        except Exception as error:
+            logger.exception(color(f'!!! exception in callback: {error}', 'red'))
+
+        self.reset()
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class Command:
+    pdu_id: Protocol.PduId
+    parameter: bytes
+
+    def to_string(self, properties: Dict[str, str]) -> str:
+        properties_str = ",".join(
+            [f"{name}={value}" for name, value in properties.items()]
+        )
+        return f"Command[{self.pdu_id.name}]({properties_str})"
+
+    def __str__(self) -> str:
+        return self.to_string({"parameters": self.parameter.hex()})
+
+    def __repr__(self) -> str:
+        return str(self)
+
+
+# -----------------------------------------------------------------------------
+class GetCapabilitiesCommand(Command):
+    class CapabilityId(OpenIntEnum):
+        COMPANY_ID = 0x02
+        EVENTS_SUPPORTED = 0x03
+
+    capability_id: CapabilityId
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> GetCapabilitiesCommand:
+        return cls(cls.CapabilityId(pdu[0]))
+
+    def __init__(self, capability_id: CapabilityId) -> None:
+        super().__init__(Protocol.PduId.GET_CAPABILITIES, bytes([capability_id]))
+        self.capability_id = capability_id
+
+    def __str__(self) -> str:
+        return self.to_string({"capability_id": self.capability_id.name})
+
+
+# -----------------------------------------------------------------------------
+class GetPlayStatusCommand(Command):
+    @classmethod
+    def from_bytes(cls, _: bytes) -> GetPlayStatusCommand:
+        return cls()
+
+    def __init__(self) -> None:
+        super().__init__(Protocol.PduId.GET_PLAY_STATUS, b'')
+
+
+# -----------------------------------------------------------------------------
+class GetElementAttributesCommand(Command):
+    identifier: int
+    attribute_ids: List[MediaAttributeId]
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> GetElementAttributesCommand:
+        identifier = struct.unpack_from(">Q", pdu)[0]
+        num_attributes = pdu[8]
+        attribute_ids = [MediaAttributeId(pdu[9 + i]) for i in range(num_attributes)]
+        return cls(identifier, attribute_ids)
+
+    def __init__(
+        self, identifier: int, attribute_ids: Sequence[MediaAttributeId]
+    ) -> None:
+        parameter = struct.pack(">QB", identifier, len(attribute_ids)) + b''.join(
+            [struct.pack(">I", int(attribute_id)) for attribute_id in attribute_ids]
+        )
+        super().__init__(Protocol.PduId.GET_ELEMENT_ATTRIBUTES, parameter)
+        self.identifier = identifier
+        self.attribute_ids = list(attribute_ids)
+
+
+# -----------------------------------------------------------------------------
+class SetAbsoluteVolumeCommand(Command):
+    MAXIMUM_VOLUME = 0x7F
+
+    volume: int
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> SetAbsoluteVolumeCommand:
+        return cls(pdu[0])
+
+    def __init__(self, volume: int) -> None:
+        super().__init__(Protocol.PduId.SET_ABSOLUTE_VOLUME, bytes([volume]))
+        self.volume = volume
+
+    def __str__(self) -> str:
+        return self.to_string({"volume": str(self.volume)})
+
+
+# -----------------------------------------------------------------------------
+class RegisterNotificationCommand(Command):
+    event_id: EventId
+    playback_interval: int
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> RegisterNotificationCommand:
+        event_id = EventId(pdu[0])
+        playback_interval = struct.unpack_from(">I", pdu, 1)[0]
+        return cls(event_id, playback_interval)
+
+    def __init__(self, event_id: EventId, playback_interval: int) -> None:
+        super().__init__(
+            Protocol.PduId.REGISTER_NOTIFICATION,
+            struct.pack(">BI", int(event_id), playback_interval),
+        )
+        self.event_id = event_id
+        self.playback_interval = playback_interval
+
+    def __str__(self) -> str:
+        return self.to_string(
+            {
+                "event_id": self.event_id.name,
+                "playback_interval": str(self.playback_interval),
+            }
+        )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class Response:
+    pdu_id: Protocol.PduId
+    parameter: bytes
+
+    def to_string(self, properties: Dict[str, str]) -> str:
+        properties_str = ",".join(
+            [f"{name}={value}" for name, value in properties.items()]
+        )
+        return f"Response[{self.pdu_id.name}]({properties_str})"
+
+    def __str__(self) -> str:
+        return self.to_string({"parameter": self.parameter.hex()})
+
+    def __repr__(self) -> str:
+        return str(self)
+
+
+# -----------------------------------------------------------------------------
+class RejectedResponse(Response):
+    status_code: Protocol.StatusCode
+
+    @classmethod
+    def from_bytes(cls, pdu_id: Protocol.PduId, pdu: bytes) -> RejectedResponse:
+        return cls(pdu_id, Protocol.StatusCode(pdu[0]))
+
+    def __init__(
+        self, pdu_id: Protocol.PduId, status_code: Protocol.StatusCode
+    ) -> None:
+        super().__init__(pdu_id, bytes([int(status_code)]))
+        self.status_code = status_code
+
+    def __str__(self) -> str:
+        return self.to_string(
+            {
+                "status_code": self.status_code.name,
+            }
+        )
+
+
+# -----------------------------------------------------------------------------
+class NotImplementedResponse(Response):
+    @classmethod
+    def from_bytes(cls, pdu_id: Protocol.PduId, pdu: bytes) -> NotImplementedResponse:
+        return cls(pdu_id, pdu[1:])
+
+
+# -----------------------------------------------------------------------------
+class GetCapabilitiesResponse(Response):
+    capability_id: GetCapabilitiesCommand.CapabilityId
+    capabilities: List[Union[SupportsBytes, bytes]]
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> GetCapabilitiesResponse:
+        if len(pdu) < 2:
+            # Possibly a reject response.
+            return cls(GetCapabilitiesCommand.CapabilityId(0), [])
+
+        # Assume that the payloads all follow the same pattern:
+        #  <CapabilityID><CapabilityCount><Capability*>
+        capability_id = GetCapabilitiesCommand.CapabilityId(pdu[0])
+        capability_count = pdu[1]
+
+        capabilities: List[Union[SupportsBytes, bytes]]
+        if capability_id == GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED:
+            capabilities = [EventId(pdu[2 + x]) for x in range(capability_count)]
+        else:
+            capability_size = (len(pdu) - 2) // capability_count
+            capabilities = [
+                pdu[x : x + capability_size]
+                for x in range(2, len(pdu), capability_size)
+            ]
+
+        return cls(capability_id, capabilities)
+
+    def __init__(
+        self,
+        capability_id: GetCapabilitiesCommand.CapabilityId,
+        capabilities: Sequence[Union[SupportsBytes, bytes]],
+    ) -> None:
+        super().__init__(
+            Protocol.PduId.GET_CAPABILITIES,
+            bytes([capability_id, len(capabilities)])
+            + b''.join(bytes(capability) for capability in capabilities),
+        )
+        self.capability_id = capability_id
+        self.capabilities = list(capabilities)
+
+    def __str__(self) -> str:
+        return self.to_string(
+            {
+                "capability_id": self.capability_id.name,
+                "capabilities": str(self.capabilities),
+            }
+        )
+
+
+# -----------------------------------------------------------------------------
+class GetPlayStatusResponse(Response):
+    song_length: int
+    song_position: int
+    play_status: PlayStatus
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> GetPlayStatusResponse:
+        (song_length, song_position) = struct.unpack_from(">II", pdu, 0)
+        play_status = PlayStatus(pdu[8])
+
+        return cls(song_length, song_position, play_status)
+
+    def __init__(
+        self,
+        song_length: int,
+        song_position: int,
+        play_status: PlayStatus,
+    ) -> None:
+        super().__init__(
+            Protocol.PduId.GET_PLAY_STATUS,
+            struct.pack(">IIB", song_length, song_position, int(play_status)),
+        )
+        self.song_length = song_length
+        self.song_position = song_position
+        self.play_status = play_status
+
+    def __str__(self) -> str:
+        return self.to_string(
+            {
+                "song_length": str(self.song_length),
+                "song_position": str(self.song_position),
+                "play_status": self.play_status.name,
+            }
+        )
+
+
+# -----------------------------------------------------------------------------
+class GetElementAttributesResponse(Response):
+    attributes: List[MediaAttribute]
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> GetElementAttributesResponse:
+        num_attributes = pdu[0]
+        offset = 1
+        attributes: List[MediaAttribute] = []
+        for _ in range(num_attributes):
+            (
+                attribute_id_int,
+                character_set_id_int,
+                attribute_value_length,
+            ) = struct.unpack_from(">IHH", pdu, offset)
+            attribute_value_bytes = pdu[
+                offset + 8 : offset + 8 + attribute_value_length
+            ]
+            attribute_id = MediaAttributeId(attribute_id_int)
+            character_set_id = CharacterSetId(character_set_id_int)
+            attribute_value = _decode_attribute_value(
+                attribute_value_bytes, character_set_id
+            )
+            attributes.append(
+                MediaAttribute(attribute_id, character_set_id, attribute_value)
+            )
+            offset += 8 + attribute_value_length
+
+        return cls(attributes)
+
+    def __init__(self, attributes: Sequence[MediaAttribute]) -> None:
+        parameter = bytes([len(attributes)])
+        for attribute in attributes:
+            attribute_value_bytes = attribute.attribute_value.encode("utf-8")
+            parameter += (
+                struct.pack(
+                    ">IHH",
+                    int(attribute.attribute_id),
+                    int(CharacterSetId.UTF_8),
+                    len(attribute_value_bytes),
+                )
+                + attribute_value_bytes
+            )
+        super().__init__(
+            Protocol.PduId.GET_ELEMENT_ATTRIBUTES,
+            parameter,
+        )
+        self.attributes = list(attributes)
+
+    def __str__(self) -> str:
+        attribute_strs = [str(attribute) for attribute in self.attributes]
+        return self.to_string(
+            {
+                "attributes": f"[{', '.join(attribute_strs)}]",
+            }
+        )
+
+
+# -----------------------------------------------------------------------------
+class SetAbsoluteVolumeResponse(Response):
+    volume: int
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> SetAbsoluteVolumeResponse:
+        return cls(pdu[0])
+
+    def __init__(self, volume: int) -> None:
+        super().__init__(Protocol.PduId.SET_ABSOLUTE_VOLUME, bytes([volume]))
+        self.volume = volume
+
+    def __str__(self) -> str:
+        return self.to_string({"volume": str(self.volume)})
+
+
+# -----------------------------------------------------------------------------
+class RegisterNotificationResponse(Response):
+    event: Event
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> RegisterNotificationResponse:
+        return cls(Event.from_bytes(pdu))
+
+    def __init__(self, event: Event) -> None:
+        super().__init__(
+            Protocol.PduId.REGISTER_NOTIFICATION,
+            bytes(event),
+        )
+        self.event = event
+
+    def __str__(self) -> str:
+        return self.to_string(
+            {
+                "event": str(self.event),
+            }
+        )
+
+
+# -----------------------------------------------------------------------------
+class EventId(OpenIntEnum):
+    PLAYBACK_STATUS_CHANGED = 0x01
+    TRACK_CHANGED = 0x02
+    TRACK_REACHED_END = 0x03
+    TRACK_REACHED_START = 0x04
+    PLAYBACK_POS_CHANGED = 0x05
+    BATT_STATUS_CHANGED = 0x06
+    SYSTEM_STATUS_CHANGED = 0x07
+    PLAYER_APPLICATION_SETTING_CHANGED = 0x08
+    NOW_PLAYING_CONTENT_CHANGED = 0x09
+    AVAILABLE_PLAYERS_CHANGED = 0x0A
+    ADDRESSED_PLAYER_CHANGED = 0x0B
+    UIDS_CHANGED = 0x0C
+    VOLUME_CHANGED = 0x0D
+
+    def __bytes__(self) -> bytes:
+        return bytes([int(self)])
+
+
+# -----------------------------------------------------------------------------
+class CharacterSetId(OpenIntEnum):
+    UTF_8 = 0x06
+
+
+# -----------------------------------------------------------------------------
+class MediaAttributeId(OpenIntEnum):
+    TITLE = 0x01
+    ARTIST_NAME = 0x02
+    ALBUM_NAME = 0x03
+    TRACK_NUMBER = 0x04
+    TOTAL_NUMBER_OF_TRACKS = 0x05
+    GENRE = 0x06
+    PLAYING_TIME = 0x07
+    DEFAULT_COVER_ART = 0x08
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class MediaAttribute:
+    attribute_id: MediaAttributeId
+    character_set_id: CharacterSetId
+    attribute_value: str
+
+
+# -----------------------------------------------------------------------------
+class PlayStatus(OpenIntEnum):
+    STOPPED = 0x00
+    PLAYING = 0x01
+    PAUSED = 0x02
+    FWD_SEEK = 0x03
+    REV_SEEK = 0x04
+    ERROR = 0xFF
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class SongAndPlayStatus:
+    song_length: int
+    song_position: int
+    play_status: PlayStatus
+
+
+# -----------------------------------------------------------------------------
+class ApplicationSetting:
+    class AttributeId(OpenIntEnum):
+        EQUALIZER_ON_OFF = 0x01
+        REPEAT_MODE = 0x02
+        SHUFFLE_ON_OFF = 0x03
+        SCAN_ON_OFF = 0x04
+
+    class EqualizerOnOffStatus(OpenIntEnum):
+        OFF = 0x01
+        ON = 0x02
+
+    class RepeatModeStatus(OpenIntEnum):
+        OFF = 0x01
+        SINGLE_TRACK_REPEAT = 0x02
+        ALL_TRACK_REPEAT = 0x03
+        GROUP_REPEAT = 0x04
+
+    class ShuffleOnOffStatus(OpenIntEnum):
+        OFF = 0x01
+        ALL_TRACKS_SHUFFLE = 0x02
+        GROUP_SHUFFLE = 0x03
+
+    class ScanOnOffStatus(OpenIntEnum):
+        OFF = 0x01
+        ALL_TRACKS_SCAN = 0x02
+        GROUP_SCAN = 0x03
+
+    class GenericValue(OpenIntEnum):
+        pass
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class Event:
+    event_id: EventId
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> Event:
+        event_id = EventId(pdu[0])
+        subclass = EVENT_SUBCLASSES.get(event_id, GenericEvent)
+        return subclass.from_bytes(pdu)
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.event_id])
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class GenericEvent(Event):
+    data: bytes
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> GenericEvent:
+        return cls(event_id=EventId(pdu[0]), data=pdu[1:])
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.event_id]) + self.data
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class PlaybackStatusChangedEvent(Event):
+    play_status: PlayStatus
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> PlaybackStatusChangedEvent:
+        return cls(play_status=PlayStatus(pdu[1]))
+
+    def __init__(self, play_status: PlayStatus) -> None:
+        super().__init__(EventId.PLAYBACK_STATUS_CHANGED)
+        self.play_status = play_status
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.event_id]) + bytes([self.play_status])
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class PlaybackPositionChangedEvent(Event):
+    playback_position: int
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> PlaybackPositionChangedEvent:
+        return cls(playback_position=struct.unpack_from(">I", pdu, 1)[0])
+
+    def __init__(self, playback_position: int) -> None:
+        super().__init__(EventId.PLAYBACK_POS_CHANGED)
+        self.playback_position = playback_position
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.event_id]) + struct.pack(">I", self.playback_position)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class TrackChangedEvent(Event):
+    identifier: bytes
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> TrackChangedEvent:
+        return cls(identifier=pdu[1:])
+
+    def __init__(self, identifier: bytes) -> None:
+        super().__init__(EventId.TRACK_CHANGED)
+        self.identifier = identifier
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.event_id]) + self.identifier
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class PlayerApplicationSettingChangedEvent(Event):
+    @dataclass
+    class Setting:
+        attribute_id: ApplicationSetting.AttributeId
+        value_id: OpenIntEnum
+
+    player_application_settings: List[Setting]
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> PlayerApplicationSettingChangedEvent:
+        def setting(attribute_id_int: int, value_id_int: int):
+            attribute_id = ApplicationSetting.AttributeId(attribute_id_int)
+            value_id: OpenIntEnum
+            if attribute_id == ApplicationSetting.AttributeId.EQUALIZER_ON_OFF:
+                value_id = ApplicationSetting.EqualizerOnOffStatus(value_id_int)
+            elif attribute_id == ApplicationSetting.AttributeId.REPEAT_MODE:
+                value_id = ApplicationSetting.RepeatModeStatus(value_id_int)
+            elif attribute_id == ApplicationSetting.AttributeId.SHUFFLE_ON_OFF:
+                value_id = ApplicationSetting.ShuffleOnOffStatus(value_id_int)
+            elif attribute_id == ApplicationSetting.AttributeId.SCAN_ON_OFF:
+                value_id = ApplicationSetting.ScanOnOffStatus(value_id_int)
+            else:
+                value_id = ApplicationSetting.GenericValue(value_id_int)
+
+            return cls.Setting(attribute_id, value_id)
+
+        settings = [
+            setting(pdu[2 + (i * 2)], pdu[2 + (i * 2) + 1]) for i in range(pdu[1])
+        ]
+        return cls(player_application_settings=settings)
+
+    def __init__(self, player_application_settings: Sequence[Setting]) -> None:
+        super().__init__(EventId.PLAYER_APPLICATION_SETTING_CHANGED)
+        self.player_application_settings = list(player_application_settings)
+
+    def __bytes__(self) -> bytes:
+        return (
+            bytes([self.event_id])
+            + bytes([len(self.player_application_settings)])
+            + b''.join(
+                [
+                    bytes([setting.attribute_id, setting.value_id])
+                    for setting in self.player_application_settings
+                ]
+            )
+        )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class NowPlayingContentChangedEvent(Event):
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> NowPlayingContentChangedEvent:
+        return cls()
+
+    def __init__(self) -> None:
+        super().__init__(EventId.NOW_PLAYING_CONTENT_CHANGED)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AvailablePlayersChangedEvent(Event):
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> AvailablePlayersChangedEvent:
+        return cls()
+
+    def __init__(self) -> None:
+        super().__init__(EventId.AVAILABLE_PLAYERS_CHANGED)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AddressedPlayerChangedEvent(Event):
+    @dataclass
+    class Player:
+        player_id: int
+        uid_counter: int
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> AddressedPlayerChangedEvent:
+        player_id, uid_counter = struct.unpack_from("<HH", pdu, 1)
+        return cls(cls.Player(player_id, uid_counter))
+
+    def __init__(self, player: Player) -> None:
+        super().__init__(EventId.ADDRESSED_PLAYER_CHANGED)
+        self.player = player
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.event_id]) + struct.pack(
+            ">HH", self.player.player_id, self.player.uid_counter
+        )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class UidsChangedEvent(Event):
+    uid_counter: int
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> UidsChangedEvent:
+        return cls(uid_counter=struct.unpack_from(">H", pdu, 1)[0])
+
+    def __init__(self, uid_counter: int) -> None:
+        super().__init__(EventId.UIDS_CHANGED)
+        self.uid_counter = uid_counter
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.event_id]) + struct.pack(">H", self.uid_counter)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class VolumeChangedEvent(Event):
+    volume: int
+
+    @classmethod
+    def from_bytes(cls, pdu: bytes) -> VolumeChangedEvent:
+        return cls(volume=pdu[1])
+
+    def __init__(self, volume: int) -> None:
+        super().__init__(EventId.VOLUME_CHANGED)
+        self.volume = volume
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.event_id]) + bytes([self.volume])
+
+
+# -----------------------------------------------------------------------------
+EVENT_SUBCLASSES: Dict[EventId, Type[Event]] = {
+    EventId.PLAYBACK_STATUS_CHANGED: PlaybackStatusChangedEvent,
+    EventId.PLAYBACK_POS_CHANGED: PlaybackPositionChangedEvent,
+    EventId.TRACK_CHANGED: TrackChangedEvent,
+    EventId.PLAYER_APPLICATION_SETTING_CHANGED: PlayerApplicationSettingChangedEvent,
+    EventId.NOW_PLAYING_CONTENT_CHANGED: NowPlayingContentChangedEvent,
+    EventId.AVAILABLE_PLAYERS_CHANGED: AvailablePlayersChangedEvent,
+    EventId.ADDRESSED_PLAYER_CHANGED: AddressedPlayerChangedEvent,
+    EventId.UIDS_CHANGED: UidsChangedEvent,
+    EventId.VOLUME_CHANGED: VolumeChangedEvent,
+}
+
+
+# -----------------------------------------------------------------------------
+class Delegate:
+    """
+    Base class for AVRCP delegates.
+
+    All the methods are async, even if they don't always need to be, so that
+    delegates that do need to wait for an async result may do so.
+    """
+
+    class Error(Exception):
+        """The delegate method failed, with a specified status code."""
+
+        def __init__(self, status_code: Protocol.StatusCode) -> None:
+            self.status_code = status_code
+
+    supported_events: List[EventId]
+    volume: int
+
+    def __init__(self, supported_events: Iterable[EventId] = ()) -> None:
+        self.supported_events = list(supported_events)
+        self.volume = 0
+
+    async def get_supported_events(self) -> List[EventId]:
+        return self.supported_events
+
+    async def set_absolute_volume(self, volume: int) -> None:
+        """
+        Set the absolute volume.
+
+        Returns: the effective volume that was set.
+        """
+        logger.debug(f"@@@ set_absolute_volume: volume={volume}")
+        self.volume = volume
+
+    async def get_absolute_volume(self) -> int:
+        return self.volume
+
+    # TODO add other delegate methods
+
+
+# -----------------------------------------------------------------------------
+class Protocol(pyee.EventEmitter):
+    """AVRCP Controller and Target protocol."""
+
+    class PacketType(enum.IntEnum):
+        SINGLE = 0b00
+        START = 0b01
+        CONTINUE = 0b10
+        END = 0b11
+
+    class PduId(OpenIntEnum):
+        GET_CAPABILITIES = 0x10
+        LIST_PLAYER_APPLICATION_SETTING_ATTRIBUTES = 0x11
+        LIST_PLAYER_APPLICATION_SETTING_VALUES = 0x12
+        GET_CURRENT_PLAYER_APPLICATION_SETTING_VALUE = 0x13
+        SET_PLAYER_APPLICATION_SETTING_VALUE = 0x14
+        GET_PLAYER_APPLICATION_SETTING_ATTRIBUTE_TEXT = 0x15
+        GET_PLAYER_APPLICATION_SETTING_VALUE_TEXT = 0x16
+        INFORM_DISPLAYABLE_CHARACTER_SET = 0x17
+        INFORM_BATTERY_STATUS_OF_CT = 0x18
+        GET_ELEMENT_ATTRIBUTES = 0x20
+        GET_PLAY_STATUS = 0x30
+        REGISTER_NOTIFICATION = 0x31
+        REQUEST_CONTINUING_RESPONSE = 0x40
+        ABORT_CONTINUING_RESPONSE = 0x41
+        SET_ABSOLUTE_VOLUME = 0x50
+        SET_ADDRESSED_PLAYER = 0x60
+        SET_BROWSED_PLAYER = 0x70
+        GET_FOLDER_ITEMS = 0x71
+        GET_TOTAL_NUMBER_OF_ITEMS = 0x75
+
+    class StatusCode(OpenIntEnum):
+        INVALID_COMMAND = 0x00
+        INVALID_PARAMETER = 0x01
+        PARAMETER_CONTENT_ERROR = 0x02
+        INTERNAL_ERROR = 0x03
+        OPERATION_COMPLETED = 0x04
+        UID_CHANGED = 0x05
+        INVALID_DIRECTION = 0x07
+        NOT_A_DIRECTORY = 0x08
+        DOES_NOT_EXIST = 0x09
+        INVALID_SCOPE = 0x0A
+        RANGE_OUT_OF_BOUNDS = 0x0B
+        FOLDER_ITEM_IS_NOT_PLAYABLE = 0x0C
+        MEDIA_IN_USE = 0x0D
+        NOW_PLAYING_LIST_FULL = 0x0E
+        SEARCH_NOT_SUPPORTED = 0x0F
+        SEARCH_IN_PROGRESS = 0x10
+        INVALID_PLAYER_ID = 0x11
+        PLAYER_NOT_BROWSABLE = 0x12
+        PLAYER_NOT_ADDRESSED = 0x13
+        NO_VALID_SEARCH_RESULTS = 0x14
+        NO_AVAILABLE_PLAYERS = 0x15
+        ADDRESSED_PLAYER_CHANGED = 0x16
+
+    class InvalidPidError(Exception):
+        """A response frame with ipid==1 was received."""
+
+    class NotPendingError(Exception):
+        """There is no pending command for a transaction label."""
+
+    class MismatchedResponseError(Exception):
+        """The response type does not corresponding to the request type."""
+
+        def __init__(self, response: Response) -> None:
+            self.response = response
+
+    class UnexpectedResponseTypeError(Exception):
+        """The response type is not the expected one."""
+
+        def __init__(self, response: Protocol.ResponseContext) -> None:
+            self.response = response
+
+    class UnexpectedResponseCodeError(Exception):
+        """The response code was not the expected one."""
+
+        def __init__(
+            self, response_code: avc.ResponseFrame.ResponseCode, response: Response
+        ) -> None:
+            self.response_code = response_code
+            self.response = response
+
+    class PendingCommand:
+        response: asyncio.Future
+
+        def __init__(self, transaction_label: int) -> None:
+            self.transaction_label = transaction_label
+            self.reset()
+
+        def reset(self):
+            self.response = asyncio.get_running_loop().create_future()
+
+    @dataclass
+    class ReceiveCommandState:
+        transaction_label: int
+        command_type: avc.CommandFrame.CommandType
+
+    @dataclass
+    class ReceiveResponseState:
+        transaction_label: int
+        response_code: avc.ResponseFrame.ResponseCode
+
+    @dataclass
+    class ResponseContext:
+        transaction_label: int
+        response: Response
+
+    @dataclass
+    class FinalResponse(ResponseContext):
+        response_code: avc.ResponseFrame.ResponseCode
+
+    @dataclass
+    class InterimResponse(ResponseContext):
+        final: Awaitable[Protocol.FinalResponse]
+
+    @dataclass
+    class NotificationListener:
+        transaction_label: int
+        register_notification_command: RegisterNotificationCommand
+
+    delegate: Delegate
+    send_transaction_label: int
+    command_pdu_assembler: PduAssembler
+    receive_command_state: Optional[ReceiveCommandState]
+    response_pdu_assembler: PduAssembler
+    receive_response_state: Optional[ReceiveResponseState]
+    avctp_protocol: Optional[avctp.Protocol]
+    free_commands: asyncio.Queue
+    pending_commands: Dict[int, PendingCommand]  # Pending commands, by label
+    notification_listeners: Dict[EventId, NotificationListener]
+
+    @staticmethod
+    def _check_vendor_dependent_frame(
+        frame: Union[avc.VendorDependentCommandFrame, avc.VendorDependentResponseFrame]
+    ) -> bool:
+        if frame.company_id != AVRCP_BLUETOOTH_SIG_COMPANY_ID:
+            logger.debug("unsupported company id, ignoring")
+            return False
+
+        if frame.subunit_type != avc.Frame.SubunitType.PANEL or frame.subunit_id != 0:
+            logger.debug("unsupported subunit")
+            return False
+
+        return True
+
+    def __init__(self, delegate: Optional[Delegate] = None) -> None:
+        super().__init__()
+        self.delegate = delegate if delegate else Delegate()
+        self.command_pdu_assembler = PduAssembler(self._on_command_pdu)
+        self.receive_command_state = None
+        self.response_pdu_assembler = PduAssembler(self._on_response_pdu)
+        self.receive_response_state = None
+        self.avctp_protocol = None
+        self.notification_listeners = {}
+
+        # Create an initial pool of free commands
+        self.pending_commands = {}
+        self.free_commands = asyncio.Queue()
+        for transaction_label in range(16):
+            self.free_commands.put_nowait(self.PendingCommand(transaction_label))
+
+    def listen(self, device: Device) -> None:
+        """
+        Listen for incoming connections.
+
+        A 'connection' event will be emitted when a connection is made, and a 'start'
+        event will be emitted when the protocol is ready to be used on that connection.
+        """
+        device.register_l2cap_server(avctp.AVCTP_PSM, self._on_avctp_connection)
+
+    async def connect(self, connection: Connection) -> None:
+        """
+        Connect to a peer.
+        """
+        avctp_channel = await connection.create_l2cap_channel(
+            l2cap.ClassicChannelSpec(psm=avctp.AVCTP_PSM)
+        )
+        self._on_avctp_channel_open(avctp_channel)
+
+    async def _obtain_pending_command(self) -> PendingCommand:
+        pending_command = await self.free_commands.get()
+        self.pending_commands[pending_command.transaction_label] = pending_command
+        return pending_command
+
+    def recycle_pending_command(self, pending_command: PendingCommand) -> None:
+        pending_command.reset()
+        del self.pending_commands[pending_command.transaction_label]
+        self.free_commands.put_nowait(pending_command)
+        logger.debug(f"recycled pending command, {self.free_commands.qsize()} free")
+
+    _R = TypeVar('_R')
+
+    @staticmethod
+    def _check_response(
+        response_context: ResponseContext, expected_type: Type[_R]
+    ) -> _R:
+        if isinstance(response_context, Protocol.FinalResponse):
+            if (
+                response_context.response_code
+                != avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE
+            ):
+                raise Protocol.UnexpectedResponseCodeError(
+                    response_context.response_code, response_context.response
+                )
+
+            if not (isinstance(response_context.response, expected_type)):
+                raise Protocol.MismatchedResponseError(response_context.response)
+
+            return response_context.response
+
+        raise Protocol.UnexpectedResponseTypeError(response_context)
+
+    def _delegate_command(
+        self, transaction_label: int, command: Command, method: Awaitable
+    ) -> None:
+        async def call():
+            try:
+                await method
+            except Delegate.Error as error:
+                self.send_rejected_avrcp_response(
+                    transaction_label,
+                    command.pdu_id,
+                    error.status_code,
+                )
+            except Exception:
+                logger.exception("delegate method raised exception")
+                self.send_rejected_avrcp_response(
+                    transaction_label,
+                    command.pdu_id,
+                    Protocol.StatusCode.INTERNAL_ERROR,
+                )
+
+        utils.AsyncRunner.spawn(call())
+
+    async def get_supported_events(self) -> List[EventId]:
+        """Get the list of events supported by the connected peer."""
+        response_context = await self.send_avrcp_command(
+            avc.CommandFrame.CommandType.STATUS,
+            GetCapabilitiesCommand(
+                GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED
+            ),
+        )
+        response = self._check_response(response_context, GetCapabilitiesResponse)
+        return cast(List[EventId], response.capabilities)
+
+    async def get_play_status(self) -> SongAndPlayStatus:
+        """Get the play status of the connected peer."""
+        response_context = await self.send_avrcp_command(
+            avc.CommandFrame.CommandType.STATUS, GetPlayStatusCommand()
+        )
+        response = self._check_response(response_context, GetPlayStatusResponse)
+        return SongAndPlayStatus(
+            response.song_length, response.song_position, response.play_status
+        )
+
+    async def get_element_attributes(
+        self, element_identifier: int, attribute_ids: Sequence[MediaAttributeId]
+    ) -> List[MediaAttribute]:
+        """Get element attributes from the connected peer."""
+        response_context = await self.send_avrcp_command(
+            avc.CommandFrame.CommandType.STATUS,
+            GetElementAttributesCommand(element_identifier, attribute_ids),
+        )
+        response = self._check_response(response_context, GetElementAttributesResponse)
+        return response.attributes
+
+    async def monitor_events(
+        self, event_id: EventId, playback_interval: int = 0
+    ) -> AsyncIterator[Event]:
+        """
+        Monitor events emitted from a peer.
+
+        This generator yields Event objects.
+        """
+
+        def check_response(response) -> Event:
+            if not isinstance(response, RegisterNotificationResponse):
+                raise self.MismatchedResponseError(response)
+
+            return response.event
+
+        while True:
+            response = await self.send_avrcp_command(
+                avc.CommandFrame.CommandType.NOTIFY,
+                RegisterNotificationCommand(event_id, playback_interval),
+            )
+
+            if isinstance(response, self.InterimResponse):
+                logger.debug(f"interim: {response}")
+                yield check_response(response.response)
+
+                logger.debug("waiting for final response")
+                response = await response.final
+
+            if not isinstance(response, self.FinalResponse):
+                raise self.UnexpectedResponseTypeError(response)
+
+            logger.debug(f"final: {response}")
+            if response.response_code != avc.ResponseFrame.ResponseCode.CHANGED:
+                raise self.UnexpectedResponseCodeError(
+                    response.response_code, response.response
+                )
+
+            yield check_response(response.response)
+
+    async def monitor_playback_status(
+        self,
+    ) -> AsyncIterator[PlayStatus]:
+        """Monitor Playback Status changes from the connected peer."""
+        async for event in self.monitor_events(EventId.PLAYBACK_STATUS_CHANGED, 0):
+            if not isinstance(event, PlaybackStatusChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield event.play_status
+
+    async def monitor_track_changed(
+        self,
+    ) -> AsyncIterator[bytes]:
+        """Monitor Track changes from the connected peer."""
+        async for event in self.monitor_events(EventId.TRACK_CHANGED, 0):
+            if not isinstance(event, TrackChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield event.identifier
+
+    async def monitor_playback_position(
+        self, playback_interval: int
+    ) -> AsyncIterator[int]:
+        """Monitor Playback Position changes from the connected peer."""
+        async for event in self.monitor_events(
+            EventId.PLAYBACK_POS_CHANGED, playback_interval
+        ):
+            if not isinstance(event, PlaybackPositionChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield event.playback_position
+
+    async def monitor_player_application_settings(
+        self,
+    ) -> AsyncIterator[List[PlayerApplicationSettingChangedEvent.Setting]]:
+        """Monitor Player Application Setting changes from the connected peer."""
+        async for event in self.monitor_events(
+            EventId.PLAYER_APPLICATION_SETTING_CHANGED, 0
+        ):
+            if not isinstance(event, PlayerApplicationSettingChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield event.player_application_settings
+
+    async def monitor_now_playing_content(self) -> AsyncIterator[None]:
+        """Monitor Now Playing changes from the connected peer."""
+        async for event in self.monitor_events(EventId.NOW_PLAYING_CONTENT_CHANGED, 0):
+            if not isinstance(event, NowPlayingContentChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield None
+
+    async def monitor_available_players(self) -> AsyncIterator[None]:
+        """Monitor Available Players changes from the connected peer."""
+        async for event in self.monitor_events(EventId.AVAILABLE_PLAYERS_CHANGED, 0):
+            if not isinstance(event, AvailablePlayersChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield None
+
+    async def monitor_addressed_player(
+        self,
+    ) -> AsyncIterator[AddressedPlayerChangedEvent.Player]:
+        """Monitor Addressed Player changes from the connected peer."""
+        async for event in self.monitor_events(EventId.ADDRESSED_PLAYER_CHANGED, 0):
+            if not isinstance(event, AddressedPlayerChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield event.player
+
+    async def monitor_uids(
+        self,
+    ) -> AsyncIterator[int]:
+        """Monitor UID changes from the connected peer."""
+        async for event in self.monitor_events(EventId.UIDS_CHANGED, 0):
+            if not isinstance(event, UidsChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield event.uid_counter
+
+    async def monitor_volume(
+        self,
+    ) -> AsyncIterator[int]:
+        """Monitor Volume changes from the connected peer."""
+        async for event in self.monitor_events(EventId.VOLUME_CHANGED, 0):
+            if not isinstance(event, VolumeChangedEvent):
+                logger.warning("unexpected event class")
+                continue
+            yield event.volume
+
+    def notify_event(self, event: Event):
+        """Notify an event to the connected peer."""
+        if (listener := self.notification_listeners.get(event.event_id)) is None:
+            logger.debug(f"no listener for {event.event_id.name}")
+            return
+
+        # Emit the notification.
+        notification = RegisterNotificationResponse(event)
+        self.send_avrcp_response(
+            listener.transaction_label,
+            avc.ResponseFrame.ResponseCode.CHANGED,
+            notification,
+        )
+
+        # Remove the listener (they will need to re-register).
+        del self.notification_listeners[event.event_id]
+
+    def notify_playback_status_changed(self, status: PlayStatus) -> None:
+        """Notify the connected peer of a Playback Status change."""
+        self.notify_event(PlaybackStatusChangedEvent(status))
+
+    def notify_track_changed(self, identifier: bytes) -> None:
+        """Notify the connected peer of a Track change."""
+        if len(identifier) != 8:
+            raise ValueError("identifier must be 8 bytes")
+        self.notify_event(TrackChangedEvent(identifier))
+
+    def notify_playback_position_changed(self, position: int) -> None:
+        """Notify the connected peer of a Position change."""
+        self.notify_event(PlaybackPositionChangedEvent(position))
+
+    def notify_player_application_settings_changed(
+        self, settings: Sequence[PlayerApplicationSettingChangedEvent.Setting]
+    ) -> None:
+        """Notify the connected peer of an Player Application Setting change."""
+        self.notify_event(
+            PlayerApplicationSettingChangedEvent(settings),
+        )
+
+    def notify_now_playing_content_changed(self) -> None:
+        """Notify the connected peer of a Now Playing change."""
+        self.notify_event(NowPlayingContentChangedEvent())
+
+    def notify_available_players_changed(self) -> None:
+        """Notify the connected peer of an Available Players change."""
+        self.notify_event(AvailablePlayersChangedEvent())
+
+    def notify_addressed_player_changed(
+        self, player: AddressedPlayerChangedEvent.Player
+    ) -> None:
+        """Notify the connected peer of an Addressed Player change."""
+        self.notify_event(AddressedPlayerChangedEvent(player))
+
+    def notify_uids_changed(self, uid_counter: int) -> None:
+        """Notify the connected peer of a UID change."""
+        self.notify_event(UidsChangedEvent(uid_counter))
+
+    def notify_volume_changed(self, volume: int) -> None:
+        """Notify the connected peer of a Volume change."""
+        self.notify_event(VolumeChangedEvent(volume))
+
+    def _register_notification_listener(
+        self, transaction_label: int, command: RegisterNotificationCommand
+    ) -> None:
+        listener = self.NotificationListener(transaction_label, command)
+        self.notification_listeners[command.event_id] = listener
+
+    def _on_avctp_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
+        logger.debug("AVCTP connection established")
+        l2cap_channel.on("open", lambda: self._on_avctp_channel_open(l2cap_channel))
+
+        self.emit("connection")
+
+    def _on_avctp_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
+        logger.debug("AVCTP channel open")
+        if self.avctp_protocol is not None:
+            # TODO: find a better strategy instead of just closing
+            logger.warning("AVCTP protocol already active, closing connection")
+            AsyncRunner.spawn(l2cap_channel.disconnect())
+            return
+
+        self.avctp_protocol = avctp.Protocol(l2cap_channel)
+        self.avctp_protocol.register_command_handler(AVRCP_PID, self._on_avctp_command)
+        self.avctp_protocol.register_response_handler(
+            AVRCP_PID, self._on_avctp_response
+        )
+        l2cap_channel.on("close", self._on_avctp_channel_close)
+
+        self.emit("start")
+
+    def _on_avctp_channel_close(self) -> None:
+        logger.debug("AVCTP channel closed")
+        self.avctp_protocol = None
+
+        self.emit("stop")
+
+    def _on_avctp_command(
+        self, transaction_label: int, command: avc.CommandFrame
+    ) -> None:
+        logger.debug(
+            f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}"
+        )
+
+        # Only the PANEL subunit type with subunit ID 0 is supported in this profile.
+        if (
+            command.subunit_type != avc.Frame.SubunitType.PANEL
+            or command.subunit_id != 0
+        ):
+            logger.debug("subunit not supported")
+            self.send_not_implemented_response(transaction_label, command)
+            return
+
+        if isinstance(command, avc.VendorDependentCommandFrame):
+            if not self._check_vendor_dependent_frame(command):
+                return
+
+            if self.receive_command_state is None:
+                self.receive_command_state = self.ReceiveCommandState(
+                    transaction_label=transaction_label, command_type=command.ctype
+                )
+            elif (
+                self.receive_command_state.transaction_label != transaction_label
+                or self.receive_command_state.command_type != command.ctype
+            ):
+                # We're in the middle of some other PDU
+                logger.warning("received interleaved PDU, resetting state")
+                self.command_pdu_assembler.reset()
+                self.receive_command_state = None
+                return
+            else:
+                self.receive_command_state.command_type = command.ctype
+                self.receive_command_state.transaction_label = transaction_label
+
+            self.command_pdu_assembler.on_pdu(command.vendor_dependent_data)
+            return
+
+        if isinstance(command, avc.PassThroughCommandFrame):
+            # TODO: delegate
+            response = avc.PassThroughResponseFrame(
+                avc.ResponseFrame.ResponseCode.ACCEPTED,
+                avc.Frame.SubunitType.PANEL,
+                0,
+                command.state_flag,
+                command.operation_id,
+                command.operation_data,
+            )
+            self.send_response(transaction_label, response)
+            return
+
+        # TODO handle other types
+        self.send_not_implemented_response(transaction_label, command)
+
+    def _on_avctp_response(
+        self, transaction_label: int, response: Optional[avc.ResponseFrame]
+    ) -> None:
+        logger.debug(
+            f"<<< AVCTP Response, transaction_label={transaction_label}: {response}"
+        )
+
+        # Check that we have a pending command that matches this response.
+        if not (pending_command := self.pending_commands.get(transaction_label)):
+            logger.warning("no pending command with this transaction label")
+            return
+
+        # A None response means an invalid PID was used in the request.
+        if response is None:
+            pending_command.response.set_exception(self.InvalidPidError())
+
+        if isinstance(response, avc.VendorDependentResponseFrame):
+            if not self._check_vendor_dependent_frame(response):
+                return
+
+            if self.receive_response_state is None:
+                self.receive_response_state = self.ReceiveResponseState(
+                    transaction_label=transaction_label, response_code=response.response
+                )
+            elif (
+                self.receive_response_state.transaction_label != transaction_label
+                or self.receive_response_state.response_code != response.response
+            ):
+                # We're in the middle of some other PDU
+                logger.warning("received interleaved PDU, resetting state")
+                self.response_pdu_assembler.reset()
+                self.receive_response_state = None
+                return
+            else:
+                self.receive_response_state.response_code = response.response
+                self.receive_response_state.transaction_label = transaction_label
+
+            self.response_pdu_assembler.on_pdu(response.vendor_dependent_data)
+            return
+
+        if isinstance(response, avc.PassThroughResponseFrame):
+            pending_command.response.set_result(response)
+
+        # TODO handle other types
+
+        self.recycle_pending_command(pending_command)
+
+    def _on_command_pdu(self, pdu_id: PduId, pdu: bytes) -> None:
+        logger.debug(f"<<< AVRCP command PDU [pdu_id={pdu_id.name}]: {pdu.hex()}")
+
+        assert self.receive_command_state is not None
+        transaction_label = self.receive_command_state.transaction_label
+
+        # Dispatch the command.
+        # NOTE: with a small number of supported commands, a manual dispatch like this
+        # is Ok, but if/when more commands are supported, a lookup dispatch mechanism
+        # would be more appropriate.
+        # TODO: switch on ctype
+        if self.receive_command_state.command_type in (
+            avc.CommandFrame.CommandType.CONTROL,
+            avc.CommandFrame.CommandType.STATUS,
+            avc.CommandFrame.CommandType.NOTIFY,
+        ):
+            # TODO: catch exceptions from delegates
+            if pdu_id == self.PduId.GET_CAPABILITIES:
+                self._on_get_capabilities_command(
+                    transaction_label, GetCapabilitiesCommand.from_bytes(pdu)
+                )
+            elif pdu_id == self.PduId.SET_ABSOLUTE_VOLUME:
+                self._on_set_absolute_volume_command(
+                    transaction_label, SetAbsoluteVolumeCommand.from_bytes(pdu)
+                )
+            elif pdu_id == self.PduId.REGISTER_NOTIFICATION:
+                self._on_register_notification_command(
+                    transaction_label, RegisterNotificationCommand.from_bytes(pdu)
+                )
+            else:
+                # Not supported.
+                # TODO: check that this is the right way to respond in this case.
+                logger.debug("unsupported PDU ID")
+                self.send_rejected_avrcp_response(
+                    transaction_label, pdu_id, self.StatusCode.INVALID_PARAMETER
+                )
+        else:
+            logger.debug("unsupported command type")
+            self.send_rejected_avrcp_response(
+                transaction_label, pdu_id, self.StatusCode.INVALID_COMMAND
+            )
+
+        self.receive_command_state = None
+
+    def _on_response_pdu(self, pdu_id: PduId, pdu: bytes) -> None:
+        logger.debug(f"<<< AVRCP response PDU [pdu_id={pdu_id.name}]: {pdu.hex()}")
+
+        assert self.receive_response_state is not None
+
+        transaction_label = self.receive_response_state.transaction_label
+        response_code = self.receive_response_state.response_code
+        self.receive_response_state = None
+
+        # Check that we have a pending command that matches this response.
+        if not (pending_command := self.pending_commands.get(transaction_label)):
+            logger.warning("no pending command with this transaction label")
+            return
+
+        # Convert the PDU bytes into a response object.
+        # NOTE: with a small number of supported responses, a manual switch like this
+        # is Ok, but if/when more responses are supported, a lookup mechanism would be
+        # more appropriate.
+        response: Optional[Response] = None
+        if response_code == avc.ResponseFrame.ResponseCode.REJECTED:
+            response = RejectedResponse.from_bytes(pdu_id, pdu)
+        elif response_code == avc.ResponseFrame.ResponseCode.NOT_IMPLEMENTED:
+            response = NotImplementedResponse.from_bytes(pdu_id, pdu)
+        elif response_code in (
+            avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE,
+            avc.ResponseFrame.ResponseCode.INTERIM,
+            avc.ResponseFrame.ResponseCode.CHANGED,
+            avc.ResponseFrame.ResponseCode.ACCEPTED,
+        ):
+            if pdu_id == self.PduId.GET_CAPABILITIES:
+                response = GetCapabilitiesResponse.from_bytes(pdu)
+            elif pdu_id == self.PduId.GET_PLAY_STATUS:
+                response = GetPlayStatusResponse.from_bytes(pdu)
+            elif pdu_id == self.PduId.GET_ELEMENT_ATTRIBUTES:
+                response = GetElementAttributesResponse.from_bytes(pdu)
+            elif pdu_id == self.PduId.SET_ABSOLUTE_VOLUME:
+                response = SetAbsoluteVolumeResponse.from_bytes(pdu)
+            elif pdu_id == self.PduId.REGISTER_NOTIFICATION:
+                response = RegisterNotificationResponse.from_bytes(pdu)
+            else:
+                logger.debug("unexpected PDU ID")
+                pending_command.response.set_exception(
+                    ProtocolError(
+                        error_code=None,
+                        error_namespace="avrcp",
+                        details="unexpected PDU ID",
+                    )
+                )
+        else:
+            logger.debug("unexpected response code")
+            pending_command.response.set_exception(
+                ProtocolError(
+                    error_code=None,
+                    error_namespace="avrcp",
+                    details="unexpected response code",
+                )
+            )
+
+        if response is None:
+            self.recycle_pending_command(pending_command)
+            return
+
+        logger.debug(f"<<< AVRCP response: {response}")
+
+        # Make the response available to the waiter.
+        if response_code == avc.ResponseFrame.ResponseCode.INTERIM:
+            pending_interim_response = pending_command.response
+            pending_command.reset()
+            pending_interim_response.set_result(
+                self.InterimResponse(
+                    pending_command.transaction_label,
+                    response,
+                    pending_command.response,
+                )
+            )
+        else:
+            pending_command.response.set_result(
+                self.FinalResponse(
+                    pending_command.transaction_label,
+                    response,
+                    response_code,
+                )
+            )
+            self.recycle_pending_command(pending_command)
+
+    def send_command(self, transaction_label: int, command: avc.CommandFrame) -> None:
+        logger.debug(f">>> AVRCP command: {command}")
+
+        if self.avctp_protocol is None:
+            logger.warning("trying to send command while avctp_protocol is None")
+            return
+
+        self.avctp_protocol.send_command(transaction_label, AVRCP_PID, bytes(command))
+
+    async def send_passthrough_command(
+        self, command: avc.PassThroughCommandFrame
+    ) -> avc.PassThroughResponseFrame:
+        # Wait for a free command slot.
+        pending_command = await self._obtain_pending_command()
+
+        # Send the command.
+        self.send_command(pending_command.transaction_label, command)
+
+        # Wait for the response.
+        return await pending_command.response
+
+    async def send_key_event(
+        self, key: avc.PassThroughCommandFrame.OperationId, pressed: bool
+    ) -> avc.PassThroughResponseFrame:
+        """Send a key event to the connected peer."""
+        return await self.send_passthrough_command(
+            avc.PassThroughCommandFrame(
+                avc.CommandFrame.CommandType.CONTROL,
+                avc.Frame.SubunitType.PANEL,
+                0,
+                avc.PassThroughFrame.StateFlag.PRESSED
+                if pressed
+                else avc.PassThroughFrame.StateFlag.RELEASED,
+                key,
+                b'',
+            )
+        )
+
+    async def send_avrcp_command(
+        self, command_type: avc.CommandFrame.CommandType, command: Command
+    ) -> ResponseContext:
+        # Wait for a free command slot.
+        pending_command = await self._obtain_pending_command()
+
+        # TODO: fragmentation
+        # Send the command.
+        logger.debug(f">>> AVRCP command PDU: {command}")
+        pdu = (
+            struct.pack(">BBH", command.pdu_id, 0, len(command.parameter))
+            + command.parameter
+        )
+        command_frame = avc.VendorDependentCommandFrame(
+            command_type,
+            avc.Frame.SubunitType.PANEL,
+            0,
+            AVRCP_BLUETOOTH_SIG_COMPANY_ID,
+            pdu,
+        )
+        self.send_command(pending_command.transaction_label, command_frame)
+
+        # Wait for the response.
+        return await pending_command.response
+
+    def send_response(
+        self, transaction_label: int, response: avc.ResponseFrame
+    ) -> None:
+        assert self.avctp_protocol is not None
+        logger.debug(f">>> AVRCP response: {response}")
+        self.avctp_protocol.send_response(transaction_label, AVRCP_PID, bytes(response))
+
+    def send_passthrough_response(
+        self,
+        transaction_label: int,
+        command: avc.PassThroughCommandFrame,
+        response_code: avc.ResponseFrame.ResponseCode,
+    ):
+        response = avc.PassThroughResponseFrame(
+            response_code,
+            avc.Frame.SubunitType.PANEL,
+            0,
+            command.state_flag,
+            command.operation_id,
+            command.operation_data,
+        )
+        self.send_response(transaction_label, response)
+
+    def send_avrcp_response(
+        self,
+        transaction_label: int,
+        response_code: avc.ResponseFrame.ResponseCode,
+        response: Response,
+    ) -> None:
+        # TODO: fragmentation
+        logger.debug(f">>> AVRCP response PDU: {response}")
+        pdu = (
+            struct.pack(">BBH", response.pdu_id, 0, len(response.parameter))
+            + response.parameter
+        )
+        response_frame = avc.VendorDependentResponseFrame(
+            response_code,
+            avc.Frame.SubunitType.PANEL,
+            0,
+            AVRCP_BLUETOOTH_SIG_COMPANY_ID,
+            pdu,
+        )
+        self.send_response(transaction_label, response_frame)
+
+    def send_not_implemented_response(
+        self, transaction_label: int, command: avc.CommandFrame
+    ) -> None:
+        response = avc.ResponseFrame(
+            avc.ResponseFrame.ResponseCode.NOT_IMPLEMENTED,
+            command.subunit_type,
+            command.subunit_id,
+            command.opcode,
+            command.operands,
+        )
+        self.send_response(transaction_label, response)
+
+    def send_rejected_avrcp_response(
+        self, transaction_label: int, pdu_id: Protocol.PduId, status_code: StatusCode
+    ) -> None:
+        self.send_avrcp_response(
+            transaction_label,
+            avc.ResponseFrame.ResponseCode.REJECTED,
+            RejectedResponse(pdu_id, status_code),
+        )
+
+    def _on_get_capabilities_command(
+        self, transaction_label: int, command: GetCapabilitiesCommand
+    ) -> None:
+        logger.debug(f"<<< AVRCP command PDU: {command}")
+
+        async def get_supported_events():
+            if (
+                command.capability_id
+                != GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED
+            ):
+                raise Protocol.InvalidParameterError
+
+            supported_events = await self.delegate.get_supported_events()
+            self.send_avrcp_response(
+                transaction_label,
+                avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE,
+                GetCapabilitiesResponse(command.capability_id, supported_events),
+            )
+
+        self._delegate_command(transaction_label, command, get_supported_events())
+
+    def _on_set_absolute_volume_command(
+        self, transaction_label: int, command: SetAbsoluteVolumeCommand
+    ) -> None:
+        logger.debug(f"<<< AVRCP command PDU: {command}")
+
+        async def set_absolute_volume():
+            await self.delegate.set_absolute_volume(command.volume)
+            effective_volume = await self.delegate.get_absolute_volume()
+            self.send_avrcp_response(
+                transaction_label,
+                avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE,
+                SetAbsoluteVolumeResponse(effective_volume),
+            )
+
+        self._delegate_command(transaction_label, command, set_absolute_volume())
+
+    def _on_register_notification_command(
+        self, transaction_label: int, command: RegisterNotificationCommand
+    ) -> None:
+        logger.debug(f"<<< AVRCP command PDU: {command}")
+
+        async def register_notification():
+            # Check if the event is supported.
+            supported_events = await self.delegate.get_supported_events()
+            if command.event_id in supported_events:
+                if command.event_id == EventId.VOLUME_CHANGED:
+                    volume = await self.delegate.get_absolute_volume()
+                    response = RegisterNotificationResponse(VolumeChangedEvent(volume))
+                    self.send_avrcp_response(
+                        transaction_label,
+                        avc.ResponseFrame.ResponseCode.INTERIM,
+                        response,
+                    )
+                    self._register_notification_listener(transaction_label, command)
+                    return
+
+                if command.event_id == EventId.PLAYBACK_STATUS_CHANGED:
+                    # TODO: testing only, use delegate
+                    response = RegisterNotificationResponse(
+                        PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING)
+                    )
+                    self.send_avrcp_response(
+                        transaction_label,
+                        avc.ResponseFrame.ResponseCode.INTERIM,
+                        response,
+                    )
+                    self._register_notification_listener(transaction_label, command)
+                    return
+
+        self._delegate_command(transaction_label, command, register_notification())
diff --git a/bumble/controller.py b/bumble/controller.py
index 9b2960a..eb20292 100644
--- a/bumble/controller.py
+++ b/bumble/controller.py
@@ -19,6 +19,7 @@
 
 import logging
 import asyncio
+import dataclasses
 import itertools
 import random
 import struct
@@ -42,6 +43,7 @@
     HCI_LE_1M_PHY,
     HCI_SUCCESS,
     HCI_UNKNOWN_HCI_COMMAND_ERROR,
+    HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
     HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
     HCI_VERSION_BLUETOOTH_CORE_5_0,
     Address,
@@ -53,17 +55,21 @@
     HCI_Connection_Request_Event,
     HCI_Disconnection_Complete_Event,
     HCI_Encryption_Change_Event,
+    HCI_Synchronous_Connection_Complete_Event,
     HCI_LE_Advertising_Report_Event,
+    HCI_LE_CIS_Established_Event,
+    HCI_LE_CIS_Request_Event,
     HCI_LE_Connection_Complete_Event,
     HCI_LE_Read_Remote_Features_Complete_Event,
     HCI_Number_Of_Completed_Packets_Event,
     HCI_Packet,
     HCI_Role_Change_Event,
 )
-from typing import Optional, Union, Dict, TYPE_CHECKING
+from typing import Optional, Union, Dict, Any, TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from bumble.transport.common import TransportSink, TransportSource
+    from bumble.link import LocalLink
+    from bumble.transport.common import TransportSink
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -79,15 +85,27 @@
 
 
 # -----------------------------------------------------------------------------
+@dataclasses.dataclass
+class CisLink:
+    handle: int
+    cis_id: int
+    cig_id: int
+    acl_connection: Optional[Connection] = None
+
+
+# -----------------------------------------------------------------------------
+@dataclasses.dataclass
 class Connection:
-    def __init__(self, controller, handle, role, peer_address, link, transport):
-        self.controller = controller
-        self.handle = handle
-        self.role = role
-        self.peer_address = peer_address
-        self.link = link
+    controller: Controller
+    handle: int
+    role: int
+    peer_address: Address
+    link: Any
+    transport: int
+    link_type: int
+
+    def __post_init__(self):
         self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
-        self.transport = transport
 
     def on_hci_acl_data_packet(self, packet):
         self.assembler.feed_packet(packet)
@@ -106,10 +124,10 @@
 class Controller:
     def __init__(
         self,
-        name,
+        name: str,
         host_source=None,
         host_sink: Optional[TransportSink] = None,
-        link=None,
+        link: Optional[LocalLink] = None,
         public_address: Optional[Union[bytes, str, Address]] = None,
     ):
         self.name = name
@@ -125,6 +143,8 @@
         self.classic_connections: Dict[
             Address, Connection
         ] = {}  # Connections in BR/EDR
+        self.central_cis_links: Dict[int, CisLink] = {}  # CIS links by handle
+        self.peripheral_cis_links: Dict[int, CisLink] = {}  # CIS links by handle
 
         self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
         self.hci_revision = 0
@@ -134,12 +154,14 @@
             '0000000060000000'
         )  # BR/EDR Not Supported, LE Supported (Controller)
         self.manufacturer_name = 0xFFFF
+        self.hc_data_packet_length = 27
+        self.hc_total_num_data_packets = 64
         self.hc_le_data_packet_length = 27
         self.hc_total_num_le_data_packets = 64
         self.event_mask = 0
         self.event_mask_page_2 = 0
         self.supported_commands = bytes.fromhex(
-            '2000800000c000000000e40000002822000000000000040000f7ffff7f000000'
+            '2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
             '30f0f9ff01008004000000000000000000000000000000000000000000000000'
         )
         self.le_event_mask = 0
@@ -301,7 +323,7 @@
     ############################################################
     # Link connections
     ############################################################
-    def allocate_connection_handle(self):
+    def allocate_connection_handle(self) -> int:
         handle = 0
         max_handle = 0
         for connection in itertools.chain(
@@ -313,6 +335,13 @@
             if connection.handle == handle:
                 # Already used, continue searching after the current max
                 handle = max_handle + 1
+        for cis_handle in itertools.chain(
+            self.central_cis_links.keys(), self.peripheral_cis_links.keys()
+        ):
+            max_handle = max(max_handle, cis_handle)
+            if cis_handle == handle:
+                # Already used, continue searching after the current max
+                handle = max_handle + 1
         return handle
 
     def find_le_connection_by_address(self, address):
@@ -357,12 +386,13 @@
         if connection is None:
             connection_handle = self.allocate_connection_handle()
             connection = Connection(
-                self,
-                connection_handle,
-                BT_PERIPHERAL_ROLE,
-                peer_address,
-                self.link,
-                BT_LE_TRANSPORT,
+                controller=self,
+                handle=connection_handle,
+                role=BT_PERIPHERAL_ROLE,
+                peer_address=peer_address,
+                link=self.link,
+                transport=BT_LE_TRANSPORT,
+                link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
             )
             self.peripheral_connections[peer_address] = connection
             logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
@@ -416,12 +446,13 @@
             if connection is None:
                 connection_handle = self.allocate_connection_handle()
                 connection = Connection(
-                    self,
-                    connection_handle,
-                    BT_CENTRAL_ROLE,
-                    peer_address,
-                    self.link,
-                    BT_LE_TRANSPORT,
+                    controller=self,
+                    handle=connection_handle,
+                    role=BT_CENTRAL_ROLE,
+                    peer_address=peer_address,
+                    link=self.link,
+                    transport=BT_LE_TRANSPORT,
+                    link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
                 )
                 self.central_connections[peer_address] = connection
                 logger.debug(
@@ -538,6 +569,104 @@
         )
         self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
 
+    def on_link_cis_request(
+        self, central_address: Address, cig_id: int, cis_id: int
+    ) -> None:
+        '''
+        Called when an incoming CIS request occurs from a central on the link
+        '''
+
+        connection = self.peripheral_connections.get(central_address)
+        assert connection
+
+        pending_cis_link = CisLink(
+            handle=self.allocate_connection_handle(),
+            cis_id=cis_id,
+            cig_id=cig_id,
+            acl_connection=connection,
+        )
+        self.peripheral_cis_links[pending_cis_link.handle] = pending_cis_link
+
+        self.send_hci_packet(
+            HCI_LE_CIS_Request_Event(
+                acl_connection_handle=connection.handle,
+                cis_connection_handle=pending_cis_link.handle,
+                cig_id=cig_id,
+                cis_id=cis_id,
+            )
+        )
+
+    def on_link_cis_established(self, cig_id: int, cis_id: int) -> None:
+        '''
+        Called when an incoming CIS established.
+        '''
+
+        cis_link = next(
+            cis_link
+            for cis_link in itertools.chain(
+                self.central_cis_links.values(), self.peripheral_cis_links.values()
+            )
+            if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
+        )
+
+        self.send_hci_packet(
+            HCI_LE_CIS_Established_Event(
+                status=HCI_SUCCESS,
+                connection_handle=cis_link.handle,
+                # CIS parameters are ignored.
+                cig_sync_delay=0,
+                cis_sync_delay=0,
+                transport_latency_c_to_p=0,
+                transport_latency_p_to_c=0,
+                phy_c_to_p=0,
+                phy_p_to_c=0,
+                nse=0,
+                bn_c_to_p=0,
+                bn_p_to_c=0,
+                ft_c_to_p=0,
+                ft_p_to_c=0,
+                max_pdu_c_to_p=0,
+                max_pdu_p_to_c=0,
+                iso_interval=0,
+            )
+        )
+
+    def on_link_cis_disconnected(self, cig_id: int, cis_id: int) -> None:
+        '''
+        Called when a CIS disconnected.
+        '''
+
+        if cis_link := next(
+            (
+                cis_link
+                for cis_link in self.peripheral_cis_links.values()
+                if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
+            ),
+            None,
+        ):
+            # Remove peripheral CIS on disconnection.
+            self.peripheral_cis_links.pop(cis_link.handle)
+        elif cis_link := next(
+            (
+                cis_link
+                for cis_link in self.central_cis_links.values()
+                if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
+            ),
+            None,
+        ):
+            # Keep central CIS on disconnection. They should be removed by HCI_LE_Remove_CIG_Command.
+            cis_link.acl_connection = None
+        else:
+            return
+
+        self.send_hci_packet(
+            HCI_Disconnection_Complete_Event(
+                status=HCI_SUCCESS,
+                connection_handle=cis_link.handle,
+                reason=HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
+            )
+        )
+
     ############################################################
     # Classic link connections
     ############################################################
@@ -566,6 +695,7 @@
                     peer_address=peer_address,
                     link=self.link,
                     transport=BT_BR_EDR_TRANSPORT,
+                    link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
                 )
                 self.classic_connections[peer_address] = connection
                 logger.debug(
@@ -619,6 +749,42 @@
             )
         )
 
+    def on_classic_sco_connection_complete(
+        self, peer_address: Address, status: int, link_type: int
+    ):
+        if status == HCI_SUCCESS:
+            # Allocate (or reuse) a connection handle
+            connection_handle = self.allocate_connection_handle()
+            connection = Connection(
+                controller=self,
+                handle=connection_handle,
+                # Role doesn't matter in SCO.
+                role=BT_CENTRAL_ROLE,
+                peer_address=peer_address,
+                link=self.link,
+                transport=BT_BR_EDR_TRANSPORT,
+                link_type=link_type,
+            )
+            self.classic_connections[peer_address] = connection
+            logger.debug(f'New SCO connection handle: 0x{connection_handle:04X}')
+        else:
+            connection_handle = 0
+
+        self.send_hci_packet(
+            HCI_Synchronous_Connection_Complete_Event(
+                status=status,
+                connection_handle=connection_handle,
+                bd_addr=peer_address,
+                link_type=link_type,
+                # TODO: Provide SCO connection parameters.
+                transmission_interval=0,
+                retransmission_window=0,
+                rx_packet_length=0,
+                tx_packet_length=0,
+                air_mode=0,
+            )
+        )
+
     ############################################################
     # Advertising support
     ############################################################
@@ -721,6 +887,17 @@
             else:
                 # Remove the connection
                 del self.classic_connections[connection.peer_address]
+        elif cis_link := (
+            self.central_cis_links.get(handle) or self.peripheral_cis_links.get(handle)
+        ):
+            if self.link:
+                self.link.disconnect_cis(
+                    initiator_controller=self,
+                    peer_address=cis_link.acl_connection.peer_address,
+                    cig_id=cis_link.cig_id,
+                    cis_id=cis_link.cis_id,
+                )
+            # Spec requires handle to be kept after disconnection.
 
     def on_hci_accept_connection_request_command(self, command):
         '''
@@ -738,6 +915,68 @@
         )
         self.link.classic_accept_connection(self, command.bd_addr, command.role)
 
+    def on_hci_enhanced_setup_synchronous_connection_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.1.45 Enhanced Setup Synchronous Connection command
+        '''
+
+        if self.link is None:
+            return
+
+        if not (
+            connection := self.find_classic_connection_by_handle(
+                command.connection_handle
+            )
+        ):
+            self.send_hci_packet(
+                HCI_Command_Status_Event(
+                    status=HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
+                    num_hci_command_packets=1,
+                    command_opcode=command.op_code,
+                )
+            )
+            return
+
+        self.send_hci_packet(
+            HCI_Command_Status_Event(
+                status=HCI_SUCCESS,
+                num_hci_command_packets=1,
+                command_opcode=command.op_code,
+            )
+        )
+        self.link.classic_sco_connect(
+            self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
+        )
+
+    def on_hci_enhanced_accept_synchronous_connection_request_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.1.46 Enhanced Accept Synchronous Connection Request command
+        '''
+
+        if self.link is None:
+            return
+
+        if not (connection := self.find_classic_connection_by_address(command.bd_addr)):
+            self.send_hci_packet(
+                HCI_Command_Status_Event(
+                    status=HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
+                    num_hci_command_packets=1,
+                    command_opcode=command.op_code,
+                )
+            )
+            return
+
+        self.send_hci_packet(
+            HCI_Command_Status_Event(
+                status=HCI_SUCCESS,
+                num_hci_command_packets=1,
+                command_opcode=command.op_code,
+            )
+        )
+        self.link.classic_accept_sco_connection(
+            self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
+        )
+
     def on_hci_switch_role_command(self, command):
         '''
         See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
@@ -912,7 +1151,41 @@
         '''
         See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
         '''
-        return bytes([HCI_SUCCESS]) + self.lmp_features
+        return bytes([HCI_SUCCESS]) + self.lmp_features[:8]
+
+    def on_hci_read_local_extended_features_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.4.4 Read Local Extended Features Command
+        '''
+        if command.page_number * 8 > len(self.lmp_features):
+            return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
+        return (
+            bytes(
+                [
+                    # Status
+                    HCI_SUCCESS,
+                    # Page number
+                    command.page_number,
+                    # Max page number
+                    len(self.lmp_features) // 8 - 1,
+                ]
+            )
+            # Features of the current page
+            + self.lmp_features[command.page_number * 8 : (command.page_number + 1) * 8]
+        )
+
+    def on_hci_read_buffer_size_command(self, _command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.4.5 Read Buffer Size Command
+        '''
+        return struct.pack(
+            '<BHBHH',
+            HCI_SUCCESS,
+            self.hc_data_packet_length,
+            0,
+            self.hc_total_num_data_packets,
+            0,
+        )
 
     def on_hci_read_bd_addr_command(self, _command):
         '''
@@ -1000,6 +1273,9 @@
         '''
         See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
         '''
+        if self.le_scan_enable:
+            return bytes([HCI_COMMAND_DISALLOWED_ERROR])
+
         self.le_scan_type = command.le_scan_type
         self.le_scan_interval = command.le_scan_interval
         self.le_scan_window = command.le_scan_window
@@ -1086,6 +1362,18 @@
         See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
         '''
 
+        handle = command.connection_handle
+
+        if not self.find_connection_by_handle(handle):
+            self.send_hci_packet(
+                HCI_Command_Status_Event(
+                    status=HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR,
+                    num_hci_command_packets=1,
+                    command_opcode=command.op_code,
+                )
+            )
+            return
+
         # First, say that the command is pending
         self.send_hci_packet(
             HCI_Command_Status_Event(
@@ -1099,7 +1387,7 @@
         self.send_hci_packet(
             HCI_LE_Read_Remote_Features_Complete_Event(
                 status=HCI_SUCCESS,
-                connection_handle=0,
+                connection_handle=handle,
                 le_features=bytes.fromhex('dd40000000000000'),
             )
         )
@@ -1255,8 +1543,135 @@
         }
         return bytes([HCI_SUCCESS])
 
+    def on_hci_le_read_maximum_advertising_data_length_command(self, _command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.8.57 LE Read Maximum Advertising Data
+        Length Command
+        '''
+        return struct.pack('<BH', HCI_SUCCESS, 0x0672)
+
+    def on_hci_le_read_number_of_supported_advertising_sets_command(self, _command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.8.58 LE Read Number of Supported
+        Advertising Set Command
+        '''
+        return struct.pack('<BB', HCI_SUCCESS, 0xF0)
+
     def on_hci_le_read_transmit_power_command(self, _command):
         '''
         See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
         '''
         return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
+
+    def on_hci_le_set_cig_parameters_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.8.97 LE Set CIG Parameter Command
+        '''
+
+        # Remove old CIG implicitly.
+        for handle, cis_link in self.central_cis_links.items():
+            if cis_link.cig_id == command.cig_id:
+                self.central_cis_links.pop(handle)
+
+        handles = []
+        for cis_id in command.cis_id:
+            handle = self.allocate_connection_handle()
+            handles.append(handle)
+            self.central_cis_links[handle] = CisLink(
+                cis_id=cis_id,
+                cig_id=command.cig_id,
+                handle=handle,
+            )
+        return struct.pack(
+            '<BBB', HCI_SUCCESS, command.cig_id, len(handles)
+        ) + b''.join([struct.pack('<H', handle) for handle in handles])
+
+    def on_hci_le_create_cis_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.8.99 LE Create CIS Command
+        '''
+        if not self.link:
+            return
+
+        for cis_handle, acl_handle in zip(
+            command.cis_connection_handle, command.acl_connection_handle
+        ):
+            if not (connection := self.find_connection_by_handle(acl_handle)):
+                logger.error(f'Cannot find connection with handle={acl_handle}')
+                return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
+
+            if not (cis_link := self.central_cis_links.get(cis_handle)):
+                logger.error(f'Cannot find CIS with handle={cis_handle}')
+                return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
+
+            cis_link.acl_connection = connection
+
+            self.link.create_cis(
+                self,
+                peripheral_address=connection.peer_address,
+                cig_id=cis_link.cig_id,
+                cis_id=cis_link.cis_id,
+            )
+
+        self.send_hci_packet(
+            HCI_Command_Status_Event(
+                status=HCI_COMMAND_STATUS_PENDING,
+                num_hci_command_packets=1,
+                command_opcode=command.op_code,
+            )
+        )
+
+    def on_hci_le_remove_cig_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.8.100 LE Remove CIG Command
+        '''
+
+        status = HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR
+
+        for cis_handle, cis_link in self.central_cis_links.items():
+            if cis_link.cig_id == command.cig_id:
+                self.central_cis_links.pop(cis_handle)
+                status = HCI_SUCCESS
+
+        return struct.pack('<BH', status, command.cig_id)
+
+    def on_hci_le_accept_cis_request_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.8.101 LE Accept CIS Request Command
+        '''
+        if not self.link:
+            return
+
+        if not (
+            pending_cis_link := self.peripheral_cis_links.get(command.connection_handle)
+        ):
+            logger.error(f'Cannot find CIS with handle={command.connection_handle}')
+            return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
+
+        assert pending_cis_link.acl_connection
+        self.link.accept_cis(
+            peripheral_controller=self,
+            central_address=pending_cis_link.acl_connection.peer_address,
+            cig_id=pending_cis_link.cig_id,
+            cis_id=pending_cis_link.cis_id,
+        )
+
+        self.send_hci_packet(
+            HCI_Command_Status_Event(
+                status=HCI_COMMAND_STATUS_PENDING,
+                num_hci_command_packets=1,
+                command_opcode=command.op_code,
+            )
+        )
+
+    def on_hci_le_setup_iso_data_path_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.8.109 LE Setup ISO Data Path Command
+        '''
+        return struct.pack('<BH', HCI_SUCCESS, command.connection_handle)
+
+    def on_hci_le_remove_iso_data_path_command(self, command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.8.110 LE Remove ISO Data Path Command
+        '''
+        return struct.pack('<BH', HCI_SUCCESS, command.connection_handle)
diff --git a/bumble/core.py b/bumble/core.py
index 4a67d6e..dce721a 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -16,6 +16,7 @@
 # Imports
 # -----------------------------------------------------------------------------
 from __future__ import annotations
+import enum
 import struct
 from typing import List, Optional, Tuple, Union, cast, Dict
 
@@ -96,12 +97,16 @@
             namespace = f'{self.error_namespace}/'
         else:
             namespace = ''
-        error_text = {
-            (True, True): f'{self.error_name} [0x{self.error_code:X}]',
-            (True, False): self.error_name,
-            (False, True): f'0x{self.error_code:X}',
-            (False, False): '',
-        }[(self.error_name != '', self.error_code is not None)]
+        have_name = self.error_name != ''
+        have_code = self.error_code is not None
+        if have_name and have_code:
+            error_text = f'{self.error_name} [0x{self.error_code:X}]'
+        elif have_name and not have_code:
+            error_text = self.error_name
+        elif not have_name and have_code:
+            error_text = f'0x{self.error_code:X}'
+        else:
+            error_text = '<unspecified>'
 
         return f'{type(self).__name__}({namespace}{error_text})'
 
@@ -318,7 +323,7 @@
 BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
 BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID    = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
 BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID    = UUID.from_16_bits(0x0016, 'HardcopyNotification')
-BT_AVTCP_PROTOCOL_ID                    = UUID.from_16_bits(0x0017, 'AVCTP')
+BT_AVCTP_PROTOCOL_ID                    = UUID.from_16_bits(0x0017, 'AVCTP')
 BT_AVDTP_PROTOCOL_ID                    = UUID.from_16_bits(0x0019, 'AVDTP')
 BT_CMTP_PROTOCOL_ID                     = UUID.from_16_bits(0x001B, 'CMTP')
 BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID     = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
@@ -820,8 +825,8 @@
             ad_structures = []
         self.ad_structures = ad_structures[:]
 
-    @staticmethod
-    def from_bytes(data):
+    @classmethod
+    def from_bytes(cls, data: bytes) -> AdvertisingData:
         instance = AdvertisingData()
         instance.append(data)
         return instance
@@ -977,7 +982,7 @@
 
         return ad_data
 
-    def append(self, data):
+    def append(self, data: bytes) -> None:
         offset = 0
         while offset + 1 < len(data):
             length = data[offset]
@@ -1051,3 +1056,13 @@
 
     def __str__(self):
         return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'
+
+
+# -----------------------------------------------------------------------------
+# LE Role
+# -----------------------------------------------------------------------------
+class LeRole(enum.IntEnum):
+    PERIPHERAL_ONLY = 0x00
+    CENTRAL_ONLY = 0x01
+    BOTH_PERIPHERAL_PREFERRED = 0x02
+    BOTH_CENTRAL_PREFERRED = 0x03
diff --git a/bumble/crypto.py b/bumble/crypto.py
index 852c675..af95160 100644
--- a/bumble/crypto.py
+++ b/bumble/crypto.py
@@ -21,6 +21,8 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
+
 import logging
 import operator
 
@@ -29,11 +31,13 @@
 from cryptography.hazmat.primitives.asymmetric.ec import (
     generate_private_key,
     ECDH,
+    EllipticCurvePrivateKey,
     EllipticCurvePublicNumbers,
     EllipticCurvePrivateNumbers,
     SECP256R1,
 )
 from cryptography.hazmat.primitives import cmac
+from typing import Tuple
 
 
 # -----------------------------------------------------------------------------
@@ -46,16 +50,18 @@
 # Classes
 # -----------------------------------------------------------------------------
 class EccKey:
-    def __init__(self, private_key):
+    def __init__(self, private_key: EllipticCurvePrivateKey) -> None:
         self.private_key = private_key
 
     @classmethod
-    def generate(cls):
+    def generate(cls) -> EccKey:
         private_key = generate_private_key(SECP256R1())
         return cls(private_key)
 
     @classmethod
-    def from_private_key_bytes(cls, d_bytes, x_bytes, y_bytes):
+    def from_private_key_bytes(
+        cls, d_bytes: bytes, x_bytes: bytes, y_bytes: bytes
+    ) -> EccKey:
         d = int.from_bytes(d_bytes, byteorder='big', signed=False)
         x = int.from_bytes(x_bytes, byteorder='big', signed=False)
         y = int.from_bytes(y_bytes, byteorder='big', signed=False)
@@ -65,7 +71,7 @@
         return cls(private_key)
 
     @property
-    def x(self):
+    def x(self) -> bytes:
         return (
             self.private_key.public_key()
             .public_numbers()
@@ -73,14 +79,14 @@
         )
 
     @property
-    def y(self):
+    def y(self) -> bytes:
         return (
             self.private_key.public_key()
             .public_numbers()
             .y.to_bytes(32, byteorder='big')
         )
 
-    def dh(self, public_key_x, public_key_y):
+    def dh(self, public_key_x: bytes, public_key_y: bytes) -> bytes:
         x = int.from_bytes(public_key_x, byteorder='big', signed=False)
         y = int.from_bytes(public_key_y, byteorder='big', signed=False)
         public_key = EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key()
@@ -93,14 +99,33 @@
 # Functions
 # -----------------------------------------------------------------------------
 
+
 # -----------------------------------------------------------------------------
-def xor(x, y):
+def generate_prand() -> bytes:
+    '''Generates random 3 bytes, with the 2 most significant bits of 0b01.
+
+    See Bluetooth spec, Vol 6, Part E - Table 1.2.
+    '''
+    prand_bytes = secrets.token_bytes(6)
+    return prand_bytes[:2] + bytes([(prand_bytes[2] & 0b01111111) | 0b01000000])
+
+
+# -----------------------------------------------------------------------------
+def xor(x: bytes, y: bytes) -> bytes:
     assert len(x) == len(y)
     return bytes(map(operator.xor, x, y))
 
 
 # -----------------------------------------------------------------------------
-def r():
+def reverse(input: bytes) -> bytes:
+    '''
+    Returns bytes of input in reversed endianness.
+    '''
+    return input[::-1]
+
+
+# -----------------------------------------------------------------------------
+def r() -> bytes:
     '''
     Generate 16 bytes of random data
     '''
@@ -108,20 +133,20 @@
 
 
 # -----------------------------------------------------------------------------
-def e(key, data):
+def e(key: bytes, data: bytes) -> bytes:
     '''
     AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output.
 
     See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e
     '''
 
-    cipher = Cipher(algorithms.AES(bytes(reversed(key))), modes.ECB())
+    cipher = Cipher(algorithms.AES(reverse(key)), modes.ECB())
     encryptor = cipher.encryptor()
-    return bytes(reversed(encryptor.update(bytes(reversed(data)))))
+    return reverse(encryptor.update(reverse(data)))
 
 
 # -----------------------------------------------------------------------------
-def ah(k, r):  # pylint: disable=redefined-outer-name
+def ah(k: bytes, r: bytes) -> bytes:  # pylint: disable=redefined-outer-name
     '''
     See Bluetooth spec Vol 3, Part H - 2.2.2 Random Address Hash function ah
     '''
@@ -132,7 +157,16 @@
 
 
 # -----------------------------------------------------------------------------
-def c1(k, r, preq, pres, iat, rat, ia, ra):  # pylint: disable=redefined-outer-name
+def c1(
+    k: bytes,
+    r: bytes,
+    preq: bytes,
+    pres: bytes,
+    iat: int,
+    rat: int,
+    ia: bytes,
+    ra: bytes,
+) -> bytes:  # pylint: disable=redefined-outer-name
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.3 Confirm value generation function c1 for
     LE Legacy Pairing
@@ -144,7 +178,7 @@
 
 
 # -----------------------------------------------------------------------------
-def s1(k, r1, r2):
+def s1(k: bytes, r1: bytes, r2: bytes) -> bytes:
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.4 Key generation function s1 for LE Legacy
     Pairing
@@ -154,7 +188,7 @@
 
 
 # -----------------------------------------------------------------------------
-def aes_cmac(m, k):
+def aes_cmac(m: bytes, k: bytes) -> bytes:
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.5 FunctionAES-CMAC
 
@@ -166,20 +200,16 @@
 
 
 # -----------------------------------------------------------------------------
-def f4(u, v, x, z):
+def f4(u: bytes, v: bytes, x: bytes, z: bytes) -> bytes:
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.6 LE Secure Connections Confirm Value
     Generation Function f4
     '''
-    return bytes(
-        reversed(
-            aes_cmac(bytes(reversed(u)) + bytes(reversed(v)) + z, bytes(reversed(x)))
-        )
-    )
+    return reverse(aes_cmac(reverse(u) + reverse(v) + z, reverse(x)))
 
 
 # -----------------------------------------------------------------------------
-def f5(w, n1, n2, a1, a2):
+def f5(w: bytes, n1: bytes, n2: bytes, a1: bytes, a2: bytes) -> Tuple[bytes, bytes]:
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.7 LE Secure Connections Key Generation
     Function f5
@@ -187,87 +217,83 @@
     NOTE: this returns a tuple: (MacKey, LTK) in little-endian byte order
     '''
     salt = bytes.fromhex('6C888391AAF5A53860370BDB5A6083BE')
-    t = aes_cmac(bytes(reversed(w)), salt)
+    t = aes_cmac(reverse(w), salt)
     key_id = bytes([0x62, 0x74, 0x6C, 0x65])
     return (
-        bytes(
-            reversed(
-                aes_cmac(
-                    bytes([0])
-                    + key_id
-                    + bytes(reversed(n1))
-                    + bytes(reversed(n2))
-                    + bytes(reversed(a1))
-                    + bytes(reversed(a2))
-                    + bytes([1, 0]),
-                    t,
-                )
+        reverse(
+            aes_cmac(
+                bytes([0])
+                + key_id
+                + reverse(n1)
+                + reverse(n2)
+                + reverse(a1)
+                + reverse(a2)
+                + bytes([1, 0]),
+                t,
             )
         ),
-        bytes(
-            reversed(
-                aes_cmac(
-                    bytes([1])
-                    + key_id
-                    + bytes(reversed(n1))
-                    + bytes(reversed(n2))
-                    + bytes(reversed(a1))
-                    + bytes(reversed(a2))
-                    + bytes([1, 0]),
-                    t,
-                )
+        reverse(
+            aes_cmac(
+                bytes([1])
+                + key_id
+                + reverse(n1)
+                + reverse(n2)
+                + reverse(a1)
+                + reverse(a2)
+                + bytes([1, 0]),
+                t,
             )
         ),
     )
 
 
 # -----------------------------------------------------------------------------
-def f6(w, n1, n2, r, io_cap, a1, a2):  # pylint: disable=redefined-outer-name
+def f6(
+    w: bytes, n1: bytes, n2: bytes, r: bytes, io_cap: bytes, a1: bytes, a2: bytes
+) -> bytes:  # pylint: disable=redefined-outer-name
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.8 LE Secure Connections Check Value
     Generation Function f6
     '''
-    return bytes(
-        reversed(
-            aes_cmac(
-                bytes(reversed(n1))
-                + bytes(reversed(n2))
-                + bytes(reversed(r))
-                + bytes(reversed(io_cap))
-                + bytes(reversed(a1))
-                + bytes(reversed(a2)),
-                bytes(reversed(w)),
-            )
+    return reverse(
+        aes_cmac(
+            reverse(n1)
+            + reverse(n2)
+            + reverse(r)
+            + reverse(io_cap)
+            + reverse(a1)
+            + reverse(a2),
+            reverse(w),
         )
     )
 
 
 # -----------------------------------------------------------------------------
-def g2(u, v, x, y):
+def g2(u: bytes, v: bytes, x: bytes, y: bytes) -> int:
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.9 LE Secure Connections Numeric Comparison
     Value Generation Function g2
     '''
     return int.from_bytes(
         aes_cmac(
-            bytes(reversed(u)) + bytes(reversed(v)) + bytes(reversed(y)),
-            bytes(reversed(x)),
+            reverse(u) + reverse(v) + reverse(y),
+            reverse(x),
         )[-4:],
         byteorder='big',
     )
 
 
 # -----------------------------------------------------------------------------
-def h6(w, key_id):
+def h6(w: bytes, key_id: bytes) -> bytes:
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.10 Link key conversion function h6
     '''
-    return aes_cmac(key_id, w)
+    return reverse(aes_cmac(key_id, reverse(w)))
 
 
 # -----------------------------------------------------------------------------
-def h7(salt, w):
+def h7(salt: bytes, w: bytes) -> bytes:
     '''
     See Bluetooth spec, Vol 3, Part H - 2.2.11 Link key conversion function h7
     '''
-    return aes_cmac(w, salt)
+    return reverse(aes_cmac(reverse(w), salt))
diff --git a/bumble/device.py b/bumble/device.py
index 7f11012..48f9d58 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -21,8 +21,10 @@
 import json
 import asyncio
 import logging
-from contextlib import asynccontextmanager, AsyncExitStack
-from dataclasses import dataclass
+import secrets
+from contextlib import asynccontextmanager, AsyncExitStack, closing
+from dataclasses import dataclass, field
+from collections.abc import Iterable
 from typing import (
     Any,
     Callable,
@@ -32,12 +34,15 @@
     Optional,
     Tuple,
     Type,
+    TypeVar,
     Union,
     cast,
     overload,
     TYPE_CHECKING,
 )
 
+from pyee import EventEmitter
+
 from .colors import color
 from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
 from .gatt import Characteristic, Descriptor, Service
@@ -45,6 +50,7 @@
     HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
     HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
     HCI_CENTRAL_ROLE,
+    HCI_PERIPHERAL_ROLE,
     HCI_COMMAND_STATUS_PENDING,
     HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR,
     HCI_DISPLAY_YES_NO_IO_CAPABILITY,
@@ -56,12 +62,8 @@
     HCI_LE_1M_PHY,
     HCI_LE_1M_PHY_BIT,
     HCI_LE_2M_PHY,
-    HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE,
-    HCI_LE_CLEAR_RESOLVING_LIST_COMMAND,
     HCI_LE_CODED_PHY,
     HCI_LE_CODED_PHY_BIT,
-    HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE,
-    HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE,
     HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND,
     HCI_LE_RAND_COMMAND,
     HCI_LE_READ_PHY_COMMAND,
@@ -75,37 +77,52 @@
     HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
     HCI_SUCCESS,
     HCI_WRITE_LE_HOST_SUPPORT_COMMAND,
-    Address,
     HCI_Accept_Connection_Request_Command,
     HCI_Authentication_Requested_Command,
     HCI_Command_Status_Event,
     HCI_Constant,
     HCI_Create_Connection_Cancel_Command,
     HCI_Create_Connection_Command,
+    HCI_Connection_Complete_Event,
     HCI_Disconnect_Command,
     HCI_Encryption_Change_Event,
     HCI_Error,
     HCI_IO_Capability_Request_Reply_Command,
     HCI_Inquiry_Cancel_Command,
     HCI_Inquiry_Command,
+    HCI_IsoDataPacket,
+    HCI_LE_Accept_CIS_Request_Command,
     HCI_LE_Add_Device_To_Resolving_List_Command,
     HCI_LE_Advertising_Report_Event,
     HCI_LE_Clear_Resolving_List_Command,
     HCI_LE_Connection_Update_Command,
     HCI_LE_Create_Connection_Cancel_Command,
     HCI_LE_Create_Connection_Command,
+    HCI_LE_Create_CIS_Command,
     HCI_LE_Enable_Encryption_Command,
     HCI_LE_Extended_Advertising_Report_Event,
     HCI_LE_Extended_Create_Connection_Command,
     HCI_LE_Rand_Command,
     HCI_LE_Read_PHY_Command,
+    HCI_LE_Read_Remote_Features_Command,
+    HCI_LE_Reject_CIS_Request_Command,
+    HCI_LE_Remove_Advertising_Set_Command,
     HCI_LE_Set_Address_Resolution_Enable_Command,
     HCI_LE_Set_Advertising_Data_Command,
     HCI_LE_Set_Advertising_Enable_Command,
     HCI_LE_Set_Advertising_Parameters_Command,
+    HCI_LE_Set_Advertising_Set_Random_Address_Command,
+    HCI_LE_Set_CIG_Parameters_Command,
+    HCI_LE_Set_Data_Length_Command,
     HCI_LE_Set_Default_PHY_Command,
     HCI_LE_Set_Extended_Scan_Enable_Command,
     HCI_LE_Set_Extended_Scan_Parameters_Command,
+    HCI_LE_Set_Extended_Scan_Response_Data_Command,
+    HCI_LE_Set_Extended_Advertising_Data_Command,
+    HCI_LE_Set_Extended_Advertising_Enable_Command,
+    HCI_LE_Set_Extended_Advertising_Parameters_Command,
+    HCI_LE_Set_Host_Feature_Command,
+    HCI_LE_Set_Periodic_Advertising_Enable_Command,
     HCI_LE_Set_PHY_Command,
     HCI_LE_Set_Random_Address_Command,
     HCI_LE_Set_Scan_Enable_Command,
@@ -120,6 +137,7 @@
     HCI_Switch_Role_Command,
     HCI_Set_Connection_Encryption_Command,
     HCI_StatusError,
+    HCI_SynchronousDataPacket,
     HCI_User_Confirmation_Request_Negative_Reply_Command,
     HCI_User_Confirmation_Request_Reply_Command,
     HCI_User_Passkey_Request_Negative_Reply_Command,
@@ -132,7 +150,11 @@
     HCI_Write_Scan_Enable_Command,
     HCI_Write_Secure_Connections_Host_Support_Command,
     HCI_Write_Simple_Pairing_Mode_Command,
+    Address,
     OwnAddressType,
+    LeFeature,
+    LeFeatureMask,
+    Phy,
     phy_list_to_bits,
 )
 from .host import Host
@@ -151,9 +173,11 @@
 from .utils import (
     AsyncRunner,
     CompositeEventEmitter,
+    EventWatcher,
     setup_event_forwarding,
     composite_listener,
     deprecated,
+    experimental,
 )
 from .keys import (
     KeyStore,
@@ -188,6 +212,8 @@
 DEVICE_MAX_SCAN_WINDOW                        = 10240
 DEVICE_MIN_LE_RSSI                            = -127
 DEVICE_MAX_LE_RSSI                            = 20
+DEVICE_MIN_EXTENDED_ADVERTISING_SET_HANDLE    = 0x00
+DEVICE_MAX_EXTENDED_ADVERTISING_SET_HANDLE    = 0xEF
 
 DEVICE_DEFAULT_ADDRESS                        = '00:00:00:00:00:00'
 DEVICE_DEFAULT_ADVERTISING_INTERVAL           = 1000  # ms
@@ -211,10 +237,16 @@
 DEVICE_DEFAULT_L2CAP_COC_MTU                  = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU
 DEVICE_DEFAULT_L2CAP_COC_MPS                  = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
 DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS          = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS
+DEVICE_DEFAULT_ADVERTISING_TX_POWER           = (
+    HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE
+)
 
 # fmt: on
 # pylint: enable=line-too-long
 
+# As specified in 7.8.56 LE Set Extended Advertising Enable command
+DEVICE_MAX_HIGH_DUTY_CYCLE_CONNECTABLE_DIRECTED_ADVERTISING_DURATION = 1.28
+
 
 # -----------------------------------------------------------------------------
 # Classes
@@ -222,16 +254,40 @@
 
 
 # -----------------------------------------------------------------------------
+@dataclass
 class Advertisement:
+    # Attributes
     address: Address
-
-    TX_POWER_NOT_AVAILABLE = (
+    rssi: int = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+    is_legacy: bool = False
+    is_anonymous: bool = False
+    is_connectable: bool = False
+    is_directed: bool = False
+    is_scannable: bool = False
+    is_scan_response: bool = False
+    is_complete: bool = True
+    is_truncated: bool = False
+    primary_phy: int = 0
+    secondary_phy: int = 0
+    tx_power: int = (
         HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
     )
-    RSSI_NOT_AVAILABLE = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+    sid: int = 0
+    data_bytes: bytes = b''
+
+    # Constants
+    TX_POWER_NOT_AVAILABLE: ClassVar[
+        int
+    ] = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
+    RSSI_NOT_AVAILABLE: ClassVar[
+        int
+    ] = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+
+    def __post_init__(self) -> None:
+        self.data = AdvertisingData.from_bytes(self.data_bytes)
 
     @classmethod
-    def from_advertising_report(cls, report):
+    def from_advertising_report(cls, report) -> Optional[Advertisement]:
         if isinstance(report, HCI_LE_Advertising_Report_Event.Report):
             return LegacyAdvertisement.from_advertising_report(report)
 
@@ -240,41 +296,6 @@
 
         return None
 
-    # pylint: disable=line-too-long
-    def __init__(
-        self,
-        address,
-        rssi=HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE,
-        is_legacy=False,
-        is_anonymous=False,
-        is_connectable=False,
-        is_directed=False,
-        is_scannable=False,
-        is_scan_response=False,
-        is_complete=True,
-        is_truncated=False,
-        primary_phy=0,
-        secondary_phy=0,
-        tx_power=HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE,
-        sid=0,
-        data=b'',
-    ):
-        self.address = address
-        self.rssi = rssi
-        self.is_legacy = is_legacy
-        self.is_anonymous = is_anonymous
-        self.is_connectable = is_connectable
-        self.is_directed = is_directed
-        self.is_scannable = is_scannable
-        self.is_scan_response = is_scan_response
-        self.is_complete = is_complete
-        self.is_truncated = is_truncated
-        self.primary_phy = primary_phy
-        self.secondary_phy = secondary_phy
-        self.tx_power = tx_power
-        self.sid = sid
-        self.data = AdvertisingData.from_bytes(data)
-
 
 # -----------------------------------------------------------------------------
 class LegacyAdvertisement(Advertisement):
@@ -298,7 +319,7 @@
             ),
             is_scan_response=report.event_type
             == HCI_LE_Advertising_Report_Event.SCAN_RSP,
-            data=report.data,
+            data_bytes=report.data,
         )
 
 
@@ -323,7 +344,7 @@
             secondary_phy    = report.secondary_phy,
             tx_power         = report.tx_power,
             sid              = report.advertising_sid,
-            data             = report.data
+            data_bytes       = report.data
         )
         # fmt: on
 
@@ -384,7 +405,7 @@
     # fmt: on
 
     @property
-    def has_data(self):
+    def has_data(self) -> bool:
         return self in (
             AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
             AdvertisingType.UNDIRECTED_SCANNABLE,
@@ -392,7 +413,7 @@
         )
 
     @property
-    def is_connectable(self):
+    def is_connectable(self) -> bool:
         return self in (
             AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
             AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
@@ -400,19 +421,369 @@
         )
 
     @property
-    def is_scannable(self):
+    def is_scannable(self) -> bool:
         return self in (
             AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
             AdvertisingType.UNDIRECTED_SCANNABLE,
         )
 
     @property
-    def is_directed(self):
+    def is_directed(self) -> bool:
         return self in (
             AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
             AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY,
         )
 
+    @property
+    def is_high_duty_cycle_directed_connectable(self):
+        return self == AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class LegacyAdvertiser:
+    device: Device
+    advertising_type: AdvertisingType
+    own_address_type: OwnAddressType
+    peer_address: Address
+    auto_restart: bool
+
+    async def start(self) -> None:
+        # Set/update the advertising data if the advertising type allows it
+        if self.advertising_type.has_data:
+            await self.device.send_command(
+                HCI_LE_Set_Advertising_Data_Command(
+                    advertising_data=self.device.advertising_data
+                ),
+                check_result=True,
+            )
+
+        # Set/update the scan response data if the advertising is scannable
+        if self.advertising_type.is_scannable:
+            await self.device.send_command(
+                HCI_LE_Set_Scan_Response_Data_Command(
+                    scan_response_data=self.device.scan_response_data
+                ),
+                check_result=True,
+            )
+
+        # Set the advertising parameters
+        await self.device.send_command(
+            HCI_LE_Set_Advertising_Parameters_Command(
+                advertising_interval_min=self.device.advertising_interval_min,
+                advertising_interval_max=self.device.advertising_interval_max,
+                advertising_type=int(self.advertising_type),
+                own_address_type=self.own_address_type,
+                peer_address_type=self.peer_address.address_type,
+                peer_address=self.peer_address,
+                advertising_channel_map=7,
+                advertising_filter_policy=0,
+            ),
+            check_result=True,
+        )
+
+        # Enable advertising
+        await self.device.send_command(
+            HCI_LE_Set_Advertising_Enable_Command(advertising_enable=1),
+            check_result=True,
+        )
+
+    async def stop(self) -> None:
+        # Disable advertising
+        await self.device.send_command(
+            HCI_LE_Set_Advertising_Enable_Command(advertising_enable=0),
+            check_result=True,
+        )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AdvertisingEventProperties:
+    is_connectable: bool = True
+    is_scannable: bool = False
+    is_directed: bool = False
+    is_high_duty_cycle_directed_connectable: bool = False
+    is_legacy: bool = False
+    is_anonymous: bool = False
+    include_tx_power: bool = False
+
+    def __int__(self) -> int:
+        properties = (
+            HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(0)
+        )
+        if self.is_connectable:
+            properties |= properties.CONNECTABLE_ADVERTISING
+        if self.is_scannable:
+            properties |= properties.SCANNABLE_ADVERTISING
+        if self.is_directed:
+            properties |= properties.DIRECTED_ADVERTISING
+        if self.is_high_duty_cycle_directed_connectable:
+            properties |= properties.HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING
+        if self.is_legacy:
+            properties |= properties.USE_LEGACY_ADVERTISING_PDUS
+        if self.is_anonymous:
+            properties |= properties.ANONYMOUS_ADVERTISING
+        if self.include_tx_power:
+            properties |= properties.INCLUDE_TX_POWER
+
+        return int(properties)
+
+    @classmethod
+    def from_advertising_type(
+        cls: Type[AdvertisingEventProperties],
+        advertising_type: AdvertisingType,
+    ) -> AdvertisingEventProperties:
+        return cls(
+            is_connectable=advertising_type.is_connectable,
+            is_scannable=advertising_type.is_scannable,
+            is_directed=advertising_type.is_directed,
+            is_high_duty_cycle_directed_connectable=advertising_type.is_high_duty_cycle_directed_connectable,
+            is_legacy=True,
+            is_anonymous=False,
+            include_tx_power=False,
+        )
+
+
+# -----------------------------------------------------------------------------
+# TODO: replace with typing.TypeAlias when the code base is all Python >= 3.10
+AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AdvertisingParameters:
+    # pylint: disable=line-too-long
+    advertising_event_properties: AdvertisingEventProperties = field(
+        default_factory=AdvertisingEventProperties
+    )
+    primary_advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
+    primary_advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
+    primary_advertising_channel_map: HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap = (
+        AdvertisingChannelMap.CHANNEL_37
+        | AdvertisingChannelMap.CHANNEL_38
+        | AdvertisingChannelMap.CHANNEL_39
+    )
+    own_address_type: OwnAddressType = OwnAddressType.RANDOM
+    peer_address: Address = Address.ANY
+    advertising_filter_policy: int = 0
+    advertising_tx_power: int = DEVICE_DEFAULT_ADVERTISING_TX_POWER
+    primary_advertising_phy: Phy = Phy.LE_1M
+    secondary_advertising_max_skip: int = 0
+    secondary_advertising_phy: Phy = Phy.LE_1M
+    advertising_sid: int = 0
+    enable_scan_request_notifications: bool = False
+    primary_advertising_phy_options: int = 0
+    secondary_advertising_phy_options: int = 0
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class PeriodicAdvertisingParameters:
+    # TODO implement this class
+    pass
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AdvertisingSet(EventEmitter):
+    device: Device
+    advertising_handle: int
+    auto_restart: bool
+    random_address: Optional[Address]
+    advertising_parameters: AdvertisingParameters
+    advertising_data: bytes
+    scan_response_data: bytes
+    periodic_advertising_parameters: Optional[PeriodicAdvertisingParameters]
+    periodic_advertising_data: bytes
+    selected_tx_power: int = 0
+    enabled: bool = False
+
+    def __post_init__(self) -> None:
+        super().__init__()
+
+    async def set_advertising_parameters(
+        self, advertising_parameters: AdvertisingParameters
+    ) -> None:
+        # Compliance check
+        if (
+            not advertising_parameters.advertising_event_properties.is_legacy
+            and advertising_parameters.advertising_event_properties.is_connectable
+            and advertising_parameters.advertising_event_properties.is_scannable
+        ):
+            logger.warning(
+                "non-legacy extended advertising event properties may not be both "
+                "connectable and scannable"
+            )
+
+        response = await self.device.send_command(
+            HCI_LE_Set_Extended_Advertising_Parameters_Command(
+                advertising_handle=self.advertising_handle,
+                advertising_event_properties=int(
+                    advertising_parameters.advertising_event_properties
+                ),
+                primary_advertising_interval_min=(
+                    int(advertising_parameters.primary_advertising_interval_min / 0.625)
+                ),
+                primary_advertising_interval_max=(
+                    int(advertising_parameters.primary_advertising_interval_min / 0.625)
+                ),
+                primary_advertising_channel_map=int(
+                    advertising_parameters.primary_advertising_channel_map
+                ),
+                own_address_type=advertising_parameters.own_address_type,
+                peer_address_type=advertising_parameters.peer_address.address_type,
+                peer_address=advertising_parameters.peer_address,
+                advertising_tx_power=advertising_parameters.advertising_tx_power,
+                advertising_filter_policy=(
+                    advertising_parameters.advertising_filter_policy
+                ),
+                primary_advertising_phy=advertising_parameters.primary_advertising_phy,
+                secondary_advertising_max_skip=(
+                    advertising_parameters.secondary_advertising_max_skip
+                ),
+                secondary_advertising_phy=(
+                    advertising_parameters.secondary_advertising_phy
+                ),
+                advertising_sid=advertising_parameters.advertising_sid,
+                scan_request_notification_enable=(
+                    1 if advertising_parameters.enable_scan_request_notifications else 0
+                ),
+            ),
+            check_result=True,
+        )
+        self.selected_tx_power = response.return_parameters.selected_tx_power
+        self.advertising_parameters = advertising_parameters
+
+    async def set_advertising_data(self, advertising_data: bytes) -> None:
+        # pylint: disable=line-too-long
+        await self.device.send_command(
+            HCI_LE_Set_Extended_Advertising_Data_Command(
+                advertising_handle=self.advertising_handle,
+                operation=HCI_LE_Set_Extended_Advertising_Data_Command.Operation.COMPLETE_DATA,
+                fragment_preference=HCI_LE_Set_Extended_Advertising_Parameters_Command.SHOULD_NOT_FRAGMENT,
+                advertising_data=advertising_data,
+            ),
+            check_result=True,
+        )
+        self.advertising_data = advertising_data
+
+    async def set_scan_response_data(self, scan_response_data: bytes) -> None:
+        # pylint: disable=line-too-long
+        if (
+            scan_response_data
+            and not self.advertising_parameters.advertising_event_properties.is_scannable
+        ):
+            logger.warning(
+                "ignoring attempt to set non-empty scan response data on non-scannable "
+                "advertising set"
+            )
+            return
+
+        await self.device.send_command(
+            HCI_LE_Set_Extended_Scan_Response_Data_Command(
+                advertising_handle=self.advertising_handle,
+                operation=HCI_LE_Set_Extended_Advertising_Data_Command.Operation.COMPLETE_DATA,
+                fragment_preference=HCI_LE_Set_Extended_Advertising_Parameters_Command.SHOULD_NOT_FRAGMENT,
+                scan_response_data=scan_response_data,
+            ),
+            check_result=True,
+        )
+        self.scan_response_data = scan_response_data
+
+    async def set_periodic_advertising_parameters(
+        self, advertising_parameters: PeriodicAdvertisingParameters
+    ) -> None:
+        # TODO: send command
+        self.periodic_advertising_parameters = advertising_parameters
+
+    async def set_periodic_advertising_data(self, advertising_data: bytes) -> None:
+        # TODO: send command
+        self.periodic_advertising_data = advertising_data
+
+    async def set_random_address(self, random_address: Address) -> None:
+        await self.device.send_command(
+            HCI_LE_Set_Advertising_Set_Random_Address_Command(
+                advertising_handle=self.advertising_handle,
+                random_address=(random_address or self.device.random_address),
+            ),
+            check_result=True,
+        )
+
+    async def start(
+        self, duration: float = 0.0, max_advertising_events: int = 0
+    ) -> None:
+        """
+        Start advertising.
+
+        Args:
+          duration: How long to advertise for, in seconds. Use 0 (the default) for
+          an unlimited duration, unless this advertising set is a High Duty Cycle
+          Directed Advertisement type.
+          max_advertising_events: Maximum number of events to advertise for. Use 0
+          (the default) for an unlimited number of advertisements.
+        """
+        await self.device.send_command(
+            HCI_LE_Set_Extended_Advertising_Enable_Command(
+                enable=1,
+                advertising_handles=[self.advertising_handle],
+                durations=[round(duration * 100)],
+                max_extended_advertising_events=[max_advertising_events],
+            ),
+            check_result=True,
+        )
+        self.enabled = True
+
+        self.emit('start')
+
+    async def start_periodic(self, include_adi: bool = False) -> None:
+        await self.device.send_command(
+            HCI_LE_Set_Periodic_Advertising_Enable_Command(
+                enable=1 | (2 if include_adi else 0),
+                advertising_handles=self.advertising_handle,
+            ),
+            check_result=True,
+        )
+
+        self.emit('start_periodic')
+
+    async def stop(self) -> None:
+        await self.device.send_command(
+            HCI_LE_Set_Extended_Advertising_Enable_Command(
+                enable=0,
+                advertising_handles=[self.advertising_handle],
+                durations=[0],
+                max_extended_advertising_events=[0],
+            ),
+            check_result=True,
+        )
+        self.enabled = False
+
+        self.emit('stop')
+
+    async def stop_periodic(self) -> None:
+        await self.device.send_command(
+            HCI_LE_Set_Periodic_Advertising_Enable_Command(
+                enable=0,
+                advertising_handles=self.advertising_handle,
+            ),
+            check_result=True,
+        )
+
+        self.emit('stop_periodic')
+
+    async def remove(self) -> None:
+        await self.device.send_command(
+            HCI_LE_Remove_Advertising_Set_Command(
+                advertising_handle=self.advertising_handle
+            ),
+            check_result=True,
+        )
+        del self.device.extended_advertising_sets[self.advertising_handle]
+
+    def on_termination(self, status: int) -> None:
+        self.enabled = False
+        self.emit('termination', status)
+
 
 # -----------------------------------------------------------------------------
 class LePhyOptions:
@@ -429,8 +800,11 @@
 
 
 # -----------------------------------------------------------------------------
+_PROXY_CLASS = TypeVar('_PROXY_CLASS', bound=gatt_client.ProfileServiceProxy)
+
+
 class Peer:
-    def __init__(self, connection):
+    def __init__(self, connection: Connection) -> None:
         self.connection = connection
 
         # Create a GATT client for the connection
@@ -438,77 +812,113 @@
         connection.gatt_client = self.gatt_client
 
     @property
-    def services(self):
+    def services(self) -> List[gatt_client.ServiceProxy]:
         return self.gatt_client.services
 
-    async def request_mtu(self, mtu):
+    async def request_mtu(self, mtu: int) -> int:
         mtu = await self.gatt_client.request_mtu(mtu)
         self.connection.emit('connection_att_mtu_update')
         return mtu
 
-    async def discover_service(self, uuid):
+    async def discover_service(
+        self, uuid: Union[core.UUID, str]
+    ) -> List[gatt_client.ServiceProxy]:
         return await self.gatt_client.discover_service(uuid)
 
-    async def discover_services(self, uuids=()):
+    async def discover_services(
+        self, uuids: Iterable[core.UUID] = ()
+    ) -> List[gatt_client.ServiceProxy]:
         return await self.gatt_client.discover_services(uuids)
 
-    async def discover_included_services(self, service):
+    async def discover_included_services(
+        self, service: gatt_client.ServiceProxy
+    ) -> List[gatt_client.ServiceProxy]:
         return await self.gatt_client.discover_included_services(service)
 
-    async def discover_characteristics(self, uuids=(), service=None):
+    async def discover_characteristics(
+        self,
+        uuids: Iterable[Union[core.UUID, str]] = (),
+        service: Optional[gatt_client.ServiceProxy] = None,
+    ) -> List[gatt_client.CharacteristicProxy]:
         return await self.gatt_client.discover_characteristics(
             uuids=uuids, service=service
         )
 
     async def discover_descriptors(
-        self, characteristic=None, start_handle=None, end_handle=None
+        self,
+        characteristic: Optional[gatt_client.CharacteristicProxy] = None,
+        start_handle: Optional[int] = None,
+        end_handle: Optional[int] = None,
     ):
         return await self.gatt_client.discover_descriptors(
             characteristic, start_handle, end_handle
         )
 
-    async def discover_attributes(self):
+    async def discover_attributes(self) -> List[gatt_client.AttributeProxy]:
         return await self.gatt_client.discover_attributes()
 
-    async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
+    async def subscribe(
+        self,
+        characteristic: gatt_client.CharacteristicProxy,
+        subscriber: Optional[Callable[[bytes], Any]] = None,
+        prefer_notify: bool = True,
+    ) -> None:
         return await self.gatt_client.subscribe(
             characteristic, subscriber, prefer_notify
         )
 
-    async def unsubscribe(self, characteristic, subscriber=None):
+    async def unsubscribe(
+        self,
+        characteristic: gatt_client.CharacteristicProxy,
+        subscriber: Optional[Callable[[bytes], Any]] = None,
+    ) -> None:
         return await self.gatt_client.unsubscribe(characteristic, subscriber)
 
-    async def read_value(self, attribute):
+    async def read_value(
+        self, attribute: Union[int, gatt_client.AttributeProxy]
+    ) -> bytes:
         return await self.gatt_client.read_value(attribute)
 
-    async def write_value(self, attribute, value, with_response=False):
+    async def write_value(
+        self,
+        attribute: Union[int, gatt_client.AttributeProxy],
+        value: bytes,
+        with_response: bool = False,
+    ) -> None:
         return await self.gatt_client.write_value(attribute, value, with_response)
 
-    async def read_characteristics_by_uuid(self, uuid, service=None):
+    async def read_characteristics_by_uuid(
+        self, uuid: core.UUID, service: Optional[gatt_client.ServiceProxy] = None
+    ) -> List[bytes]:
         return await self.gatt_client.read_characteristics_by_uuid(uuid, service)
 
-    def get_services_by_uuid(self, uuid):
+    def get_services_by_uuid(self, uuid: core.UUID) -> List[gatt_client.ServiceProxy]:
         return self.gatt_client.get_services_by_uuid(uuid)
 
-    def get_characteristics_by_uuid(self, uuid, service=None):
+    def get_characteristics_by_uuid(
+        self, uuid: core.UUID, service: Optional[gatt_client.ServiceProxy] = None
+    ) -> List[gatt_client.CharacteristicProxy]:
         return self.gatt_client.get_characteristics_by_uuid(uuid, service)
 
-    def create_service_proxy(self, proxy_class):
-        return proxy_class.from_client(self.gatt_client)
+    def create_service_proxy(self, proxy_class: Type[_PROXY_CLASS]) -> _PROXY_CLASS:
+        return cast(_PROXY_CLASS, proxy_class.from_client(self.gatt_client))
 
-    async def discover_service_and_create_proxy(self, proxy_class):
+    async def discover_service_and_create_proxy(
+        self, proxy_class: Type[_PROXY_CLASS]
+    ) -> Optional[_PROXY_CLASS]:
         # Discover the first matching service and its characteristics
         services = await self.discover_service(proxy_class.SERVICE_CLASS.UUID)
         if services:
             service = services[0]
             await service.discover_characteristics()
             return self.create_service_proxy(proxy_class)
+        return None
 
-    async def sustain(self, timeout=None):
+    async def sustain(self, timeout: Optional[float] = None) -> None:
         await self.connection.sustain(timeout)
 
     # [Classic only]
-    async def request_name(self):
+    async def request_name(self) -> str:
         return await self.connection.request_remote_name()
 
     async def __aenter__(self):
@@ -521,7 +931,7 @@
     async def __aexit__(self, exc_type, exc_value, traceback):
         pass
 
-    def __str__(self):
+    def __str__(self) -> str:
         return f'{self.connection.peer_address} as {self.connection.role_name}'
 
 
@@ -541,6 +951,46 @@
 
 
 # -----------------------------------------------------------------------------
+@dataclass
+class ScoLink(CompositeEventEmitter):
+    device: Device
+    acl_connection: Connection
+    handle: int
+    link_type: int
+
+    def __post_init__(self):
+        super().__init__()
+
+    async def disconnect(
+        self, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
+    ) -> None:
+        await self.device.disconnect(self, reason)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class CisLink(CompositeEventEmitter):
+    class State(IntEnum):
+        PENDING = 0
+        ESTABLISHED = 1
+
+    device: Device
+    acl_connection: Connection  # Based ACL connection
+    handle: int  # CIS handle assigned by Controller (in LE_Set_CIG_Parameters Complete or LE_CIS_Request events)
+    cis_id: int  # CIS ID assigned by Central device
+    cig_id: int  # CIG ID assigned by Central device
+    state: State = State.PENDING
+
+    def __post_init__(self):
+        super().__init__()
+
+    async def disconnect(
+        self, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
+    ) -> None:
+        await self.device.disconnect(self, reason)
+
+
+# -----------------------------------------------------------------------------
 class Connection(CompositeEventEmitter):
     device: Device
     handle: int
@@ -548,6 +998,7 @@
     self_address: Address
     peer_address: Address
     peer_resolvable_address: Optional[Address]
+    peer_le_features: Optional[LeFeatureMask]
     role: int
     encryption: int
     authenticated: bool
@@ -621,6 +1072,7 @@
         )  # By default, use the device's shared server
         self.pairing_peer_io_capability = None
         self.pairing_peer_authentication_requirements = None
+        self.peer_le_features = None
 
     # [Classic only]
     @classmethod
@@ -721,7 +1173,7 @@
     async def switch_role(self, role: int) -> None:
         return await self.device.switch_role(self, role)
 
-    async def sustain(self, timeout=None):
+    async def sustain(self, timeout: Optional[float] = None) -> None:
         """Idles the current task waiting for a disconnect or timeout"""
 
         abort = asyncio.get_running_loop().create_future()
@@ -736,6 +1188,9 @@
         self.remove_listener('disconnection', abort.set_result)
         self.remove_listener('disconnection_failure', abort.set_exception)
 
+    async def set_data_length(self, tx_octets, tx_time) -> None:
+        return await self.device.set_data_length(self, tx_octets, tx_time)
+
     async def update_parameters(
         self,
         connection_interval_min,
@@ -766,6 +1221,15 @@
     async def request_remote_name(self):
         return await self.device.request_remote_name(self)
 
+    async def get_remote_le_features(self) -> LeFeatureMask:
+        """[LE Only] Reads remote LE supported features.
+
+        Returns:
+            LE features supported by the remote device.
+        """
+        self.peer_le_features = await self.device.get_remote_le_features(self)
+        return self.peer_le_features
+
     async def __aenter__(self):
         return self
 
@@ -782,7 +1246,8 @@
         return (
             f'Connection(handle=0x{self.handle:04X}, '
             f'role={self.role_name}, '
-            f'address={self.peer_address})'
+            f'self_address={self.self_address}, '
+            f'peer_address={self.peer_address})'
         )
 
 
@@ -815,6 +1280,7 @@
         self.keystore = None
         self.gatt_services: List[Dict[str, Any]] = []
         self.address_resolution_offload = False
+        self.cis_enabled = False
 
     def load_from_dict(self, config: Dict[str, Any]) -> None:
         # Load simple properties
@@ -850,17 +1316,21 @@
         self.address_resolution_offload = config.get(
             'address_resolution_offload', self.address_resolution_offload
         )
+        self.cis_enabled = config.get('cis_enabled', self.cis_enabled)
 
         # Load or synthesize an IRK
         irk = config.get('irk')
         if irk:
             self.irk = bytes.fromhex(irk)
-        else:
+        elif self.address != Address(DEVICE_DEFAULT_ADDRESS):
             # Construct an IRK from the address bytes
             # NOTE: this is not secure, but will always give the same IRK for the same
             # address
             address_bytes = bytes(self.address)
             self.irk = (address_bytes * 3)[:16]
+        else:
+            # Fallback - when both IRK and address are not set, randomly generate an IRK.
+            self.irk = secrets.token_bytes(16)
 
         # Load advertising data
         advertising_data = config.get('advertising_data')
@@ -890,7 +1360,7 @@
     @functools.wraps(function)
     def wrapper(self, connection_handle, *args, **kwargs):
         if (connection := self.lookup_connection(connection_handle)) is None:
-            raise ValueError(f"no connection for handle: 0x{connection_handle:04x}")
+            raise ValueError(f'no connection for handle: 0x{connection_handle:04x}')
         return function(self, connection, *args, **kwargs)
 
     return wrapper
@@ -956,6 +1426,10 @@
     ]
     advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
     config: DeviceConfiguration
+    legacy_advertiser: Optional[LegacyAdvertiser]
+    sco_links: Dict[int, ScoLink]
+    cis_links: Dict[int, CisLink]
+    _pending_cis: Dict[int, Tuple[int, int]]
 
     @composite_listener
     class Listener:
@@ -1030,10 +1504,7 @@
 
         self._host = None
         self.powered_on = False
-        self.advertising = False
-        self.advertising_type = None
         self.auto_restart_inquiry = True
-        self.auto_restart_advertising = False
         self.command_timeout = 10  # seconds
         self.gatt_server = gatt_server.Server(self)
         self.sdp_server = sdp.Server(self)
@@ -1048,6 +1519,9 @@
         self.disconnecting = False
         self.connections = {}  # Connections, by connection handle
         self.pending_connections = {}  # Connections, by BD address (BR/EDR only)
+        self.sco_links = {}  # ScoLinks, by connection handle (BR/EDR only)
+        self.cis_links = {}  # CisLinks, by connection handle (LE only)
+        self._pending_cis = {}  # (CIS_ID, CIG_ID), by CIS_handle
         self.classic_enabled = False
         self.inquiry_response = None
         self.address_resolver = None
@@ -1056,7 +1530,6 @@
         }  # Futures, by BD address OR [Futures] for Address.ANY
 
         # Own address type cache
-        self.advertising_own_address_type = None
         self.connect_own_address_type = None
 
         # Use the initial config or a default
@@ -1067,15 +1540,12 @@
         self.name = config.name
         self.random_address = config.address
         self.class_of_device = config.class_of_device
-        self.scan_response_data = config.scan_response_data
-        self.advertising_data = config.advertising_data
-        self.advertising_interval_min = config.advertising_interval_min
-        self.advertising_interval_max = config.advertising_interval_max
         self.keystore = None
         self.irk = config.irk
         self.le_enabled = config.le_enabled
         self.classic_enabled = config.classic_enabled
         self.le_simultaneous_enabled = config.le_simultaneous_enabled
+        self.cis_enabled = config.cis_enabled
         self.classic_sc_enabled = config.classic_sc_enabled
         self.classic_ssp_enabled = config.classic_ssp_enabled
         self.classic_smp_enabled = config.classic_smp_enabled
@@ -1084,6 +1554,22 @@
         self.classic_accept_any = config.classic_accept_any
         self.address_resolution_offload = config.address_resolution_offload
 
+        # Extended advertising.
+        self.extended_advertising_sets: Dict[int, AdvertisingSet] = {}
+
+        # Legacy advertising.
+        # The advertising and scan response data, as well as the advertising interval
+        # values are stored as properties of this object for convenience so that they
+        # can be initialized from a config object, and for backward compatibility for
+        # client code that may set those values directly before calling
+        # start_advertising().
+        self.legacy_advertising_set: Optional[AdvertisingSet] = None
+        self.legacy_advertiser: Optional[LegacyAdvertiser] = None
+        self.advertising_data = config.advertising_data
+        self.scan_response_data = config.scan_response_data
+        self.advertising_interval_min = config.advertising_interval_min
+        self.advertising_interval_max = config.advertising_interval_max
+
         for service in config.gatt_services:
             characteristics = []
             for characteristic in service.get("characteristics", []):
@@ -1092,7 +1578,8 @@
                     # Leave this check until 5/25/2023
                     if descriptor.get("permission", False):
                         raise Exception(
-                            "Error parsing Device Config's GATT Services. The key 'permission' must be renamed to 'permissions'"
+                            "Error parsing Device Config's GATT Services. "
+                            "The key 'permission' must be renamed to 'permissions'"
                         )
                     new_descriptor = Descriptor(
                         attribute_type=descriptor["descriptor_type"],
@@ -1308,7 +1795,7 @@
                 self.host.send_command(command, check_result), self.command_timeout
             )
         except asyncio.TimeoutError as error:
-            logger.warning('!!! Command timed out')
+            logger.warning(f'!!! Command {command.name} timed out')
             raise CommandTimeoutError() from error
 
     async def power_on(self) -> None:
@@ -1316,7 +1803,7 @@
         await self.host.reset()
 
         # Try to get the public address from the controller
-        response = await self.send_command(HCI_Read_BD_ADDR_Command())  # type: ignore[call-arg]
+        response = await self.send_command(HCI_Read_BD_ADDR_Command())
         if response.return_parameters.status == HCI_SUCCESS:
             logger.debug(
                 color(f'BD_ADDR: {response.return_parameters.bd_addr}', 'yellow')
@@ -1339,7 +1826,7 @@
                 HCI_Write_LE_Host_Support_Command(
                     le_supported_host=int(self.le_enabled),
                     simultaneous_le_host=int(self.le_simultaneous_enabled),
-                )  # type: ignore[call-arg]
+                )
             )
 
         if self.le_enabled:
@@ -1349,7 +1836,7 @@
                 if self.host.supports_command(HCI_LE_RAND_COMMAND):
                     # Get 8 random bytes
                     response = await self.send_command(
-                        HCI_LE_Rand_Command(), check_result=True  # type: ignore[call-arg]
+                        HCI_LE_Rand_Command(), check_result=True
                     )
 
                     # Ensure the address bytes can be a static random address
@@ -1370,7 +1857,7 @@
                 await self.send_command(
                     HCI_LE_Set_Random_Address_Command(
                         random_address=self.random_address
-                    ),  # type: ignore[call-arg]
+                    ),
                     check_result=True,
                 )
 
@@ -1383,25 +1870,33 @@
                 await self.send_command(
                     HCI_LE_Set_Address_Resolution_Enable_Command(
                         address_resolution_enable=1
-                    )  # type: ignore[call-arg]
+                    )
+                )
+
+            if self.cis_enabled:
+                await self.send_command(
+                    HCI_LE_Set_Host_Feature_Command(
+                        bit_number=LeFeature.CONNECTED_ISOCHRONOUS_STREAM,
+                        bit_value=1,
+                    )
                 )
 
         if self.classic_enabled:
             await self.send_command(
-                HCI_Write_Local_Name_Command(local_name=self.name.encode('utf8'))  # type: ignore[call-arg]
+                HCI_Write_Local_Name_Command(local_name=self.name.encode('utf8'))
             )
             await self.send_command(
-                HCI_Write_Class_Of_Device_Command(class_of_device=self.class_of_device)  # type: ignore[call-arg]
+                HCI_Write_Class_Of_Device_Command(class_of_device=self.class_of_device)
             )
             await self.send_command(
                 HCI_Write_Simple_Pairing_Mode_Command(
                     simple_pairing_mode=int(self.classic_ssp_enabled)
-                )  # type: ignore[call-arg]
+                )
             )
             await self.send_command(
                 HCI_Write_Secure_Connections_Host_Support_Command(
                     secure_connections_host_support=int(self.classic_sc_enabled)
-                )  # type: ignore[call-arg]
+                )
             )
             await self.set_connectable(self.connectable)
             await self.set_discoverable(self.discoverable)
@@ -1409,6 +1904,9 @@
         # Done
         self.powered_on = True
 
+    async def reset(self) -> None:
+        await self.host.reset()
+
     async def power_off(self) -> None:
         if self.powered_on:
             await self.host.flush()
@@ -1422,7 +1920,17 @@
         self.address_resolver = smp.AddressResolver(resolving_keys)
 
         if self.address_resolution_offload:
-            await self.send_command(HCI_LE_Clear_Resolving_List_Command())  # type: ignore[call-arg]
+            await self.send_command(HCI_LE_Clear_Resolving_List_Command())
+
+            # Add an empty entry for non-directed address generation.
+            await self.send_command(
+                HCI_LE_Add_Device_To_Resolving_List_Command(
+                    peer_identity_address_type=Address.ANY.address_type,
+                    peer_identity_address=Address.ANY,
+                    peer_irk=bytes(16),
+                    local_irk=self.irk,
+                )
+            )
 
             for irk, address in resolving_keys:
                 await self.send_command(
@@ -1431,24 +1939,28 @@
                         peer_identity_address=address,
                         peer_irk=irk,
                         local_irk=self.irk,
-                    )  # type: ignore[call-arg]
+                    )
                 )
 
-    def supports_le_feature(self, feature):
-        return self.host.supports_le_feature(feature)
+    def supports_le_features(self, feature: LeFeatureMask) -> bool:
+        return self.host.supports_le_features(feature)
 
     def supports_le_phy(self, phy):
         if phy == HCI_LE_1M_PHY:
             return True
 
         feature_map = {
-            HCI_LE_2M_PHY: HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE,
-            HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE,
+            HCI_LE_2M_PHY: LeFeatureMask.LE_2M_PHY,
+            HCI_LE_CODED_PHY: LeFeatureMask.LE_CODED_PHY,
         }
         if phy not in feature_map:
             raise ValueError('invalid PHY')
 
-        return self.host.supports_le_feature(feature_map[phy])
+        return self.supports_le_features(feature_map[phy])
+
+    @property
+    def supports_le_extended_advertising(self):
+        return self.supports_le_features(LeFeatureMask.LE_EXTENDED_ADVERTISING)
 
     async def start_advertising(
         self,
@@ -1456,82 +1968,261 @@
         target: Optional[Address] = None,
         own_address_type: int = OwnAddressType.RANDOM,
         auto_restart: bool = False,
+        advertising_data: Optional[bytes] = None,
+        scan_response_data: Optional[bytes] = None,
+        advertising_interval_min: Optional[int] = None,
+        advertising_interval_max: Optional[int] = None,
     ) -> None:
-        # If we're advertising, stop first
-        if self.advertising:
-            await self.stop_advertising()
+        """Start legacy advertising.
 
-        # Set/update the advertising data if the advertising type allows it
-        if advertising_type.has_data:
-            await self.send_command(
-                HCI_LE_Set_Advertising_Data_Command(
-                    advertising_data=self.advertising_data
-                ),  # type: ignore[call-arg]
-                check_result=True,
-            )
+        If the controller supports it, extended advertising commands with legacy PDUs
+        will be used to advertise. If not, legacy advertising commands will be used.
 
-        # Set/update the scan response data if the advertising is scannable
-        if advertising_type.is_scannable:
-            await self.send_command(
-                HCI_LE_Set_Scan_Response_Data_Command(
-                    scan_response_data=self.scan_response_data
-                ),  # type: ignore[call-arg]
-                check_result=True,
-            )
+        Args:
+          advertising_type:
+            Type of advertising events.
+          target:
+            Peer address for directed advertising target.
+            (Ignored if `advertising_type` is not directed)
+          own_address_type:
+            Own address type to use in the advertising.
+          auto_restart:
+            Whether the advertisement will be restarted after disconnection.
+          advertising_data:
+            Raw advertising data. If None, the value of the property
+            self.advertising_data will be used.
+          scan_response_data:
+            Raw scan response. If None, the value of the property
+            self.scan_response_data will be used.
+          advertising_interval_min:
+            Minimum advertising interval, in milliseconds. If None, the value of the
+            property self.advertising_interval_min will be used.
+          advertising_interval_max:
+            Maximum advertising interval, in milliseconds. If None, the value of the
+            property self.advertising_interval_max will be used.
+        """
+        # Update backing properties.
+        if advertising_data is not None:
+            self.advertising_data = advertising_data
+        if scan_response_data is not None:
+            self.scan_response_data = scan_response_data
+        if advertising_interval_min is not None:
+            self.advertising_interval_min = advertising_interval_min
+        if advertising_interval_max is not None:
+            self.advertising_interval_max = advertising_interval_max
 
         # Decide what peer address to use
         if advertising_type.is_directed:
             if target is None:
-                raise ValueError('directed advertising requires a target address')
-
+                raise ValueError('directed advertising requires a target')
             peer_address = target
-            peer_address_type = target.address_type
         else:
-            peer_address = Address('00:00:00:00:00:00')
-            peer_address_type = Address.PUBLIC_DEVICE_ADDRESS
+            peer_address = Address.ANY
 
-        # Set the advertising parameters
-        await self.send_command(
-            HCI_LE_Set_Advertising_Parameters_Command(
-                advertising_interval_min=self.advertising_interval_min,
-                advertising_interval_max=self.advertising_interval_max,
-                advertising_type=int(advertising_type),
-                own_address_type=own_address_type,
-                peer_address_type=peer_address_type,
+        # If we're already advertising, stop now because we'll be re-creating
+        # a new advertiser or advertising set.
+        await self.stop_advertising()
+        assert self.legacy_advertiser is None
+        assert self.legacy_advertising_set is None
+
+        if self.supports_le_extended_advertising:
+            # Use extended advertising commands with legacy PDUs.
+            self.legacy_advertising_set = await self.create_advertising_set(
+                auto_start=True,
+                auto_restart=auto_restart,
+                random_address=self.random_address,
+                advertising_parameters=AdvertisingParameters(
+                    advertising_event_properties=(
+                        AdvertisingEventProperties.from_advertising_type(
+                            advertising_type
+                        )
+                    ),
+                    primary_advertising_interval_min=self.advertising_interval_min,
+                    primary_advertising_interval_max=self.advertising_interval_max,
+                    own_address_type=OwnAddressType(own_address_type),
+                    peer_address=peer_address,
+                ),
+                advertising_data=(
+                    self.advertising_data if advertising_type.has_data else b''
+                ),
+                scan_response_data=(
+                    self.scan_response_data if advertising_type.is_scannable else b''
+                ),
+            )
+        else:
+            # Use legacy commands.
+            self.legacy_advertiser = LegacyAdvertiser(
+                device=self,
+                advertising_type=advertising_type,
+                own_address_type=OwnAddressType(own_address_type),
                 peer_address=peer_address,
-                advertising_channel_map=7,
-                advertising_filter_policy=0,
-            ),  # type: ignore[call-arg]
-            check_result=True,
-        )
-
-        # Enable advertising
-        await self.send_command(
-            HCI_LE_Set_Advertising_Enable_Command(advertising_enable=1),  # type: ignore[call-arg]
-            check_result=True,
-        )
-
-        self.advertising_type = advertising_type
-        self.advertising_own_address_type = own_address_type
-        self.advertising = True
-        self.auto_restart_advertising = auto_restart
-
-    async def stop_advertising(self) -> None:
-        # Disable advertising
-        if self.advertising:
-            await self.send_command(
-                HCI_LE_Set_Advertising_Enable_Command(advertising_enable=0),  # type: ignore[call-arg]
-                check_result=True,
+                auto_restart=auto_restart,
             )
 
-            self.advertising_type = None
-            self.advertising_own_address_type = None
-            self.advertising = False
-            self.auto_restart_advertising = False
+            await self.legacy_advertiser.start()
+
+    async def stop_advertising(self) -> None:
+        """Stop legacy advertising."""
+        # Disable advertising
+        if self.legacy_advertising_set:
+            if self.legacy_advertising_set.enabled:
+                await self.legacy_advertising_set.stop()
+            await self.legacy_advertising_set.remove()
+            self.legacy_advertising_set = None
+        elif self.legacy_advertiser:
+            await self.legacy_advertiser.stop()
+            self.legacy_advertiser = None
+
+    async def create_advertising_set(
+        self,
+        advertising_parameters: Optional[AdvertisingParameters] = None,
+        random_address: Optional[Address] = None,
+        advertising_data: bytes = b'',
+        scan_response_data: bytes = b'',
+        periodic_advertising_parameters: Optional[PeriodicAdvertisingParameters] = None,
+        periodic_advertising_data: bytes = b'',
+        auto_start: bool = True,
+        auto_restart: bool = False,
+    ) -> AdvertisingSet:
+        """
+        Create an advertising set.
+
+        This method allows the creation of advertising sets for controllers that
+        support extended advertising.
+
+        Args:
+          advertising_parameters:
+            The parameters to use for this set. If None, default parameters are used.
+          random_address:
+            The random address to use (only relevant when the parameters specify that
+            own_address_type is random).
+          advertising_data:
+            Initial value for the set's advertising data.
+          scan_response_data:
+            Initial value for the set's scan response data.
+          periodic_advertising_parameters:
+            The parameters to use for periodic advertising (if needed).
+          periodic_advertising_data:
+            Initial value for the set's periodic advertising data.
+          auto_start:
+            True if the set should be automatically started upon creation.
+          auto_restart:
+            True if the set should be automatically restated after a disconnection.
+
+        Returns:
+          An AdvertisingSet instance.
+        """
+        # Instantiate default values
+        if advertising_parameters is None:
+            advertising_parameters = AdvertisingParameters()
+
+        if (
+            not advertising_parameters.advertising_event_properties.is_legacy
+            and advertising_data
+            and scan_response_data
+        ):
+            raise ValueError(
+                "Extended advertisements can't have both data and scan \
+                              response data"
+            )
+
+        # Allocate a new handle
+        try:
+            advertising_handle = next(
+                handle
+                for handle in range(
+                    DEVICE_MIN_EXTENDED_ADVERTISING_SET_HANDLE,
+                    DEVICE_MAX_EXTENDED_ADVERTISING_SET_HANDLE + 1,
+                )
+                if handle not in self.extended_advertising_sets
+            )
+        except StopIteration as exc:
+            raise RuntimeError("all valid advertising handles already in use") from exc
+
+        # Use the device's random address if a random address is needed but none was
+        # provided.
+        if (
+            advertising_parameters.own_address_type
+            in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
+            and random_address is None
+        ):
+            random_address = self.random_address
+
+        # Create the object that represents the set.
+        advertising_set = AdvertisingSet(
+            device=self,
+            advertising_handle=advertising_handle,
+            auto_restart=auto_restart,
+            random_address=random_address,
+            advertising_parameters=advertising_parameters,
+            advertising_data=advertising_data,
+            scan_response_data=scan_response_data,
+            periodic_advertising_parameters=periodic_advertising_parameters,
+            periodic_advertising_data=periodic_advertising_data,
+        )
+
+        # Create the set in the controller.
+        await advertising_set.set_advertising_parameters(advertising_parameters)
+
+        # Update the set in the controller.
+        try:
+            if random_address:
+                await advertising_set.set_random_address(random_address)
+
+            if advertising_data:
+                await advertising_set.set_advertising_data(advertising_data)
+
+            if scan_response_data:
+                await advertising_set.set_scan_response_data(scan_response_data)
+
+            if periodic_advertising_parameters:
+                # TODO: call LE Set Periodic Advertising Parameters command
+                raise NotImplementedError('periodic advertising not yet supported')
+
+            if periodic_advertising_data:
+                # TODO: call LE Set Periodic Advertising Data command
+                raise NotImplementedError('periodic advertising not yet supported')
+
+        except HCI_Error as error:
+            # Remove the advertising set so that it doesn't stay dangling in the
+            # controller.
+            await self.send_command(
+                HCI_LE_Remove_Advertising_Set_Command(
+                    advertising_handle=advertising_data
+                ),
+                check_result=False,
+            )
+            raise error
+
+        # Remember the set.
+        self.extended_advertising_sets[advertising_handle] = advertising_set
+
+        # Try to start the set if requested.
+        if auto_start:
+            try:
+                # pylint: disable=line-too-long
+                duration = (
+                    DEVICE_MAX_HIGH_DUTY_CYCLE_CONNECTABLE_DIRECTED_ADVERTISING_DURATION
+                    if advertising_parameters.advertising_event_properties.is_high_duty_cycle_directed_connectable
+                    else 0
+                )
+                await advertising_set.start(duration=duration)
+            except Exception as error:
+                logger.exception(f'failed to start advertising set: {error}')
+                await advertising_set.remove()
+                raise
+
+        return advertising_set
 
     @property
     def is_advertising(self):
-        return self.advertising
+        if self.legacy_advertiser:
+            return True
+
+        return any(
+            advertising_set.enabled
+            for advertising_set in self.extended_advertising_sets.values()
+        )
 
     async def start_scanning(
         self,
@@ -1541,7 +2232,7 @@
         scan_window: int = DEVICE_DEFAULT_SCAN_WINDOW,  # Scan window in ms
         own_address_type: int = OwnAddressType.RANDOM,
         filter_duplicates: bool = False,
-        scanning_phys: Tuple[int, int] = (HCI_LE_1M_PHY, HCI_LE_CODED_PHY),
+        scanning_phys: List[int] = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY],
     ) -> None:
         # Check that the arguments are legal
         if scan_interval < scan_window:
@@ -1558,9 +2249,7 @@
         self.advertisement_accumulators = {}
 
         # Enable scanning
-        if not legacy and self.supports_le_feature(
-            HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE
-        ):
+        if not legacy and self.supports_le_extended_advertising:
             # Set the scanning parameters
             scan_type = (
                 HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING
@@ -1577,7 +2266,7 @@
                 scanning_phys_bits |= 1 << HCI_LE_1M_PHY_BIT
                 scanning_phy_count += 1
             if HCI_LE_CODED_PHY in scanning_phys:
-                if self.supports_le_feature(HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE):
+                if self.supports_le_features(LeFeatureMask.LE_CODED_PHY):
                     scanning_phys_bits |= 1 << HCI_LE_CODED_PHY_BIT
                     scanning_phy_count += 1
 
@@ -1592,7 +2281,7 @@
                     scan_types=[scan_type] * scanning_phy_count,
                     scan_intervals=[int(scan_window / 0.625)] * scanning_phy_count,
                     scan_windows=[int(scan_window / 0.625)] * scanning_phy_count,
-                ),  # type: ignore[call-arg]
+                ),
                 check_result=True,
             )
 
@@ -1603,7 +2292,7 @@
                     filter_duplicates=1 if filter_duplicates else 0,
                     duration=0,  # TODO allow other values
                     period=0,  # TODO allow other values
-                ),  # type: ignore[call-arg]
+                ),
                 check_result=True,
             )
         else:
@@ -1621,7 +2310,7 @@
                     le_scan_window=int(scan_window / 0.625),
                     own_address_type=own_address_type,
                     scanning_filter_policy=HCI_LE_Set_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY,
-                ),  # type: ignore[call-arg]
+                ),
                 check_result=True,
             )
 
@@ -1629,25 +2318,25 @@
             await self.send_command(
                 HCI_LE_Set_Scan_Enable_Command(
                     le_scan_enable=1, filter_duplicates=1 if filter_duplicates else 0
-                ),  # type: ignore[call-arg]
+                ),
                 check_result=True,
             )
 
         self.scanning_is_passive = not active
         self.scanning = True
 
-    async def stop_scanning(self) -> None:
+    async def stop_scanning(self, legacy: bool = False) -> None:
         # Disable scanning
-        if self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE):
+        if not legacy and self.supports_le_extended_advertising:
             await self.send_command(
                 HCI_LE_Set_Extended_Scan_Enable_Command(
                     enable=0, filter_duplicates=0, duration=0, period=0
-                ),  # type: ignore[call-arg]
+                ),
                 check_result=True,
             )
         else:
             await self.send_command(
-                HCI_LE_Set_Scan_Enable_Command(le_scan_enable=0, filter_duplicates=0),  # type: ignore[call-arg]
+                HCI_LE_Set_Scan_Enable_Command(le_scan_enable=0, filter_duplicates=0),
                 check_result=True,
             )
 
@@ -1667,7 +2356,7 @@
 
     async def start_discovery(self, auto_restart: bool = True) -> None:
         await self.send_command(
-            HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE),  # type: ignore[call-arg]
+            HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE),
             check_result=True,
         )
 
@@ -1676,7 +2365,7 @@
                 lap=HCI_GENERAL_INQUIRY_LAP,
                 inquiry_length=DEVICE_DEFAULT_INQUIRY_LENGTH,
                 num_responses=0,  # Unlimited number of responses.
-            )  # type: ignore[call-arg]
+            )
         )
         if response.status != HCI_Command_Status_Event.PENDING:
             self.discovering = False
@@ -1687,7 +2376,7 @@
 
     async def stop_discovery(self) -> None:
         if self.discovering:
-            await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True)  # type: ignore[call-arg]
+            await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True)
         self.auto_restart_inquiry = True
         self.discovering = False
 
@@ -1735,7 +2424,7 @@
             await self.send_command(
                 HCI_Write_Extended_Inquiry_Response_Command(
                     fec_required=0, extended_inquiry_response=self.inquiry_response
-                ),  # type: ignore[call-arg]
+                ),
                 check_result=True,
             )
             await self.set_scan_enable(
@@ -1924,7 +2613,7 @@
                             supervision_timeouts=supervision_timeouts,
                             min_ce_lengths=min_ce_lengths,
                             max_ce_lengths=max_ce_lengths,
-                        )  # type: ignore[call-arg]
+                        )
                     )
                 else:
                     if HCI_LE_1M_PHY not in connection_parameters_preferences:
@@ -1953,7 +2642,7 @@
                             supervision_timeout=int(prefs.supervision_timeout / 10),
                             min_ce_length=int(prefs.min_ce_length / 0.625),
                             max_ce_length=int(prefs.max_ce_length / 0.625),
-                        )  # type: ignore[call-arg]
+                        )
                     )
             else:
                 # Save pending connection
@@ -1970,7 +2659,7 @@
                         clock_offset=0x0000,
                         allow_role_switch=0x01,
                         reserved=0,
-                    )  # type: ignore[call-arg]
+                    )
                 )
 
             if result.status != HCI_Command_Status_Event.PENDING:
@@ -1989,10 +2678,10 @@
                 )
             except asyncio.TimeoutError:
                 if transport == BT_LE_TRANSPORT:
-                    await self.send_command(HCI_LE_Create_Connection_Cancel_Command())  # type: ignore[call-arg]
+                    await self.send_command(HCI_LE_Create_Connection_Cancel_Command())
                 else:
                     await self.send_command(
-                        HCI_Create_Connection_Cancel_Command(bd_addr=peer_address)  # type: ignore[call-arg]
+                        HCI_Create_Connection_Cancel_Command(bd_addr=peer_address)
                     )
 
                 try:
@@ -2106,7 +2795,7 @@
         try:
             # Accept connection request
             await self.send_command(
-                HCI_Accept_Connection_Request_Command(bd_addr=peer_address, role=role)  # type: ignore[call-arg]
+                HCI_Accept_Connection_Request_Command(bd_addr=peer_address, role=role)
             )
 
             # Wait for connection complete
@@ -2163,7 +2852,9 @@
                 check_result=True,
             )
 
-    async def disconnect(self, connection, reason):
+    async def disconnect(
+        self, connection: Union[Connection, ScoLink, CisLink], reason: int
+    ) -> None:
         # Create a future so that we can wait for the disconnection's result
         pending_disconnection = asyncio.get_running_loop().create_future()
         connection.on('disconnection', pending_disconnection.set_result)
@@ -2190,6 +2881,22 @@
             )
             self.disconnecting = False
 
+    async def set_data_length(self, connection, tx_octets, tx_time) -> None:
+        if tx_octets < 0x001B or tx_octets > 0x00FB:
+            raise ValueError('tx_octets must be between 0x001B and 0x00FB')
+
+        if tx_time < 0x0148 or tx_time > 0x4290:
+            raise ValueError('tx_time must be between 0x0148 and 0x4290')
+
+        return await self.send_command(
+            HCI_LE_Set_Data_Length_Command(
+                connection_handle=connection.handle,
+                tx_octets=tx_octets,
+                tx_time=tx_time,
+            ),
+            check_result=True,
+        )
+
     async def update_connection_parameters(
         self,
         connection,
@@ -2232,7 +2939,7 @@
                 supervision_timeout=supervision_timeout,
                 min_ce_length=min_ce_length,
                 max_ce_length=max_ce_length,
-            )  # type: ignore[call-arg]
+            )
         )
         if result.status != HCI_Command_Status_Event.PENDING:
             raise HCI_StatusError(result)
@@ -2560,7 +3267,7 @@
 
         try:
             result = await self.send_command(
-                HCI_Switch_Role_Command(bd_addr=connection.peer_address, role=role)  # type: ignore[call-arg]
+                HCI_Switch_Role_Command(bd_addr=connection.peer_address, role=role)
             )
             if result.status != HCI_COMMAND_STATUS_PENDING:
                 logger.warning(
@@ -2602,7 +3309,7 @@
                     page_scan_repetition_mode=HCI_Remote_Name_Request_Command.R2,
                     reserved=0,
                     clock_offset=0,  # TODO investigate non-0 values
-                )  # type: ignore[call-arg]
+                )
             )
 
             if result.status != HCI_COMMAND_STATUS_PENDING:
@@ -2618,6 +3325,172 @@
             self.remove_listener('remote_name', handler)
             self.remove_listener('remote_name_failure', failure_handler)
 
+    # [LE only]
+    @experimental('Only for testing.')
+    async def setup_cig(
+        self,
+        cig_id: int,
+        cis_id: List[int],
+        sdu_interval: Tuple[int, int],
+        framing: int,
+        max_sdu: Tuple[int, int],
+        retransmission_number: int,
+        max_transport_latency: Tuple[int, int],
+    ) -> List[int]:
+        """Sends HCI_LE_Set_CIG_Parameters_Command.
+
+        Args:
+            cig_id: CIG_ID.
+            cis_id: CID ID list.
+            sdu_interval: SDU intervals of (Central->Peripheral, Peripheral->Cental).
+            framing: Un-framing(0) or Framing(1).
+            max_sdu: Max SDU counts of (Central->Peripheral, Peripheral->Cental).
+            retransmission_number: retransmission_number.
+            max_transport_latency: Max transport latencies of
+                                   (Central->Peripheral, Peripheral->Cental).
+
+        Returns:
+            List of created CIS handles corresponding to the same order of [cid_id].
+        """
+        num_cis = len(cis_id)
+
+        response = await self.send_command(
+            HCI_LE_Set_CIG_Parameters_Command(
+                cig_id=cig_id,
+                sdu_interval_c_to_p=sdu_interval[0],
+                sdu_interval_p_to_c=sdu_interval[1],
+                worst_case_sca=0x00,  # 251-500 ppm
+                packing=0x00,  # Sequential
+                framing=framing,
+                max_transport_latency_c_to_p=max_transport_latency[0],
+                max_transport_latency_p_to_c=max_transport_latency[1],
+                cis_id=cis_id,
+                max_sdu_c_to_p=[max_sdu[0]] * num_cis,
+                max_sdu_p_to_c=[max_sdu[1]] * num_cis,
+                phy_c_to_p=[HCI_LE_2M_PHY] * num_cis,
+                phy_p_to_c=[HCI_LE_2M_PHY] * num_cis,
+                rtn_c_to_p=[retransmission_number] * num_cis,
+                rtn_p_to_c=[retransmission_number] * num_cis,
+            ),
+            check_result=True,
+        )
+
+        # Ideally, we should manage CIG lifecycle, but they are not useful for Unicast
+        # Server, so here it only provides a basic functionality for testing.
+        cis_handles = response.return_parameters.connection_handle[:]
+        for id, cis_handle in zip(cis_id, cis_handles):
+            self._pending_cis[cis_handle] = (id, cig_id)
+
+        return cis_handles
+
+    # [LE only]
+    @experimental('Only for testing.')
+    async def create_cis(self, cis_acl_pairs: List[Tuple[int, int]]) -> List[CisLink]:
+        for cis_handle, acl_handle in cis_acl_pairs:
+            acl_connection = self.lookup_connection(acl_handle)
+            assert acl_connection
+            cis_id, cig_id = self._pending_cis.pop(cis_handle)
+            self.cis_links[cis_handle] = CisLink(
+                device=self,
+                acl_connection=acl_connection,
+                handle=cis_handle,
+                cis_id=cis_id,
+                cig_id=cig_id,
+            )
+
+        with closing(EventWatcher()) as watcher:
+            pending_cis_establishments = {
+                cis_handle: asyncio.get_running_loop().create_future()
+                for cis_handle, _ in cis_acl_pairs
+            }
+
+            @watcher.on(self, 'cis_establishment')
+            def on_cis_establishment(cis_link: CisLink) -> None:
+                if pending_future := pending_cis_establishments.get(cis_link.handle):
+                    pending_future.set_result(cis_link)
+
+            result = await self.send_command(
+                HCI_LE_Create_CIS_Command(
+                    cis_connection_handle=[p[0] for p in cis_acl_pairs],
+                    acl_connection_handle=[p[1] for p in cis_acl_pairs],
+                ),
+            )
+            if result.status != HCI_COMMAND_STATUS_PENDING:
+                logger.warning(
+                    'HCI_LE_Create_CIS_Command failed: '
+                    f'{HCI_Constant.error_name(result.status)}'
+                )
+                raise HCI_StatusError(result)
+
+            return await asyncio.gather(*pending_cis_establishments.values())
+
+    # [LE only]
+    @experimental('Only for testing.')
+    async def accept_cis_request(self, handle: int) -> CisLink:
+        result = await self.send_command(
+            HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
+        )
+        if result.status != HCI_COMMAND_STATUS_PENDING:
+            logger.warning(
+                'HCI_LE_Accept_CIS_Request_Command failed: '
+                f'{HCI_Constant.error_name(result.status)}'
+            )
+            raise HCI_StatusError(result)
+
+        pending_cis_establishment = asyncio.get_running_loop().create_future()
+
+        with closing(EventWatcher()) as watcher:
+
+            @watcher.on(self, 'cis_establishment')
+            def on_cis_establishment(cis_link: CisLink) -> None:
+                if cis_link.handle == handle:
+                    pending_cis_establishment.set_result(cis_link)
+
+            return await pending_cis_establishment
+
+    # [LE only]
+    @experimental('Only for testing.')
+    async def reject_cis_request(
+        self,
+        handle: int,
+        reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
+    ) -> None:
+        result = await self.send_command(
+            HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason),
+        )
+        if result.status != HCI_COMMAND_STATUS_PENDING:
+            logger.warning(
+                'HCI_LE_Reject_CIS_Request_Command failed: '
+                f'{HCI_Constant.error_name(result.status)}'
+            )
+            raise HCI_StatusError(result)
+
+    async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask:
+        """[LE Only] Reads remote LE supported features.
+
+        Args:
+            handle: connection handle to read LE features.
+
+        Returns:
+            LE features supported by the remote device.
+        """
+        with closing(EventWatcher()) as watcher:
+            read_feature_future: asyncio.Future[
+                LeFeatureMask
+            ] = asyncio.get_running_loop().create_future()
+
+            def on_le_remote_features(handle: int, features: int):
+                if handle == connection.handle:
+                    read_feature_future.set_result(LeFeatureMask(features))
+
+            watcher.on(self.host, 'le_remote_features', on_le_remote_features)
+            await self.send_command(
+                HCI_LE_Read_Remote_Features_Command(
+                    connection_handle=connection.handle
+                ),
+            )
+            return await read_feature_future
+
     @host_event_handler
     def on_flush(self):
         self.emit('flush')
@@ -2670,6 +3543,74 @@
         await self.gatt_server.indicate_subscribers(attribute, value, force)
 
     @host_event_handler
+    def on_advertising_set_termination(
+        self,
+        status,
+        advertising_handle,
+        connection_handle,
+        number_of_completed_extended_advertising_events,
+    ):
+        # Legacy advertising set is also one of extended advertising sets.
+        if not (
+            advertising_set := self.extended_advertising_sets.get(advertising_handle)
+        ):
+            logger.warning(f'advertising set {advertising_handle} not found')
+            return
+
+        advertising_set.on_termination(status)
+
+        if status != HCI_SUCCESS:
+            logger.debug(
+                f'advertising set {advertising_handle} '
+                f'terminated with status {status}'
+            )
+            return
+
+        if not (connection := self.lookup_connection(connection_handle)):
+            logger.warning(f'no connection for handle 0x{connection_handle:04x}')
+            return
+
+        # Update the connection address.
+        connection.self_address = (
+            advertising_set.random_address
+            if advertising_set.advertising_parameters.own_address_type
+            in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
+            else self.public_address
+        )
+
+        # Setup auto-restart of the advertising set if needed.
+        if advertising_set.auto_restart:
+            connection.once(
+                'disconnection',
+                lambda _: self.abort_on('flush', advertising_set.start()),
+            )
+
+        self._emit_le_connection(connection)
+
+    def _emit_le_connection(self, connection: Connection) -> None:
+        # If supported, read which PHY we're connected with before
+        # notifying listeners of the new connection.
+        if self.host.supports_command(HCI_LE_READ_PHY_COMMAND):
+
+            async def read_phy():
+                result = await self.send_command(
+                    HCI_LE_Read_PHY_Command(connection_handle=connection.handle),
+                    check_result=True,
+                )
+                connection.phy = ConnectionPHY(
+                    result.return_parameters.tx_phy, result.return_parameters.rx_phy
+                )
+                # Emit an event to notify listeners of the new connection
+                self.emit('connection', connection)
+
+            # Do so asynchronously to not block the current event handler
+            connection.abort_on('disconnection', read_phy())
+
+            return
+
+        self.emit('connection', connection)
+
+    @host_event_handler
     def on_connection(
         self,
         connection_handle,
@@ -2687,8 +3628,6 @@
                 'new connection reuses the same handle as a previous connection'
             )
 
-        peer_resolvable_address = None
-
         if transport == BT_BR_EDR_TRANSPORT:
             # Create a new connection
             connection = self.pending_connections.pop(peer_address)
@@ -2697,70 +3636,76 @@
 
             # Emit an event to notify listeners of the new connection
             self.emit('connection', connection)
+
+            return
+
+        # Resolve the peer address if we can
+        peer_resolvable_address = None
+        if self.address_resolver:
+            if peer_address.is_resolvable:
+                resolved_address = self.address_resolver.resolve(peer_address)
+                if resolved_address is not None:
+                    logger.debug(f'*** Address resolved as {resolved_address}')
+                    peer_resolvable_address = peer_address
+                    peer_address = resolved_address
+
+        self_address = None
+        if role == HCI_CENTRAL_ROLE:
+            own_address_type = self.connect_own_address_type
+            assert own_address_type is not None
         else:
-            # Resolve the peer address if we can
-            if self.address_resolver:
-                if peer_address.is_resolvable:
-                    resolved_address = self.address_resolver.resolve(peer_address)
-                    if resolved_address is not None:
-                        logger.debug(f'*** Address resolved as {resolved_address}')
-                        peer_resolvable_address = peer_address
-                        peer_address = resolved_address
-
-            # Guess which own address type is used for this connection.
-            # This logic is somewhat correct but may need to be improved
-            # when multiple advertising are run simultaneously.
-            if self.connect_own_address_type is not None:
-                own_address_type = self.connect_own_address_type
+            if self.supports_le_extended_advertising:
+                # We'll know the address when the advertising set terminates,
+                # Use a temporary placeholder value for self_address.
+                self_address = Address.ANY_RANDOM
             else:
-                own_address_type = self.advertising_own_address_type
+                # We were connected via a legacy advertisement.
+                if self.legacy_advertiser:
+                    own_address_type = self.legacy_advertiser.own_address_type
+                    self.legacy_advertiser = None
+                else:
+                    # This should not happen, but just in case, pick a default.
+                    logger.warning("connection without an advertiser")
+                    self_address = self.random_address
 
-            # We are no longer advertising
-            self.advertising = False
-
-            if own_address_type in (
-                OwnAddressType.PUBLIC,
-                OwnAddressType.RESOLVABLE_OR_PUBLIC,
-            ):
-                self_address = self.public_address
-            else:
-                self_address = self.random_address
-
-            # Create a new connection
-            connection = Connection(
-                self,
-                connection_handle,
-                transport,
-                self_address,
-                peer_address,
-                peer_resolvable_address,
-                role,
-                connection_parameters,
-                ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY),
+        if self_address is None:
+            self_address = (
+                self.public_address
+                if own_address_type
+                in (
+                    OwnAddressType.PUBLIC,
+                    OwnAddressType.RESOLVABLE_OR_PUBLIC,
+                )
+                else self.random_address
             )
-            self.connections[connection_handle] = connection
 
-            # If supported, read which PHY we're connected with before
-            # notifying listeners of the new connection.
-            if self.host.supports_command(HCI_LE_READ_PHY_COMMAND):
+        # Create a connection.
+        connection = Connection(
+            self,
+            connection_handle,
+            transport,
+            self_address,
+            peer_address,
+            peer_resolvable_address,
+            role,
+            connection_parameters,
+            ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY),
+        )
+        self.connections[connection_handle] = connection
 
-                async def read_phy():
-                    result = await self.send_command(
-                        HCI_LE_Read_PHY_Command(connection_handle=connection_handle),
-                        check_result=True,
-                    )
-                    connection.phy = ConnectionPHY(
-                        result.return_parameters.tx_phy, result.return_parameters.rx_phy
-                    )
-                    # Emit an event to notify listeners of the new connection
-                    self.emit('connection', connection)
+        if (
+            role == HCI_PERIPHERAL_ROLE
+            and self.legacy_advertiser
+            and self.legacy_advertiser.auto_restart
+        ):
+            connection.once(
+                'disconnection',
+                lambda _: self.abort_on('flush', self.legacy_advertiser.start()),
+            )
 
-                # Do so asynchronously to not block the current event handler
-                connection.abort_on('disconnection', read_phy())
-
-            else:
-                # Emit an event to notify listeners of the new connection
-                self.emit('connection', connection)
+        if role == HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising:
+            # We can emit now, we have all the info we need
+            self._emit_le_connection(connection)
 
     @host_event_handler
     def on_connection_failure(self, transport, peer_address, error_code):
@@ -2769,10 +3714,10 @@
         # For directed advertising, this means a timeout
         if (
             transport == BT_LE_TRANSPORT
-            and self.advertising
-            and self.advertising_type.is_directed
+            and self.legacy_advertiser
+            and self.legacy_advertiser.advertising_type.is_directed
         ):
-            self.advertising = False
+            self.legacy_advertiser = None
 
         # Notify listeners
         error = core.ConnectionError(
@@ -2789,8 +3734,21 @@
     def on_connection_request(self, bd_addr, class_of_device, link_type):
         logger.debug(f'*** Connection request: {bd_addr}')
 
+        # Handle SCO request.
+        if link_type in (
+            HCI_Connection_Complete_Event.SCO_LINK_TYPE,
+            HCI_Connection_Complete_Event.ESCO_LINK_TYPE,
+        ):
+            if connection := self.find_connection_by_bd_addr(
+                bd_addr, transport=BT_BR_EDR_TRANSPORT
+            ):
+                self.emit('sco_request', connection, link_type)
+            else:
+                logger.error(f'SCO request from a non-connected device {bd_addr}')
+            return
+
         # match a pending future using `bd_addr`
-        if bd_addr in self.classic_pending_accepts:
+        elif bd_addr in self.classic_pending_accepts:
             future, *_ = self.classic_pending_accepts.pop(bd_addr)
             future.set_result((bd_addr, class_of_device, link_type))
 
@@ -2822,30 +3780,23 @@
             )
 
     @host_event_handler
-    @with_connection_from_handle
-    def on_disconnection(self, connection, reason):
-        logger.debug(
-            f'*** Disconnection: [0x{connection.handle:04X}] '
-            f'{connection.peer_address} as {connection.role_name}, reason={reason}'
-        )
-        connection.emit('disconnection', reason)
+    def on_disconnection(self, connection_handle: int, reason: int) -> None:
+        if connection := self.connections.pop(connection_handle, None):
+            logger.debug(
+                f'*** Disconnection: [0x{connection.handle:04X}] '
+                f'{connection.peer_address} as {connection.role_name}, reason={reason}'
+            )
+            connection.emit('disconnection', reason)
 
-        # Remove the connection from the map
-        del self.connections[connection.handle]
-
-        # Cleanup subsystems that maintain per-connection state
-        self.gatt_server.on_disconnection(connection)
-
-        # Restart advertising if auto-restart is enabled
-        if self.auto_restart_advertising:
-            logger.debug('restarting advertising')
-            self.abort_on(
-                'flush',
-                self.start_advertising(
-                    advertising_type=self.advertising_type,
-                    own_address_type=self.advertising_own_address_type,
-                    auto_restart=True,
-                ),
+            # Cleanup subsystems that maintain per-connection state
+            self.gatt_server.on_disconnection(connection)
+        elif sco_link := self.sco_links.pop(connection_handle, None):
+            sco_link.emit('disconnection', reason)
+        elif cis_link := self.cis_links.pop(connection_handle, None):
+            cis_link.emit('disconnection', reason)
+        else:
+            logger.error(
+                f'*** Unknown disconnection handle=0x{connection_handle}, reason={reason} ***'
             )
 
     @host_event_handler
@@ -2996,7 +3947,7 @@
             try:
                 if await connection.abort_on('disconnection', method()):
                     await self.host.send_command(
-                        HCI_User_Confirmation_Request_Reply_Command(  # type: ignore[call-arg]
+                        HCI_User_Confirmation_Request_Reply_Command(
                             bd_addr=connection.peer_address
                         )
                     )
@@ -3005,7 +3956,7 @@
                 logger.warning(f'exception while confirming: {error}')
 
             await self.host.send_command(
-                HCI_User_Confirmation_Request_Negative_Reply_Command(  # type: ignore[call-arg]
+                HCI_User_Confirmation_Request_Negative_Reply_Command(
                     bd_addr=connection.peer_address
                 )
             )
@@ -3026,7 +3977,7 @@
                 )
                 if number is not None:
                     await self.host.send_command(
-                        HCI_User_Passkey_Request_Reply_Command(  # type: ignore[call-arg]
+                        HCI_User_Passkey_Request_Reply_Command(
                             bd_addr=connection.peer_address, numeric_value=number
                         )
                     )
@@ -3035,7 +3986,7 @@
                 logger.warning(f'exception while asking for pass-key: {error}')
 
             await self.host.send_command(
-                HCI_User_Passkey_Request_Negative_Reply_Command(  # type: ignore[call-arg]
+                HCI_User_Passkey_Request_Negative_Reply_Command(
                     bd_addr=connection.peer_address
                 )
             )
@@ -3124,6 +4075,107 @@
             connection.emit('remote_name_failure', error)
         self.emit('remote_name_failure', address, error)
 
+    # [Classic only]
+    @host_event_handler
+    @with_connection_from_address
+    @experimental('Only for testing.')
+    def on_sco_connection(
+        self, acl_connection: Connection, sco_handle: int, link_type: int
+    ) -> None:
+        logger.debug(
+            f'*** SCO connected: {acl_connection.peer_address}, '
+            f'sco_handle=[0x{sco_handle:04X}], '
+            f'link_type=[0x{link_type:02X}] ***'
+        )
+        sco_link = self.sco_links[sco_handle] = ScoLink(
+            device=self,
+            acl_connection=acl_connection,
+            handle=sco_handle,
+            link_type=link_type,
+        )
+        self.emit('sco_connection', sco_link)
+
+    # [Classic only]
+    @host_event_handler
+    @with_connection_from_address
+    @experimental('Only for testing.')
+    def on_sco_connection_failure(
+        self, acl_connection: Connection, status: int
+    ) -> None:
+        logger.debug(f'*** SCO connection failure: {acl_connection.peer_address}***')
+        self.emit('sco_connection_failure')
+
+    # [Classic only]
+    @host_event_handler
+    @experimental('Only for testing')
+    def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None:
+        if sco_link := self.sco_links.get(sco_handle):
+            sco_link.emit('pdu', packet)
+
+    # [LE only]
+    @host_event_handler
+    @with_connection_from_handle
+    @experimental('Only for testing')
+    def on_cis_request(
+        self,
+        acl_connection: Connection,
+        cis_handle: int,
+        cig_id: int,
+        cis_id: int,
+    ) -> None:
+        logger.debug(
+            f'*** CIS Request '
+            f'acl_handle=[0x{acl_connection.handle:04X}]{acl_connection.peer_address}, '
+            f'cis_handle=[0x{cis_handle:04X}], '
+            f'cig_id=[0x{cig_id:02X}], '
+            f'cis_id=[0x{cis_id:02X}] ***'
+        )
+        # LE_CIS_Established event doesn't provide info, so we must store them here.
+        self.cis_links[cis_handle] = CisLink(
+            device=self,
+            acl_connection=acl_connection,
+            handle=cis_handle,
+            cig_id=cig_id,
+            cis_id=cis_id,
+        )
+        self.emit('cis_request', acl_connection, cis_handle, cig_id, cis_id)
+
+    # [LE only]
+    @host_event_handler
+    @experimental('Only for testing')
+    def on_cis_establishment(self, cis_handle: int) -> None:
+        cis_link = self.cis_links[cis_handle]
+        cis_link.state = CisLink.State.ESTABLISHED
+
+        assert cis_link.acl_connection
+
+        logger.debug(
+            f'*** CIS Establishment '
+            f'{cis_link.acl_connection.peer_address}, '
+            f'cis_handle=[0x{cis_handle:04X}], '
+            f'cig_id=[0x{cis_link.cig_id:02X}], '
+            f'cis_id=[0x{cis_link.cis_id:02X}] ***'
+        )
+
+        cis_link.emit('establishment')
+        self.emit('cis_establishment', cis_link)
+
+    # [LE only]
+    @host_event_handler
+    @experimental('Only for testing')
+    def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
+        logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
+        if cis_link := self.cis_links.pop(cis_handle):
+            cis_link.emit('establishment_failure')
+        self.emit('cis_establishment_failure', cis_handle, status)
+
+    # [LE only]
+    @host_event_handler
+    @experimental('Only for testing')
+    def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None:
+        if cis_link := self.cis_links.get(handle):
+            cis_link.emit('pdu', packet)
+
     @host_event_handler
     @with_connection_from_handle
     def on_connection_encryption_change(self, connection, encryption):
@@ -3135,10 +4187,18 @@
         connection.encryption = encryption
         if (
             not connection.authenticated
+            and connection.transport == BT_BR_EDR_TRANSPORT
             and encryption == HCI_Encryption_Change_Event.AES_CCM
         ):
             connection.authenticated = True
             connection.sc = True
+        if (
+            not connection.authenticated
+            and connection.transport == BT_LE_TRANSPORT
+            and encryption == HCI_Encryption_Change_Event.E0_OR_AES_CCM
+        ):
+            connection.authenticated = True
+            connection.sc = True
         connection.emit('connection_encryption_change')
 
     @host_event_handler
diff --git a/bumble/drivers/__init__.py b/bumble/drivers/__init__.py
index d8ea06e..1e72665 100644
--- a/bumble/drivers/__init__.py
+++ b/bumble/drivers/__init__.py
@@ -19,12 +19,17 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
-import abc
+from __future__ import annotations
 import logging
 import pathlib
 import platform
-from . import rtk
+from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING
 
+from . import rtk, intel
+from .common import Driver
+
+if TYPE_CHECKING:
+    from bumble.host import Host
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -33,39 +38,30 @@
 
 
 # -----------------------------------------------------------------------------
-# Classes
-# -----------------------------------------------------------------------------
-class Driver(abc.ABC):
-    """Base class for drivers."""
-
-    @staticmethod
-    async def for_host(_host):
-        """Return a driver instance for a host.
-
-        Args:
-            host: Host object for which a driver should be created.
-
-        Returns:
-            A Driver instance if a driver should be instantiated for this host, or
-            None if no driver instance of this class is needed.
-        """
-        return None
-
-    @abc.abstractmethod
-    async def init_controller(self):
-        """Initialize the controller."""
-
-
-# -----------------------------------------------------------------------------
 # Functions
 # -----------------------------------------------------------------------------
-async def get_driver_for_host(host):
-    """Probe all known diver classes until one returns a valid instance for a host,
-    or none is found.
+async def get_driver_for_host(host: Host) -> Optional[Driver]:
+    """Probe diver classes until one returns a valid instance for a host, or none is
+    found.
+    If a "driver" HCI metadata entry is present, only that driver class will be probed.
     """
-    if driver := await rtk.Driver.for_host(host):
-        logger.debug("Instantiated RTK driver")
-        return driver
+    driver_classes: Dict[str, Type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
+    probe_list: Iterable[str]
+    if driver_name := host.hci_metadata.get("driver"):
+        # Only probe a single driver
+        probe_list = [driver_name]
+    else:
+        # Probe all drivers
+        probe_list = driver_classes.keys()
+
+    for driver_name in probe_list:
+        if driver_class := driver_classes.get(driver_name):
+            logger.debug(f"Probing driver class: {driver_name}")
+            if driver := await driver_class.for_host(host):
+                logger.debug(f"Instantiated {driver_name} driver")
+                return driver
+        else:
+            logger.debug(f"Skipping unknown driver class: {driver_name}")
 
     return None
 
diff --git a/bumble/drivers/common.py b/bumble/drivers/common.py
new file mode 100644
index 0000000..a4c0427
--- /dev/null
+++ b/bumble/drivers/common.py
@@ -0,0 +1,45 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Common types for drivers.
+"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import abc
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
+class Driver(abc.ABC):
+    """Base class for drivers."""
+
+    @staticmethod
+    async def for_host(_host):
+        """Return a driver instance for a host.
+
+        Args:
+            host: Host object for which a driver should be created.
+
+        Returns:
+            A Driver instance if a driver should be instantiated for this host, or
+            None if no driver instance of this class is needed.
+        """
+        return None
+
+    @abc.abstractmethod
+    async def init_controller(self):
+        """Initialize the controller."""
diff --git a/bumble/drivers/intel.py b/bumble/drivers/intel.py
new file mode 100644
index 0000000..e613c1e
--- /dev/null
+++ b/bumble/drivers/intel.py
@@ -0,0 +1,102 @@
+# Copyright 2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import logging
+
+from bumble.drivers import common
+from bumble.hci import (
+    hci_vendor_command_op_code,  # type: ignore
+    HCI_Command,
+    HCI_Reset_Command,
+)
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+# -----------------------------------------------------------------------------
+# Constant
+# -----------------------------------------------------------------------------
+
+INTEL_USB_PRODUCTS = {
+    # Intel AX210
+    (0x8087, 0x0032),
+    # Intel BE200
+    (0x8087, 0x0036),
+}
+
+# -----------------------------------------------------------------------------
+# HCI Commands
+# -----------------------------------------------------------------------------
+HCI_INTEL_DDC_CONFIG_WRITE_COMMAND = hci_vendor_command_op_code(0xFC8B)  # type: ignore
+HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD = [0x03, 0xE4, 0x02, 0x00]
+
+HCI_Command.register_commands(globals())
+
+
+@HCI_Command.command(  # type: ignore
+    fields=[("params", "*")],
+    return_parameters_fields=[
+        ("params", "*"),
+    ],
+)
+class Hci_Intel_DDC_Config_Write_Command(HCI_Command):
+    pass
+
+
+class Driver(common.Driver):
+    def __init__(self, host):
+        self.host = host
+
+    @staticmethod
+    def check(host):
+        driver = host.hci_metadata.get("driver")
+        if driver == "intel":
+            return True
+
+        vendor_id = host.hci_metadata.get("vendor_id")
+        product_id = host.hci_metadata.get("product_id")
+
+        if vendor_id is None or product_id is None:
+            logger.debug("USB metadata not sufficient")
+            return False
+
+        if (vendor_id, product_id) not in INTEL_USB_PRODUCTS:
+            logger.debug(
+                f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
+            )
+            return False
+
+        return True
+
+    @classmethod
+    async def for_host(cls, host, force=False):  # type: ignore
+        # Only instantiate this driver if explicitly selected
+        if not force and not cls.check(host):
+            return None
+
+        return cls(host)
+
+    async def init_controller(self):
+        self.host.ready = True
+        await self.host.send_command(HCI_Reset_Command(), check_result=True)
+        await self.host.send_command(
+            Hci_Intel_DDC_Config_Write_Command(
+                params=HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD
+            )
+        )
diff --git a/bumble/drivers/rtk.py b/bumble/drivers/rtk.py
index f78a14d..4a9034d 100644
--- a/bumble/drivers/rtk.py
+++ b/bumble/drivers/rtk.py
@@ -41,7 +41,7 @@
     HCI_Reset_Command,
     HCI_Read_Local_Version_Information_Command,
 )
-
+from bumble.drivers import common
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -285,7 +285,7 @@
             )
 
 
-class Driver:
+class Driver(common.Driver):
     @dataclass
     class DriverInfo:
         rom: int
@@ -470,8 +470,12 @@
             logger.debug("USB metadata not found")
             return False
 
-        vendor_id = host.hci_metadata.get("vendor_id", None)
-        product_id = host.hci_metadata.get("product_id", None)
+        if host.hci_metadata.get('driver') == 'rtk':
+            # Forced driver
+            return True
+
+        vendor_id = host.hci_metadata.get("vendor_id")
+        product_id = host.hci_metadata.get("product_id")
         if vendor_id is None or product_id is None:
             logger.debug("USB metadata not sufficient")
             return False
@@ -486,6 +490,9 @@
 
     @classmethod
     async def driver_info_for_host(cls, host):
+        await host.send_command(HCI_Reset_Command(), check_result=True)
+        host.ready = True  # Needed to let the host know the controller is ready.
+
         response = await host.send_command(
             HCI_Read_Local_Version_Information_Command(), check_result=True
         )
diff --git a/bumble/gatt.py b/bumble/gatt.py
index fe3e85c..71c01f4 100644
--- a/bumble/gatt.py
+++ b/bumble/gatt.py
@@ -23,16 +23,28 @@
 # Imports
 # -----------------------------------------------------------------------------
 from __future__ import annotations
-import asyncio
 import enum
 import functools
 import logging
 import struct
-from typing import Optional, Sequence, Iterable, List, Union
+from typing import (
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Sequence,
+    Union,
+    TYPE_CHECKING,
+)
 
-from .colors import color
-from .core import UUID, get_dict_key_by_value
-from .att import Attribute
+from bumble.colors import color
+from bumble.core import UUID
+from bumble.att import Attribute, AttributeValue
+
+if TYPE_CHECKING:
+    from bumble.gatt_client import AttributeProxy
+    from bumble.device import Connection
 
 
 # -----------------------------------------------------------------------------
@@ -93,20 +105,35 @@
 GATT_INSULIN_DELIVERY_SERVICE               = UUID.from_16_bits(0x183A, 'Insulin Delivery')
 GATT_BINARY_SENSOR_SERVICE                  = UUID.from_16_bits(0x183B, 'Binary Sensor')
 GATT_EMERGENCY_CONFIGURATION_SERVICE        = UUID.from_16_bits(0x183C, 'Emergency Configuration')
+GATT_AUTHORIZATION_CONTROL_SERVICE          = UUID.from_16_bits(0x183D, 'Authorization Control')
 GATT_PHYSICAL_ACTIVITY_MONITOR_SERVICE      = UUID.from_16_bits(0x183E, 'Physical Activity Monitor')
+GATT_ELAPSED_TIME_SERVICE                   = UUID.from_16_bits(0x183F, 'Elapsed Time')
+GATT_GENERIC_HEALTH_SENSOR_SERVICE          = UUID.from_16_bits(0x1840, 'Generic Health Sensor')
 GATT_AUDIO_INPUT_CONTROL_SERVICE            = UUID.from_16_bits(0x1843, 'Audio Input Control')
 GATT_VOLUME_CONTROL_SERVICE                 = UUID.from_16_bits(0x1844, 'Volume Control')
 GATT_VOLUME_OFFSET_CONTROL_SERVICE          = UUID.from_16_bits(0x1845, 'Volume Offset Control')
-GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification Service')
+GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification')
 GATT_DEVICE_TIME_SERVICE                    = UUID.from_16_bits(0x1847, 'Device Time')
-GATT_MEDIA_CONTROL_SERVICE                  = UUID.from_16_bits(0x1848, 'Media Control Service')
-GATT_GENERIC_MEDIA_CONTROL_SERVICE          = UUID.from_16_bits(0x1849, 'Generic Media Control Service')
+GATT_MEDIA_CONTROL_SERVICE                  = UUID.from_16_bits(0x1848, 'Media Control')
+GATT_GENERIC_MEDIA_CONTROL_SERVICE          = UUID.from_16_bits(0x1849, 'Generic Media Control')
 GATT_CONSTANT_TONE_EXTENSION_SERVICE        = UUID.from_16_bits(0x184A, 'Constant Tone Extension')
-GATT_TELEPHONE_BEARER_SERVICE               = UUID.from_16_bits(0x184B, 'Telephone Bearer Service')
-GATT_GENERIC_TELEPHONE_BEARER_SERVICE       = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer Service')
+GATT_TELEPHONE_BEARER_SERVICE               = UUID.from_16_bits(0x184B, 'Telephone Bearer')
+GATT_GENERIC_TELEPHONE_BEARER_SERVICE       = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer')
 GATT_MICROPHONE_CONTROL_SERVICE             = UUID.from_16_bits(0x184D, 'Microphone Control')
+GATT_AUDIO_STREAM_CONTROL_SERVICE           = UUID.from_16_bits(0x184E, 'Audio Stream Control')
+GATT_BROADCAST_AUDIO_SCAN_SERVICE           = UUID.from_16_bits(0x184F, 'Broadcast Audio Scan')
+GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE   = UUID.from_16_bits(0x1850, 'Published Audio Capabilities')
+GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE       = UUID.from_16_bits(0x1851, 'Basic Audio Announcement')
+GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE   = UUID.from_16_bits(0x1852, 'Broadcast Audio Announcement')
+GATT_COMMON_AUDIO_SERVICE                   = UUID.from_16_bits(0x1853, 'Common Audio')
+GATT_HEARING_ACCESS_SERVICE                 = UUID.from_16_bits(0x1854, 'Hearing Access')
+GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE      = UUID.from_16_bits(0x1855, 'Telephony and Media Audio')
+GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE  = UUID.from_16_bits(0x1856, 'Public Broadcast Announcement')
+GATT_ELECTRONIC_SHELF_LABEL_SERVICE         = UUID.from_16_bits(0X1857, 'Electronic Shelf Label')
+GATT_GAMING_AUDIO_SERVICE                   = UUID.from_16_bits(0x1858, 'Gaming Audio')
+GATT_MESH_PROXY_SOLICITATION_SERVICE        = UUID.from_16_bits(0x1859, 'Mesh Audio Solicitation')
 
-# Types
+# Attribute Types
 GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE   = UUID.from_16_bits(0x2800, 'Primary Service')
 GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2801, 'Secondary Service')
 GATT_INCLUDE_ATTRIBUTE_TYPE           = UUID.from_16_bits(0x2802, 'Include')
@@ -129,6 +156,8 @@
 GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR        = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting')
 GATT_TIME_TRIGGER_DESCRIPTOR                         = UUID.from_16_bits(0x290E, 'Time Trigger Setting')
 GATT_COMPLETE_BR_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data')
+GATT_OBSERVATION_SCHEDULE_DESCRIPTOR                 = UUID.from_16_bits(0x290F, 'Observation Schedule')
+GATT_VALID_RANGE_AND_ACCURACY_DESCRIPTOR             = UUID.from_16_bits(0x290F, 'Valid Range And Accuracy')
 
 # Device Information Service
 GATT_SYSTEM_ID_CHARACTERISTIC                          = UUID.from_16_bits(0x2A23, 'System ID')
@@ -156,6 +185,96 @@
 # Battery Service
 GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level')
 
+# Telephony And Media Audio Service (TMAS)
+GATT_TMAP_ROLE_CHARACTERISTIC = UUID.from_16_bits(0x2B51, 'TMAP Role')
+
+# Audio Input Control Service (AICS)
+GATT_AUDIO_INPUT_STATE_CHARACTERISTIC         = UUID.from_16_bits(0x2B77, 'Audio Input State')
+GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC   = UUID.from_16_bits(0x2B78, 'Gain Settings Attribute')
+GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC          = UUID.from_16_bits(0x2B79, 'Audio Input Type')
+GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC        = UUID.from_16_bits(0x2B7A, 'Audio Input Status')
+GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7B, 'Audio Input Control Point')
+GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC   = UUID.from_16_bits(0x2B7C, 'Audio Input Description')
+
+# Volume Control Service (VCS)
+GATT_VOLUME_STATE_CHARACTERISTIC                = UUID.from_16_bits(0x2B7D, 'Volume State')
+GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC        = UUID.from_16_bits(0x2B7E, 'Volume Control Point')
+GATT_VOLUME_FLAGS_CHARACTERISTIC                = UUID.from_16_bits(0x2B7F, 'Volume Flags')
+
+# Volume Offset Control Service (VOCS)
+GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC         = UUID.from_16_bits(0x2B80, 'Volume Offset State')
+GATT_AUDIO_LOCATION_CHARACTERISTIC              = UUID.from_16_bits(0x2B81, 'Audio Location')
+GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B82, 'Volume Offset Control Point')
+GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC    = UUID.from_16_bits(0x2B83, 'Audio Output Description')
+
+# Coordinated Set Identification Service (CSIS)
+GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC  = UUID.from_16_bits(0x2B84, 'Set Identity Resolving Key')
+GATT_COORDINATED_SET_SIZE_CHARACTERISTIC        = UUID.from_16_bits(0x2B85, 'Coordinated Set Size')
+GATT_SET_MEMBER_LOCK_CHARACTERISTIC             = UUID.from_16_bits(0x2B86, 'Set Member Lock')
+GATT_SET_MEMBER_RANK_CHARACTERISTIC             = UUID.from_16_bits(0x2B87, 'Set Member Rank')
+
+# Media Control Service (MCS)
+GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC                     = UUID.from_16_bits(0x2B93, 'Media Player Name')
+GATT_MEDIA_PLAYER_ICON_OBJECT_ID_CHARACTERISTIC           = UUID.from_16_bits(0x2B94, 'Media Player Icon Object ID')
+GATT_MEDIA_PLAYER_ICON_URL_CHARACTERISTIC                 = UUID.from_16_bits(0x2B95, 'Media Player Icon URL')
+GATT_TRACK_CHANGED_CHARACTERISTIC                         = UUID.from_16_bits(0x2B96, 'Track Changed')
+GATT_TRACK_TITLE_CHARACTERISTIC                           = UUID.from_16_bits(0x2B97, 'Track Title')
+GATT_TRACK_DURATION_CHARACTERISTIC                        = UUID.from_16_bits(0x2B98, 'Track Duration')
+GATT_TRACK_POSITION_CHARACTERISTIC                        = UUID.from_16_bits(0x2B99, 'Track Position')
+GATT_PLAYBACK_SPEED_CHARACTERISTIC                        = UUID.from_16_bits(0x2B9A, 'Playback Speed')
+GATT_SEEKING_SPEED_CHARACTERISTIC                         = UUID.from_16_bits(0x2B9B, 'Seeking Speed')
+GATT_CURRENT_TRACK_SEGMENTS_OBJECT_ID_CHARACTERISTIC      = UUID.from_16_bits(0x2B9C, 'Current Track Segments Object ID')
+GATT_CURRENT_TRACK_OBJECT_ID_CHARACTERISTIC               = UUID.from_16_bits(0x2B9D, 'Current Track Object ID')
+GATT_NEXT_TRACK_OBJECT_ID_CHARACTERISTIC                  = UUID.from_16_bits(0x2B9E, 'Next Track Object ID')
+GATT_PARENT_GROUP_OBJECT_ID_CHARACTERISTIC                = UUID.from_16_bits(0x2B9F, 'Parent Group Object ID')
+GATT_CURRENT_GROUP_OBJECT_ID_CHARACTERISTIC               = UUID.from_16_bits(0x2BA0, 'Current Group Object ID')
+GATT_PLAYING_ORDER_CHARACTERISTIC                         = UUID.from_16_bits(0x2BA1, 'Playing Order')
+GATT_PLAYING_ORDERS_SUPPORTED_CHARACTERISTIC              = UUID.from_16_bits(0x2BA2, 'Playing Orders Supported')
+GATT_MEDIA_STATE_CHARACTERISTIC                           = UUID.from_16_bits(0x2BA3, 'Media State')
+GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC                   = UUID.from_16_bits(0x2BA4, 'Media Control Point')
+GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC = UUID.from_16_bits(0x2BA5, 'Media Control Point Opcodes Supported')
+GATT_SEARCH_RESULTS_OBJECT_ID_CHARACTERISTIC              = UUID.from_16_bits(0x2BA6, 'Search Results Object ID')
+GATT_SEARCH_CONTROL_POINT_CHARACTERISTIC                  = UUID.from_16_bits(0x2BA7, 'Search Control Point')
+GATT_CONTENT_CONTROL_ID_CHARACTERISTIC                    = UUID.from_16_bits(0x2BBA, 'Content Control Id')
+
+# Telephone Bearer Service (TBS)
+GATT_BEARER_PROVIDER_NAME_CHARACTERISTIC                      = UUID.from_16_bits(0x2BB4, 'Bearer Provider Name')
+GATT_BEARER_UCI_CHARACTERISTIC                                = UUID.from_16_bits(0x2BB5, 'Bearer UCI')
+GATT_BEARER_TECHNOLOGY_CHARACTERISTIC                         = UUID.from_16_bits(0x2BB6, 'Bearer Technology')
+GATT_BEARER_URI_SCHEMES_SUPPORTED_LIST_CHARACTERISTIC         = UUID.from_16_bits(0x2BB7, 'Bearer URI Schemes Supported List')
+GATT_BEARER_SIGNAL_STRENGTH_CHARACTERISTIC                    = UUID.from_16_bits(0x2BB8, 'Bearer Signal Strength')
+GATT_BEARER_SIGNAL_STRENGTH_REPORTING_INTERVAL_CHARACTERISTIC = UUID.from_16_bits(0x2BB9, 'Bearer Signal Strength Reporting Interval')
+GATT_BEARER_LIST_CURRENT_CALLS_CHARACTERISTIC                 = UUID.from_16_bits(0x2BBA, 'Bearer List Current Calls')
+GATT_CONTENT_CONTROL_ID_CHARACTERISTIC                        = UUID.from_16_bits(0x2BBB, 'Content Control ID')
+GATT_STATUS_FLAGS_CHARACTERISTIC                              = UUID.from_16_bits(0x2BBC, 'Status Flags')
+GATT_INCOMING_CALL_TARGET_BEARER_URI_CHARACTERISTIC           = UUID.from_16_bits(0x2BBD, 'Incoming Call Target Bearer URI')
+GATT_CALL_STATE_CHARACTERISTIC                                = UUID.from_16_bits(0x2BBE, 'Call State')
+GATT_CALL_CONTROL_POINT_CHARACTERISTIC                        = UUID.from_16_bits(0x2BBF, 'Call Control Point')
+GATT_CALL_CONTROL_POINT_OPTIONAL_OPCODES_CHARACTERISTIC       = UUID.from_16_bits(0x2BC0, 'Call Control Point Optional Opcodes')
+GATT_TERMINATION_REASON_CHARACTERISTIC                        = UUID.from_16_bits(0x2BC1, 'Termination Reason')
+GATT_INCOMING_CALL_CHARACTERISTIC                             = UUID.from_16_bits(0x2BC2, 'Incoming Call')
+GATT_CALL_FRIENDLY_NAME_CHARACTERISTIC                        = UUID.from_16_bits(0x2BC3, 'Call Friendly Name')
+
+# Microphone Control Service (MICS)
+GATT_MUTE_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Mute')
+
+# Audio Stream Control Service (ASCS)
+GATT_SINK_ASE_CHARACTERISTIC                    = UUID.from_16_bits(0x2BC4, 'Sink ASE')
+GATT_SOURCE_ASE_CHARACTERISTIC                  = UUID.from_16_bits(0x2BC5, 'Source ASE')
+GATT_ASE_CONTROL_POINT_CHARACTERISTIC           = UUID.from_16_bits(0x2BC6, 'ASE Control Point')
+
+# Broadcast Audio Scan Service (BASS)
+GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC7, 'Broadcast Audio Scan Control Point')
+GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC            = UUID.from_16_bits(0x2BC8, 'Broadcast Receive State')
+
+# Published Audio Capabilities Service (PACS)
+GATT_SINK_PAC_CHARACTERISTIC                    = UUID.from_16_bits(0x2BC9, 'Sink PAC')
+GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC         = UUID.from_16_bits(0x2BCA, 'Sink Audio Location')
+GATT_SOURCE_PAC_CHARACTERISTIC                  = UUID.from_16_bits(0x2BCB, 'Source PAC')
+GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC       = UUID.from_16_bits(0x2BCC, 'Source Audio Location')
+GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC    = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
+GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC    = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')
+
 # ASHA Service
 GATT_ASHA_SERVICE                             = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
 GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
@@ -177,6 +296,9 @@
 GATT_CURRENT_TIME_CHARACTERISTIC                               = UUID.from_16_bits(0x2A2B, 'Current Time')
 GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC                = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report')
 GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC                = UUID.from_16_bits(0x2AA6, 'Central Address Resolution')
+GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC                  = UUID.from_16_bits(0x2B29, 'Client Supported Features')
+GATT_DATABASE_HASH_CHARACTERISTIC                              = UUID.from_16_bits(0x2B2A, 'Database Hash')
+GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC                  = UUID.from_16_bits(0x2B3A, 'Server Supported Features')
 
 # fmt: on
 # pylint: enable=line-too-long
@@ -258,9 +380,12 @@
     UUID: UUID
 
     def __init__(
-        self, characteristics: List[Characteristic], primary: bool = True
+        self,
+        characteristics: List[Characteristic],
+        primary: bool = True,
+        included_services: List[Service] = [],
     ) -> None:
-        super().__init__(self.UUID, characteristics, primary)
+        super().__init__(self.UUID, characteristics, primary, included_services)
 
 
 # -----------------------------------------------------------------------------
@@ -409,56 +534,43 @@
 
 
 # -----------------------------------------------------------------------------
-class CharacteristicValue:
-    '''
-    Characteristic value where reading and/or writing is delegated to functions
-    passed as arguments to the constructor.
-    '''
-
-    def __init__(self, read=None, write=None):
-        self._read = read
-        self._write = write
-
-    def read(self, connection):
-        return self._read(connection) if self._read else b''
-
-    def write(self, connection, value):
-        if self._write:
-            self._write(connection, value)
+class CharacteristicValue(AttributeValue):
+    """Same as AttributeValue, for backward compatibility"""
 
 
 # -----------------------------------------------------------------------------
 class CharacteristicAdapter:
     '''
-    An adapter that can adapt any object with `read_value` and `write_value`
-    methods (like Characteristic and CharacteristicProxy objects) by wrapping
-    those methods with ones that return/accept encoded/decoded values.
-    Objects with async methods are considered proxies, so the adaptation is one
-    where the return value of `read_value` is decoded and the value passed to
-    `write_value` is encoded. Other objects are considered local characteristics
-    so the adaptation is one where the return value of `read_value` is encoded
-    and the value passed to `write_value` is decoded.
-    If the characteristic has a `subscribe` method, it is wrapped with one where
-    the values are decoded before being passed to the subscriber.
+    An adapter that can adapt Characteristic and AttributeProxy objects
+    by wrapping their `read_value()` and `write_value()` methods with ones that
+    return/accept encoded/decoded values.
+
+    For proxies (i.e used by a GATT client), the adaptation is one where the return
+    value of `read_value()` is decoded and the value passed to `write_value()` is
+    encoded. The `subscribe()` method, is wrapped with one where the values are decoded
+    before being passed to the subscriber.
+
+    For local values (i.e hosted by a GATT server) the adaptation is one where the
+    return value of `read_value()` is encoded and the value passed to `write_value()`
+    is decoded.
     '''
 
-    def __init__(self, characteristic):
-        self.wrapped_characteristic = characteristic
-        self.subscribers = {}  # Map from subscriber to proxy subscriber
+    read_value: Callable
+    write_value: Callable
 
-        if asyncio.iscoroutinefunction(
-            characteristic.read_value
-        ) and asyncio.iscoroutinefunction(characteristic.write_value):
-            self.read_value = self.read_decoded_value
-            self.write_value = self.write_decoded_value
-        else:
+    def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
+        self.wrapped_characteristic = characteristic
+        self.subscribers: Dict[
+            Callable, Callable
+        ] = {}  # Map from subscriber to proxy subscriber
+
+        if isinstance(characteristic, Characteristic):
             self.read_value = self.read_encoded_value
             self.write_value = self.write_encoded_value
-
-        if hasattr(self.wrapped_characteristic, 'subscribe'):
+        else:
+            self.read_value = self.read_decoded_value
+            self.write_value = self.write_decoded_value
             self.subscribe = self.wrapped_subscribe
-
-        if hasattr(self.wrapped_characteristic, 'unsubscribe'):
             self.unsubscribe = self.wrapped_unsubscribe
 
     def __getattr__(self, name):
@@ -477,11 +589,13 @@
         else:
             setattr(self.wrapped_characteristic, name, value)
 
-    def read_encoded_value(self, connection):
-        return self.encode_value(self.wrapped_characteristic.read_value(connection))
+    async def read_encoded_value(self, connection):
+        return self.encode_value(
+            await self.wrapped_characteristic.read_value(connection)
+        )
 
-    def write_encoded_value(self, connection, value):
-        return self.wrapped_characteristic.write_value(
+    async def write_encoded_value(self, connection, value):
+        return await self.wrapped_characteristic.write_value(
             connection, self.decode_value(value)
         )
 
@@ -616,13 +730,24 @@
     '''
 
     def __str__(self) -> str:
+        if isinstance(self.value, bytes):
+            value_str = self.value.hex()
+        elif isinstance(self.value, CharacteristicValue):
+            value = self.value.read(None)
+            if isinstance(value, bytes):
+                value_str = value.hex()
+            else:
+                value_str = '<async>'
+        else:
+            value_str = '<...>'
         return (
             f'Descriptor(handle=0x{self.handle:04X}, '
             f'type={self.type}, '
-            f'value={self.read_value(None).hex()})'
+            f'value={value_str})'
         )
 
 
+# -----------------------------------------------------------------------------
 class ClientCharacteristicConfigurationBits(enum.IntFlag):
     '''
     See Vol 3, Part G - 3.3.3.3 - Table 3.11 Client Characteristic Configuration bit
diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py
index e3b8bb2..2079a65 100644
--- a/bumble/gatt_client.py
+++ b/bumble/gatt_client.py
@@ -38,6 +38,7 @@
     Any,
     Iterable,
     Type,
+    Set,
     TYPE_CHECKING,
 )
 
@@ -128,7 +129,7 @@
     included_services: List[ServiceProxy]
 
     @staticmethod
-    def from_client(service_class, client, service_uuid):
+    def from_client(service_class, client: Client, service_uuid: UUID):
         # The service and its characteristics are considered to have already been
         # discovered
         services = client.get_services_by_uuid(service_uuid)
@@ -206,11 +207,11 @@
 
         return await self.client.subscribe(self, subscriber, prefer_notify)
 
-    async def unsubscribe(self, subscriber=None):
+    async def unsubscribe(self, subscriber=None, force=False):
         if subscriber in self.subscribers:
             subscriber = self.subscribers.pop(subscriber)
 
-        return await self.client.unsubscribe(self, subscriber)
+        return await self.client.unsubscribe(self, subscriber, force)
 
     def __str__(self) -> str:
         return (
@@ -246,8 +247,12 @@
 class Client:
     services: List[ServiceProxy]
     cached_values: Dict[int, Tuple[datetime, bytes]]
-    notification_subscribers: Dict[int, Callable[[bytes], Any]]
-    indication_subscribers: Dict[int, Callable[[bytes], Any]]
+    notification_subscribers: Dict[
+        int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
+    ]
+    indication_subscribers: Dict[
+        int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
+    ]
     pending_response: Optional[asyncio.futures.Future[ATT_PDU]]
     pending_request: Optional[ATT_PDU]
 
@@ -257,10 +262,8 @@
         self.request_semaphore = asyncio.Semaphore(1)
         self.pending_request = None
         self.pending_response = None
-        self.notification_subscribers = (
-            {}
-        )  # Notification subscribers, by attribute handle
-        self.indication_subscribers = {}  # Indication subscribers, by attribute handle
+        self.notification_subscribers = {}  # Subscriber set, by attribute handle
+        self.indication_subscribers = {}  # Subscriber set, by attribute handle
         self.services = []
         self.cached_values = {}
 
@@ -682,8 +685,8 @@
     async def discover_descriptors(
         self,
         characteristic: Optional[CharacteristicProxy] = None,
-        start_handle=None,
-        end_handle=None,
+        start_handle: Optional[int] = None,
+        end_handle: Optional[int] = None,
     ) -> List[DescriptorProxy]:
         '''
         See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
@@ -789,7 +792,12 @@
 
         return attributes
 
-    async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
+    async def subscribe(
+        self,
+        characteristic: CharacteristicProxy,
+        subscriber: Optional[Callable[[bytes], Any]] = None,
+        prefer_notify: bool = True,
+    ) -> None:
         # If we haven't already discovered the descriptors for this characteristic,
         # do it now
         if not characteristic.descriptors_discovered:
@@ -826,6 +834,7 @@
         subscriber_set = subscribers.setdefault(characteristic.handle, set())
         if subscriber is not None:
             subscriber_set.add(subscriber)
+
         # Add the characteristic as a subscriber, which will result in the
         # characteristic emitting an 'update' event when a notification or indication
         # is received
@@ -833,7 +842,18 @@
 
         await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
 
-    async def unsubscribe(self, characteristic, subscriber=None):
+    async def unsubscribe(
+        self,
+        characteristic: CharacteristicProxy,
+        subscriber: Optional[Callable[[bytes], Any]] = None,
+        force: bool = False,
+    ) -> None:
+        '''
+        Unsubscribe from a characteristic.
+
+        If `force` is True, this will write zeros to the CCCD when there are no
+        subscribers left, even if there were already no registered subscribers.
+        '''
         # If we haven't already discovered the descriptors for this characteristic,
         # do it now
         if not characteristic.descriptors_discovered:
@@ -847,31 +867,45 @@
             logger.warning('unsubscribing from characteristic with no CCCD descriptor')
             return
 
+        # Check if the characteristic has subscribers
+        if not (
+            characteristic.handle in self.notification_subscribers
+            or characteristic.handle in self.indication_subscribers
+        ):
+            if not force:
+                return
+
+        # Remove the subscriber(s)
         if subscriber is not None:
             # Remove matching subscriber from subscriber sets
             for subscriber_set in (
                 self.notification_subscribers,
                 self.indication_subscribers,
             ):
-                subscribers = subscriber_set.get(characteristic.handle, [])
-                if subscriber in subscribers:
+                if (
+                    subscribers := subscriber_set.get(characteristic.handle)
+                ) and subscriber in subscribers:
                     subscribers.remove(subscriber)
 
                     # Cleanup if we removed the last one
                     if not subscribers:
                         del subscriber_set[characteristic.handle]
         else:
-            # Remove all subscribers for this attribute from the sets!
+            # Remove all subscribers for this attribute from the sets
             self.notification_subscribers.pop(characteristic.handle, None)
             self.indication_subscribers.pop(characteristic.handle, None)
 
-        if not self.notification_subscribers and not self.indication_subscribers:
+        # Update the CCCD
+        if not (
+            characteristic.handle in self.notification_subscribers
+            or characteristic.handle in self.indication_subscribers
+        ):
             # No more subscribers left
             await self.write_value(cccd, b'\x00\x00', with_response=True)
 
     async def read_value(
         self, attribute: Union[int, AttributeProxy], no_long_read: bool = False
-    ) -> Any:
+    ) -> bytes:
         '''
         See Vol 3, Part G - 4.8.1 Read Characteristic Value
 
@@ -1034,7 +1068,7 @@
                 logger.warning('!!! unexpected response, there is no pending request')
                 return
 
-            # Sanity check: the response should match the pending request unless it is
+            # The response should match the pending request unless it is
             # an error response
             if att_pdu.op_code != ATT_ERROR_RESPONSE:
                 expected_response_name = self.pending_request.name.replace(
@@ -1067,7 +1101,7 @@
     def on_att_handle_value_notification(self, notification):
         # Call all subscribers
         subscribers = self.notification_subscribers.get(
-            notification.attribute_handle, []
+            notification.attribute_handle, set()
         )
         if not subscribers:
             logger.warning('!!! received notification with no subscriber')
@@ -1081,7 +1115,9 @@
 
     def on_att_handle_value_indication(self, indication):
         # Call all subscribers
-        subscribers = self.indication_subscribers.get(indication.attribute_handle, [])
+        subscribers = self.indication_subscribers.get(
+            indication.attribute_handle, set()
+        )
         if not subscribers:
             logger.warning('!!! received indication with no subscriber')
 
diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py
index cdf1b5e..3be4185 100644
--- a/bumble/gatt_server.py
+++ b/bumble/gatt_server.py
@@ -31,9 +31,9 @@
 from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
 from pyee import EventEmitter
 
-from .colors import color
-from .core import UUID
-from .att import (
+from bumble.colors import color
+from bumble.core import UUID
+from bumble.att import (
     ATT_ATTRIBUTE_NOT_FOUND_ERROR,
     ATT_ATTRIBUTE_NOT_LONG_ERROR,
     ATT_CID,
@@ -60,7 +60,7 @@
     ATT_Write_Response,
     Attribute,
 )
-from .gatt import (
+from bumble.gatt import (
     GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
     GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
     GATT_MAX_ATTRIBUTE_VALUE_SIZE,
@@ -74,6 +74,7 @@
     Descriptor,
     Service,
 )
+from bumble.utils import AsyncRunner
 
 if TYPE_CHECKING:
     from bumble.device import Device, Connection
@@ -327,7 +328,7 @@
             f'handle=0x{characteristic.handle:04X}: {value.hex()}'
         )
 
-        # Sanity check
+        # Check parameters
         if len(value) != 2:
             logger.warning('CCCD value not 2 bytes long')
             return
@@ -379,7 +380,7 @@
 
         # Get or encode the value
         value = (
-            attribute.read_value(connection)
+            await attribute.read_value(connection)
             if value is None
             else attribute.encode_value(value)
         )
@@ -422,7 +423,7 @@
 
         # Get or encode the value
         value = (
-            attribute.read_value(connection)
+            await attribute.read_value(connection)
             if value is None
             else attribute.encode_value(value)
         )
@@ -650,7 +651,8 @@
 
         self.send_response(connection, response)
 
-    def on_att_find_by_type_value_request(self, connection, request):
+    @AsyncRunner.run_in_task()
+    async def on_att_find_by_type_value_request(self, connection, request):
         '''
         See Bluetooth spec Vol 3, Part F - 3.4.3.3 Find By Type Value Request
         '''
@@ -658,13 +660,13 @@
         # Build list of returned attributes
         pdu_space_available = connection.att_mtu - 2
         attributes = []
-        for attribute in (
+        async for attribute in (
             attribute
             for attribute in self.attributes
             if attribute.handle >= request.starting_handle
             and attribute.handle <= request.ending_handle
             and attribute.type == request.attribute_type
-            and attribute.read_value(connection) == request.attribute_value
+            and (await attribute.read_value(connection)) == request.attribute_value
             and pdu_space_available >= 4
         ):
             # TODO: check permissions
@@ -702,7 +704,8 @@
 
         self.send_response(connection, response)
 
-    def on_att_read_by_type_request(self, connection, request):
+    @AsyncRunner.run_in_task()
+    async def on_att_read_by_type_request(self, connection, request):
         '''
         See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
         '''
@@ -725,7 +728,7 @@
             and pdu_space_available
         ):
             try:
-                attribute_value = attribute.read_value(connection)
+                attribute_value = await attribute.read_value(connection)
             except ATT_Error as error:
                 # If the first attribute is unreadable, return an error
                 # Otherwise return attributes up to this point
@@ -767,14 +770,15 @@
 
         self.send_response(connection, response)
 
-    def on_att_read_request(self, connection, request):
+    @AsyncRunner.run_in_task()
+    async def on_att_read_request(self, connection, request):
         '''
         See Bluetooth spec Vol 3, Part F - 3.4.4.3 Read Request
         '''
 
         if attribute := self.get_attribute(request.attribute_handle):
             try:
-                value = attribute.read_value(connection)
+                value = await attribute.read_value(connection)
             except ATT_Error as error:
                 response = ATT_Error_Response(
                     request_opcode_in_error=request.op_code,
@@ -792,14 +796,15 @@
             )
         self.send_response(connection, response)
 
-    def on_att_read_blob_request(self, connection, request):
+    @AsyncRunner.run_in_task()
+    async def on_att_read_blob_request(self, connection, request):
         '''
         See Bluetooth spec Vol 3, Part F - 3.4.4.5 Read Blob Request
         '''
 
         if attribute := self.get_attribute(request.attribute_handle):
             try:
-                value = attribute.read_value(connection)
+                value = await attribute.read_value(connection)
             except ATT_Error as error:
                 response = ATT_Error_Response(
                     request_opcode_in_error=request.op_code,
@@ -836,7 +841,8 @@
             )
         self.send_response(connection, response)
 
-    def on_att_read_by_group_type_request(self, connection, request):
+    @AsyncRunner.run_in_task()
+    async def on_att_read_by_group_type_request(self, connection, request):
         '''
         See Bluetooth spec Vol 3, Part F - 3.4.4.9 Read by Group Type Request
         '''
@@ -864,7 +870,7 @@
         ):
             # No need to catch permission errors here, since these attributes
             # must all be world-readable
-            attribute_value = attribute.read_value(connection)
+            attribute_value = await attribute.read_value(connection)
             # Check the attribute value size
             max_attribute_size = min(connection.att_mtu - 6, 251)
             if len(attribute_value) > max_attribute_size:
@@ -903,7 +909,8 @@
 
         self.send_response(connection, response)
 
-    def on_att_write_request(self, connection, request):
+    @AsyncRunner.run_in_task()
+    async def on_att_write_request(self, connection, request):
         '''
         See Bluetooth spec Vol 3, Part F - 3.4.5.1 Write Request
         '''
@@ -936,12 +943,13 @@
             return
 
         # Accept the value
-        attribute.write_value(connection, request.attribute_value)
+        await attribute.write_value(connection, request.attribute_value)
 
         # Done
         self.send_response(connection, ATT_Write_Response())
 
-    def on_att_write_command(self, connection, request):
+    @AsyncRunner.run_in_task()
+    async def on_att_write_command(self, connection, request):
         '''
         See Bluetooth spec Vol 3, Part F - 3.4.5.3 Write Command
         '''
@@ -959,9 +967,9 @@
 
         # Accept the value
         try:
-            attribute.write_value(connection, request.attribute_value)
+            await attribute.write_value(connection, request.attribute_value)
         except Exception as error:
-            logger.warning(f'!!! ignoring exception: {error}')
+            logger.exception(f'!!! ignoring exception: {error}')
 
     def on_att_handle_value_confirmation(self, connection, _confirmation):
         '''
diff --git a/bumble/hci.py b/bumble/hci.py
index 057c04a..013a2d3 100644
--- a/bumble/hci.py
+++ b/bumble/hci.py
@@ -17,11 +17,15 @@
 # -----------------------------------------------------------------------------
 from __future__ import annotations
 import collections
+import dataclasses
+import enum
 import functools
 import logging
+import secrets
 import struct
-from typing import Any, Dict, Callable, Optional, Type, Union
+from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union
 
+from bumble import crypto
 from .colors import color
 from .core import (
     BT_BR_EDR_TRANSPORT,
@@ -148,6 +152,7 @@
 HCI_ACL_DATA_PACKET         = 0x02
 HCI_SYNCHRONOUS_DATA_PACKET = 0x03
 HCI_EVENT_PACKET            = 0x04
+HCI_ISO_DATA_PACKET         = 0x05
 
 # HCI Event Codes
 HCI_INQUIRY_COMPLETE_EVENT                                       = 0x01
@@ -218,41 +223,47 @@
 
 
 # HCI Subevent Codes
-HCI_LE_CONNECTION_COMPLETE_EVENT                         = 0x01
-HCI_LE_ADVERTISING_REPORT_EVENT                          = 0x02
-HCI_LE_CONNECTION_UPDATE_COMPLETE_EVENT                  = 0x03
-HCI_LE_READ_REMOTE_FEATURES_COMPLETE_EVENT               = 0x04
-HCI_LE_LONG_TERM_KEY_REQUEST_EVENT                       = 0x05
-HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_EVENT         = 0x06
-HCI_LE_DATA_LENGTH_CHANGE_EVENT                          = 0x07
-HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT        = 0x08
-HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT                     = 0x09
-HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT                = 0x0A
-HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT                 = 0x0B
-HCI_LE_PHY_UPDATE_COMPLETE_EVENT                         = 0x0C
-HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT                 = 0x0D
-HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT       = 0x0E
-HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT                 = 0x0F
-HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT              = 0x10
-HCI_LE_SCAN_TIMEOUT_EVENT                                = 0x11
-HCI_LE_ADVERTISING_SET_TERMINATED_EVENT                  = 0x12
-HCI_LE_SCAN_REQUEST_RECEIVED_EVENT                       = 0x13
-HCI_LE_CHANNEL_SELECTION_ALGORITHM_EVENT                 = 0x14
-HCI_LE_CONNECTIONLESS_IQ_REPORT_EVENT                    = 0X15
-HCI_LE_CONNECTION_IQ_REPORT_EVENT                        = 0X16
-HCI_LE_CTE_REQUEST_FAILED_EVENT                          = 0X17
-HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_EVENT = 0X18
-HCI_LE_CIS_ESTABLISHED_EVENT                             = 0X19
-HCI_LE_CIS_REQUEST_EVENT                                 = 0X1A
-HCI_LE_CREATE_BIG_COMPLETE_EVENT                         = 0X1B
-HCI_LE_TERMINATE_BIG_COMPLETE_EVENT                      = 0X1C
-HCI_LE_BIG_SYNC_ESTABLISHED_EVENT                        = 0X1D
-HCI_LE_BIG_SYNC_LOST_EVENT                               = 0X1E
-HCI_LE_REQUEST_PEER_SCA_COMPLETE_EVENT                   = 0X1F
-HCI_LE_PATH_LOSS_THRESHOLD_EVENT                         = 0X20
-HCI_LE_TRANSMIT_POWER_REPORTING_EVENT                    = 0X21
-HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT                  = 0X22
-HCI_LE_SUBRATE_CHANGE_EVENT                              = 0X23
+HCI_LE_CONNECTION_COMPLETE_EVENT                            = 0x01
+HCI_LE_ADVERTISING_REPORT_EVENT                             = 0x02
+HCI_LE_CONNECTION_UPDATE_COMPLETE_EVENT                     = 0x03
+HCI_LE_READ_REMOTE_FEATURES_COMPLETE_EVENT                  = 0x04
+HCI_LE_LONG_TERM_KEY_REQUEST_EVENT                          = 0x05
+HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_EVENT            = 0x06
+HCI_LE_DATA_LENGTH_CHANGE_EVENT                             = 0x07
+HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT           = 0x08
+HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT                        = 0x09
+HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT                   = 0x0A
+HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT                    = 0x0B
+HCI_LE_PHY_UPDATE_COMPLETE_EVENT                            = 0x0C
+HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT                    = 0x0D
+HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT          = 0x0E
+HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT                    = 0x0F
+HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT                 = 0x10
+HCI_LE_SCAN_TIMEOUT_EVENT                                   = 0x11
+HCI_LE_ADVERTISING_SET_TERMINATED_EVENT                     = 0x12
+HCI_LE_SCAN_REQUEST_RECEIVED_EVENT                          = 0x13
+HCI_LE_CHANNEL_SELECTION_ALGORITHM_EVENT                    = 0x14
+HCI_LE_CONNECTIONLESS_IQ_REPORT_EVENT                       = 0X15
+HCI_LE_CONNECTION_IQ_REPORT_EVENT                           = 0X16
+HCI_LE_CTE_REQUEST_FAILED_EVENT                             = 0X17
+HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_EVENT    = 0X18
+HCI_LE_CIS_ESTABLISHED_EVENT                                = 0X19
+HCI_LE_CIS_REQUEST_EVENT                                    = 0X1A
+HCI_LE_CREATE_BIG_COMPLETE_EVENT                            = 0X1B
+HCI_LE_TERMINATE_BIG_COMPLETE_EVENT                         = 0X1C
+HCI_LE_BIG_SYNC_ESTABLISHED_EVENT                           = 0X1D
+HCI_LE_BIG_SYNC_LOST_EVENT                                  = 0X1E
+HCI_LE_REQUEST_PEER_SCA_COMPLETE_EVENT                      = 0X1F
+HCI_LE_PATH_LOSS_THRESHOLD_EVENT                            = 0X20
+HCI_LE_TRANSMIT_POWER_REPORTING_EVENT                       = 0X21
+HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT                     = 0X22
+HCI_LE_SUBRATE_CHANGE_EVENT                                 = 0X23
+HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_V2_EVENT       = 0X24
+HCI_LE_PERIODIC_ADVERTISING_REPORT_V2_EVENT                 = 0X25
+HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_V2_EVENT = 0X26
+HCI_LE_PERIODIC_ADVERTISING_SUBEVENT_DATA_REQUEST_EVENT     = 0X27
+HCI_LE_PERIODIC_ADVERTISING_RESPONSE_REPORT_EVENT           = 0X28
+HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT                = 0X29
 
 
 # HCI Command
@@ -558,6 +569,12 @@
 HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND                          = hci_command_op_code(0x08, 0x007C)
 HCI_LE_SET_DEFAULT_SUBRATE_COMMAND                                       = hci_command_op_code(0x08, 0x007D)
 HCI_LE_SUBRATE_REQUEST_COMMAND                                           = hci_command_op_code(0x08, 0x007E)
+HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND                    = hci_command_op_code(0x08, 0x007F)
+HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND                    = hci_command_op_code(0x08, 0x0082)
+HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND                    = hci_command_op_code(0x08, 0x0083)
+HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND                                = hci_command_op_code(0x08, 0x0084)
+HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND                             = hci_command_op_code(0x08, 0x0085)
+HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND                    = hci_command_op_code(0x08, 0x0086)
 
 
 # HCI Error Codes
@@ -639,47 +656,6 @@
 # Command Status codes
 HCI_COMMAND_STATUS_PENDING = 0
 
-# LE Event Masks
-HCI_LE_CONNECTION_COMPLETE_EVENT_MASK                         = (1 << 0)
-HCI_LE_ADVERTISING_REPORT_EVENT_MASK                          = (1 << 1)
-HCI_LE_CONNECTION_UPDATE_COMPLETE_EVENT_MASK                  = (1 << 2)
-HCI_LE_READ_REMOTE_FEATURES_COMPLETE_EVENT_MASK               = (1 << 3)
-HCI_LE_LONG_TERM_KEY_REQUEST_EVENT_MASK                       = (1 << 4)
-HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_EVENT_MASK         = (1 << 5)
-HCI_LE_DATA_LENGTH_CHANGE_EVENT_MASK                          = (1 << 6)
-HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT_MASK        = (1 << 7)
-HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT_MASK                     = (1 << 8)
-HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT_MASK                = (1 << 9)
-HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT_MASK                 = (1 << 10)
-HCI_LE_PHY_UPDATE_COMPLETE_EVENT_MASK                         = (1 << 11)
-HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT_MASK                 = (1 << 12)
-HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT_MASK       = (1 << 13)
-HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT_MASK                 = (1 << 14)
-HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT_MASK              = (1 << 15)
-HCI_LE_EXTENDED_SCAN_TIMEOUT_EVENT_MASK                       = (1 << 16)
-HCI_LE_EXTENDED_ADVERTISING_SET_TERMINATED_EVENT_MASK         = (1 << 17)
-HCI_LE_SCAN_REQUEST_RECEIVED_EVENT_MASK                       = (1 << 18)
-HCI_LE_CHANNEL_SELECTION_ALGORITHM_EVENT_MASK                 = (1 << 19)
-HCI_LE_CONNECTIONLESS_IQ_REPORT_EVENT_MASK                    = (1 << 20)
-HCI_LE_CONNECTION_IQ_REPORT_EVENT_MASK                        = (1 << 21)
-HCI_LE_CTE_REQUEST_FAILED_EVENT_MASK                          = (1 << 22)
-HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_EVENT_MASK = (1 << 23)
-HCI_LE_CIS_ESTABLISHED_EVENT_MASK                             = (1 << 24)
-HCI_LE_CIS_REQUEST_EVENT_MASK                                 = (1 << 25)
-HCI_LE_CREATE_BIG_COMPLETE_EVENT_MASK                         = (1 << 26)
-HCI_LE_TERMINATE_BIG_COMPLETE_EVENT_MASK                      = (1 << 27)
-HCI_LE_BIG_SYNC_ESTABLISHED_EVENT_MASK                        = (1 << 28)
-HCI_LE_BIG_SYNC_LOST_EVENT_MASK                               = (1 << 29)
-HCI_LE_REQUEST_PEER_SCA_COMPLETE_EVENT_MASK                   = (1 << 30)
-HCI_LE_PATH_LOSS_THRESHOLD_EVENT_MASK                         = (1 << 31)
-HCI_LE_TRANSMIT_POWER_REPORTING_EVENT_MASK                    = (1 << 32)
-HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT_MASK                  = (1 << 33)
-HCI_LE_SUBRATE_CHANGE_EVENT_MASK                              = (1 << 34)
-
-HCI_LE_EVENT_MASK_NAMES = {
-    mask: mask_name for (mask_name, mask) in globals().items()
-    if mask_name.startswith('HCI_LE_') and mask_name.endswith('_EVENT_MASK')
-}
 
 # ACL
 HCI_ACL_PB_FIRST_NON_FLUSHABLE = 0
@@ -719,6 +695,19 @@
     HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_BIT
 }
 
+
+class Phy(enum.IntEnum):
+    LE_1M    = HCI_LE_1M_PHY
+    LE_2M    = HCI_LE_2M_PHY
+    LE_CODED = HCI_LE_CODED_PHY
+
+
+class PhyBit(enum.IntFlag):
+    LE_1M    = 1 << HCI_LE_1M_PHY_BIT
+    LE_2M    = 1 << HCI_LE_2M_PHY_BIT
+    LE_CODED = 1 << HCI_LE_CODED_PHY_BIT
+
+
 # Connection Parameters
 HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25
 HCI_CONNECTION_LATENCY_MS_PER_UNIT  = 1.25
@@ -801,574 +790,586 @@
 HCI_PUBLIC_IDENTITY_ADDRESS_TYPE = 0x02
 HCI_RANDOM_IDENTITY_ADDRESS_TYPE = 0x03
 
-# Supported Commands Flags
+# Supported Commands Masks
 # See Bluetooth spec @ 6.27 SUPPORTED COMMANDS
-HCI_SUPPORTED_COMMANDS_FLAGS = (
-    # Octet 0
-    (
-        HCI_INQUIRY_COMMAND,
-        HCI_INQUIRY_CANCEL_COMMAND,
-        HCI_PERIODIC_INQUIRY_MODE_COMMAND,
-        HCI_EXIT_PERIODIC_INQUIRY_MODE_COMMAND,
-        HCI_CREATE_CONNECTION_COMMAND,
-        HCI_DISCONNECT_COMMAND,
-        None,
-        HCI_CREATE_CONNECTION_CANCEL_COMMAND
-    ),
-    # Octet 1
-    (
-        HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
-        HCI_REJECT_CONNECTION_REQUEST_COMMAND,
-        HCI_LINK_KEY_REQUEST_REPLY_COMMAND,
-        HCI_LINK_KEY_REQUEST_NEGATIVE_REPLY_COMMAND,
-        HCI_PIN_CODE_REQUEST_REPLY_COMMAND,
-        HCI_PIN_CODE_REQUEST_NEGATIVE_REPLY_COMMAND,
-        HCI_CHANGE_CONNECTION_PACKET_TYPE_COMMAND,
-        HCI_AUTHENTICATION_REQUESTED_COMMAND
-    ),
-    # Octet 2
-    (
-        HCI_SET_CONNECTION_ENCRYPTION_COMMAND,
-        HCI_CHANGE_CONNECTION_LINK_KEY_COMMAND,
-        HCI_LINK_KEY_SELECTION_COMMAND,
-        HCI_REMOTE_NAME_REQUEST_COMMAND,
-        HCI_REMOTE_NAME_REQUEST_CANCEL_COMMAND,
-        HCI_READ_REMOTE_SUPPORTED_FEATURES_COMMAND,
-        HCI_READ_REMOTE_EXTENDED_FEATURES_COMMAND,
-        HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND
-    ),
-    # Octet 3
-    (
-        HCI_READ_CLOCK_OFFSET_COMMAND,
-        HCI_READ_LMP_HANDLE_COMMAND,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None
-    ),
-    # Octet 4
-    (
-        None,
-        HCI_HOLD_MODE_COMMAND,
-        HCI_SNIFF_MODE_COMMAND,
-        HCI_EXIT_SNIFF_MODE_COMMAND,
-        None,
-        None,
-        HCI_QOS_SETUP_COMMAND,
-        HCI_ROLE_DISCOVERY_COMMAND
-    ),
-    # Octet 5
-    (
-        HCI_SWITCH_ROLE_COMMAND,
-        HCI_READ_LINK_POLICY_SETTINGS_COMMAND,
-        HCI_WRITE_LINK_POLICY_SETTINGS_COMMAND,
-        HCI_READ_DEFAULT_LINK_POLICY_SETTINGS_COMMAND,
-        HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND,
-        HCI_FLOW_SPECIFICATION_COMMAND,
-        HCI_SET_EVENT_MASK_COMMAND,
-        HCI_RESET_COMMAND
-    ),
-    # Octet 6
-    (
-        HCI_SET_EVENT_FILTER_COMMAND,
-        HCI_FLUSH_COMMAND,
-        HCI_READ_PIN_TYPE_COMMAND,
-        HCI_WRITE_PIN_TYPE_COMMAND,
-        None,
-        HCI_READ_STORED_LINK_KEY_COMMAND,
-        HCI_WRITE_STORED_LINK_KEY_COMMAND,
-        HCI_DELETE_STORED_LINK_KEY_COMMAND
-    ),
-    # Octet 7
-    (
-        HCI_WRITE_LOCAL_NAME_COMMAND,
-        HCI_READ_LOCAL_NAME_COMMAND,
-        HCI_READ_CONNECTION_ACCEPT_TIMEOUT_COMMAND,
-        HCI_WRITE_CONNECTION_ACCEPT_TIMEOUT_COMMAND,
-        HCI_READ_PAGE_TIMEOUT_COMMAND,
-        HCI_WRITE_PAGE_TIMEOUT_COMMAND,
-        HCI_READ_SCAN_ENABLE_COMMAND,
-        HCI_WRITE_SCAN_ENABLE_COMMAND
-    ),
-    # Octet 8
-    (
-        HCI_READ_PAGE_SCAN_ACTIVITY_COMMAND,
-        HCI_WRITE_PAGE_SCAN_ACTIVITY_COMMAND,
-        HCI_READ_INQUIRY_SCAN_ACTIVITY_COMMAND,
-        HCI_WRITE_INQUIRY_SCAN_ACTIVITY_COMMAND,
-        HCI_READ_AUTHENTICATION_ENABLE_COMMAND,
-        HCI_WRITE_AUTHENTICATION_ENABLE_COMMAND,
-        None,
-        None
-    ),
-    # Octet 9
-    (
-        HCI_READ_CLASS_OF_DEVICE_COMMAND,
-        HCI_WRITE_CLASS_OF_DEVICE_COMMAND,
-        HCI_READ_VOICE_SETTING_COMMAND,
-        HCI_WRITE_VOICE_SETTING_COMMAND,
-        HCI_READ_AUTOMATIC_FLUSH_TIMEOUT_COMMAND,
-        HCI_WRITE_AUTOMATIC_FLUSH_TIMEOUT_COMMAND,
-        HCI_READ_NUM_BROADCAST_RETRANSMISSIONS_COMMAND,
-        HCI_WRITE_NUM_BROADCAST_RETRANSMISSIONS_COMMAND
-    ),
-    # Octet 10
-    (
-        HCI_READ_HOLD_MODE_ACTIVITY_COMMAND,
-        HCI_WRITE_HOLD_MODE_ACTIVITY_COMMAND,
-        HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND,
-        HCI_READ_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND,
-        HCI_WRITE_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND,
-        HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND,
-        HCI_HOST_BUFFER_SIZE_COMMAND,
-        HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND
-    ),
-    # Octet 11
-    (
-        HCI_READ_LINK_SUPERVISION_TIMEOUT_COMMAND,
-        HCI_WRITE_LINK_SUPERVISION_TIMEOUT_COMMAND,
-        HCI_READ_NUMBER_OF_SUPPORTED_IAC_COMMAND,
-        HCI_READ_CURRENT_IAC_LAP_COMMAND,
-        HCI_WRITE_CURRENT_IAC_LAP_COMMAND,
-        None,
-        None,
-        None
-    ),
-    # Octet 12
-    (
-        None,
-        HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND,
-        None,
-        None,
-        HCI_READ_INQUIRY_SCAN_TYPE_COMMAND,
-        HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND,
-        HCI_READ_INQUIRY_MODE_COMMAND,
-        HCI_WRITE_INQUIRY_MODE_COMMAND
-    ),
-    # Octet 13
-    (
-        HCI_READ_PAGE_SCAN_TYPE_COMMAND,
-        HCI_WRITE_PAGE_SCAN_TYPE_COMMAND,
-        HCI_READ_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND,
-        HCI_WRITE_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND,
-        None,
-        None,
-        None,
-        None,
-    ),
-    # Octet 14
-    (
-        None,
-        None,
-        None,
-        HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND,
-        None,
-        HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
-        HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND,
-        HCI_READ_BUFFER_SIZE_COMMAND
-    ),
-    # Octet 15
-    (
-        None,
-        HCI_READ_BD_ADDR_COMMAND,
-        HCI_READ_FAILED_CONTACT_COUNTER_COMMAND,
-        HCI_RESET_FAILED_CONTACT_COUNTER_COMMAND,
-        HCI_READ_LINK_QUALITY_COMMAND,
-        HCI_READ_RSSI_COMMAND,
-        HCI_READ_AFH_CHANNEL_MAP_COMMAND,
-        HCI_READ_CLOCK_COMMAND
-    ),
-    # Octet  16
-    (
-        HCI_READ_LOOPBACK_MODE_COMMAND,
-        HCI_WRITE_LOOPBACK_MODE_COMMAND,
-        HCI_ENABLE_DEVICE_UNDER_TEST_MODE_COMMAND,
-        HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND,
-        HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
-        HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
-        None,
-        None,
-    ),
-    # Octet 17
-    (
-        HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND,
-        HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND,
-        HCI_REFRESH_ENCRYPTION_KEY_COMMAND,
-        None,
-        HCI_SNIFF_SUBRATING_COMMAND,
-        HCI_READ_SIMPLE_PAIRING_MODE_COMMAND,
-        HCI_WRITE_SIMPLE_PAIRING_MODE_COMMAND,
-        HCI_READ_LOCAL_OOB_DATA_COMMAND
-    ),
-    # Octet 18
-    (
-        HCI_READ_INQUIRY_RESPONSE_TRANSMIT_POWER_LEVEL_COMMAND,
-        HCI_WRITE_INQUIRY_TRANSMIT_POWER_LEVEL_COMMAND,
-        HCI_READ_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND,
-        HCI_WRITE_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND,
-        None,
-        None,
-        None,
-        HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND
-    ),
-    # Octet 19
-    (
-        HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND,
-        HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND,
-        HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND,
-        HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND,
-        HCI_REMOTE_OOB_DATA_REQUEST_REPLY_COMMAND,
-        HCI_WRITE_SIMPLE_PAIRING_DEBUG_MODE_COMMAND,
-        HCI_ENHANCED_FLUSH_COMMAND,
-        HCI_REMOTE_OOB_DATA_REQUEST_NEGATIVE_REPLY_COMMAND
-    ),
-    # Octet 20
-    (
-        None,
-        None,
-        HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND,
-        HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND,
-        HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND,
-        None,
-        None,
-        None,
-    ),
-    # Octet 21
-    (
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-    ),
-    # Octet 22
-    (
-        None,
-        None,
-        HCI_SET_EVENT_MASK_PAGE_2_COMMAND,
-        None,
-        None,
-        None,
-        None,
-        None,
-    ),
-    # Octet 23
-    (
-        HCI_READ_FLOW_CONTROL_MODE_COMMAND,
-        HCI_WRITE_FLOW_CONTROL_MODE_COMMAND,
-        HCI_READ_DATA_BLOCK_SIZE_COMMAND,
-        None,
-        None,
-        None,
-        None,
-        None,
-    ),
-    # Octet 24
-    (
-        HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND,
-        None,
-        None,
-        None,
-        None,
-        HCI_READ_LE_HOST_SUPPORT_COMMAND,
-        HCI_WRITE_LE_HOST_SUPPORT_COMMAND,
-        None,
-    ),
-    # Octet 25
-    (
-        HCI_LE_SET_EVENT_MASK_COMMAND,
-        HCI_LE_READ_BUFFER_SIZE_COMMAND,
-        HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
-        None,
-        HCI_LE_SET_RANDOM_ADDRESS_COMMAND,
-        HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND,
-        HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND,
-        HCI_LE_SET_ADVERTISING_DATA_COMMAND,
-    ),
-    # Octet 26
-    (
-        HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND,
-        HCI_LE_SET_ADVERTISING_ENABLE_COMMAND,
-        HCI_LE_SET_SCAN_PARAMETERS_COMMAND,
-        HCI_LE_SET_SCAN_ENABLE_COMMAND,
-        HCI_LE_CREATE_CONNECTION_COMMAND,
-        HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND,
-        HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND,
-        HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND
-    ),
-    # Octet 27
-    (
-        HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND,
-        HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND,
-        HCI_LE_CONNECTION_UPDATE_COMMAND,
-        HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND,
-        HCI_LE_READ_CHANNEL_MAP_COMMAND,
-        HCI_LE_READ_REMOTE_FEATURES_COMMAND,
-        HCI_LE_ENCRYPT_COMMAND,
-        HCI_LE_RAND_COMMAND
-    ),
-    # Octet 28
-    (
-        HCI_LE_ENABLE_ENCRYPTION_COMMAND,
-        HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND,
-        HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND,
-        HCI_LE_READ_SUPPORTED_STATES_COMMAND,
-        HCI_LE_RECEIVER_TEST_COMMAND,
-        HCI_LE_TRANSMITTER_TEST_COMMAND,
-        HCI_LE_TEST_END_COMMAND,
-        None,
-    ),
-    # Octet 29
-    (
-        None,
-        None,
-        None,
-        HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND,
-        HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
-        HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND,
-        HCI_SET_MWS_CHANNEL_PARAMETERS_COMMAND,
-        HCI_SET_EXTERNAL_FRAME_CONFIGURATION_COMMAND
-    ),
-    # Octet 30
-    (
-        HCI_SET_MWS_SIGNALING_COMMAND,
-        HCI_SET_MWS_TRANSPORT_LAYER_COMMAND,
-        HCI_SET_MWS_SCAN_FREQUENCY_TABLE_COMMAND,
-        HCI_GET_MWS_TRANSPORT_LAYER_CONFIGURATION_COMMAND,
-        HCI_SET_MWS_PATTERN_CONFIGURATION_COMMAND,
-        HCI_SET_TRIGGERED_CLOCK_CAPTURE_COMMAND,
-        HCI_TRUNCATED_PAGE_COMMAND,
-        HCI_TRUNCATED_PAGE_CANCEL_COMMAND
-    ),
-    # Octet 31
-    (
-        HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_COMMAND,
-        HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVE_COMMAND,
-        HCI_START_SYNCHRONIZATION_TRAIN_COMMAND,
-        HCI_RECEIVE_SYNCHRONIZATION_TRAIN_COMMAND,
-        HCI_SET_RESERVED_LT_ADDR_COMMAND,
-        HCI_DELETE_RESERVED_LT_ADDR_COMMAND,
-        HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_DATA_COMMAND,
-        HCI_READ_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND
-    ),
-    # Octet 32
-    (
-        HCI_WRITE_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND,
-        HCI_REMOTE_OOB_EXTENDED_DATA_REQUEST_REPLY_COMMAND,
-        HCI_READ_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND,
-        HCI_WRITE_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND,
-        HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
-        HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
-        HCI_READ_LOCAL_OOB_EXTENDED_DATA_COMMAND,
-        HCI_WRITE_SECURE_CONNECTIONS_TEST_MODE_COMMAND
-    ),
-    # Octet 33
-    (
-        HCI_READ_EXTENDED_PAGE_TIMEOUT_COMMAND,
-        HCI_WRITE_EXTENDED_PAGE_TIMEOUT_COMMAND,
-        HCI_READ_EXTENDED_INQUIRY_LENGTH_COMMAND,
-        HCI_WRITE_EXTENDED_INQUIRY_LENGTH_COMMAND,
-        HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND,
-        HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND,
-        HCI_LE_SET_DATA_LENGTH_COMMAND,
-        HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
-    ),
-    # Octet 34
-    (
-        HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
-        HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMMAND,
-        HCI_LE_GENERATE_DHKEY_COMMAND,
-        HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND,
-        HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND,
-        HCI_LE_CLEAR_RESOLVING_LIST_COMMAND,
-        HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND,
-        HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND
-    ),
-    # Octet 35
-    (
-        HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND,
-        HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND,
-        HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND,
-        HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
-        HCI_LE_READ_PHY_COMMAND,
-        HCI_LE_SET_DEFAULT_PHY_COMMAND,
-        HCI_LE_SET_PHY_COMMAND,
-        HCI_LE_RECEIVER_TEST_V2_COMMAND
-    ),
-    # Octet 36
-    (
-        HCI_LE_TRANSMITTER_TEST_V2_COMMAND,
-        HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND,
-        HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND,
-        HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND,
-        HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND,
-        HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND,
-        HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
-        HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
-    ),
-    # Octet 37
-    (
-        HCI_LE_REMOVE_ADVERTISING_SET_COMMAND,
-        HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND,
-        HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_COMMAND,
-        HCI_LE_SET_PERIODIC_ADVERTISING_DATA_COMMAND,
-        HCI_LE_SET_PERIODIC_ADVERTISING_ENABLE_COMMAND,
-        HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND,
-        HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND,
-        HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND
-    ),
-    # Octet 38
-    (
-        HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_COMMAND,
-        HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_CANCEL_COMMAND,
-        HCI_LE_PERIODIC_ADVERTISING_TERMINATE_SYNC_COMMAND,
-        HCI_LE_ADD_DEVICE_TO_PERIODIC_ADVERTISER_LIST_COMMAND,
-        HCI_LE_REMOVE_DEVICE_FROM_PERIODIC_ADVERTISER_LIST_COMMAND,
-        HCI_LE_CLEAR_PERIODIC_ADVERTISER_LIST_COMMAND,
-        HCI_LE_READ_PERIODIC_ADVERTISER_LIST_SIZE_COMMAND,
-        HCI_LE_READ_TRANSMIT_POWER_COMMAND
-    ),
-    # Octet 39
-    (
-        HCI_LE_READ_RF_PATH_COMPENSATION_COMMAND,
-        HCI_LE_WRITE_RF_PATH_COMPENSATION_COMMAND,
-        HCI_LE_SET_PRIVACY_MODE_COMMAND,
-        HCI_LE_RECEIVER_TEST_V3_COMMAND,
-        HCI_LE_TRANSMITTER_TEST_V3_COMMAND,
-        HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_PARAMETERS_COMMAND,
-        HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_ENABLE_COMMAND,
-        HCI_LE_SET_CONNECTIONLESS_IQ_SAMPLING_ENABLE_COMMAND,
-    ),
-    # Octet 40
-    (
-        HCI_LE_SET_CONNECTION_CTE_RECEIVE_PARAMETERS_COMMAND,
-        HCI_LE_SET_CONNECTION_CTE_TRANSMIT_PARAMETERS_COMMAND,
-        HCI_LE_CONNECTION_CTE_REQUEST_ENABLE_COMMAND,
-        HCI_LE_CONNECTION_CTE_RESPONSE_ENABLE_COMMAND,
-        HCI_LE_READ_ANTENNA_INFORMATION_COMMAND,
-        HCI_LE_SET_PERIODIC_ADVERTISING_RECEIVE_ENABLE_COMMAND,
-        HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_COMMAND,
-        HCI_LE_PERIODIC_ADVERTISING_SET_INFO_TRANSFER_COMMAND
-    ),
-    # Octet 41
-    (
-        HCI_LE_SET_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND,
-        HCI_LE_SET_DEFAULT_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND,
-        HCI_LE_GENERATE_DHKEY_V2_COMMAND,
-        HCI_READ_LOCAL_SIMPLE_PAIRING_OPTIONS_COMMAND,
-        HCI_LE_MODIFY_SLEEP_CLOCK_ACCURACY_COMMAND,
-        HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
-        HCI_LE_READ_ISO_TX_SYNC_COMMAND,
-        HCI_LE_SET_CIG_PARAMETERS_COMMAND
-    ),
-    # Octet 42
-    (
-        HCI_LE_SET_CIG_PARAMETERS_TEST_COMMAND,
-        HCI_LE_CREATE_CIS_COMMAND,
-        HCI_LE_REMOVE_CIG_COMMAND,
-        HCI_LE_ACCEPT_CIS_REQUEST_COMMAND,
-        HCI_LE_REJECT_CIS_REQUEST_COMMAND,
-        HCI_LE_CREATE_BIG_COMMAND,
-        HCI_LE_CREATE_BIG_TEST_COMMAND,
-        HCI_LE_TERMINATE_BIG_COMMAND,
-    ),
-    # Octet 43
-    (
-        HCI_LE_BIG_CREATE_SYNC_COMMAND,
-        HCI_LE_BIG_TERMINATE_SYNC_COMMAND,
-        HCI_LE_REQUEST_PEER_SCA_COMMAND,
-        HCI_LE_SETUP_ISO_DATA_PATH_COMMAND,
-        HCI_LE_REMOVE_ISO_DATA_PATH_COMMAND,
-        HCI_LE_ISO_TRANSMIT_TEST_COMMAND,
-        HCI_LE_ISO_RECEIVE_TEST_COMMAND,
-        HCI_LE_ISO_READ_TEST_COUNTERS_COMMAND
-    ),
-    # Octet 44
-    (
-        HCI_LE_ISO_TEST_END_COMMAND,
-        HCI_LE_SET_HOST_FEATURE_COMMAND,
-        HCI_LE_READ_ISO_LINK_QUALITY_COMMAND,
-        HCI_LE_ENHANCED_READ_TRANSMIT_POWER_LEVEL_COMMAND,
-        HCI_LE_READ_REMOTE_TRANSMIT_POWER_LEVEL_COMMAND,
-        HCI_LE_SET_PATH_LOSS_REPORTING_PARAMETERS_COMMAND,
-        HCI_LE_SET_PATH_LOSS_REPORTING_ENABLE_COMMAND,
-        HCI_LE_SET_TRANSMIT_POWER_REPORTING_ENABLE_COMMAND
-    ),
-    # Octet 45
-    (
-        HCI_LE_TRANSMITTER_TEST_V4_COMMAND,
-        HCI_SET_ECOSYSTEM_BASE_INTERVAL_COMMAND,
-        HCI_READ_LOCAL_SUPPORTED_CODECS_V2_COMMAND,
-        HCI_READ_LOCAL_SUPPORTED_CODEC_CAPABILITIES_COMMAND,
-        HCI_READ_LOCAL_SUPPORTED_CONTROLLER_DELAY_COMMAND,
-        HCI_CONFIGURE_DATA_PATH_COMMAND,
-        HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND,
-        HCI_SET_MIN_ENCRYPTION_KEY_SIZE_COMMAND
-    ),
-    # Octet 46
-    (
-        HCI_LE_SET_DEFAULT_SUBRATE_COMMAND,
-        HCI_LE_SUBRATE_REQUEST_COMMAND,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None
-    )
-)
+HCI_SUPPORTED_COMMANDS_MASKS = {
+    HCI_INQUIRY_COMMAND                                                       : 1 << (0*8+0),
+    HCI_INQUIRY_CANCEL_COMMAND                                                : 1 << (0*8+1),
+    HCI_PERIODIC_INQUIRY_MODE_COMMAND                                         : 1 << (0*8+2),
+    HCI_EXIT_PERIODIC_INQUIRY_MODE_COMMAND                                    : 1 << (0*8+3),
+    HCI_CREATE_CONNECTION_COMMAND                                             : 1 << (0*8+4),
+    HCI_DISCONNECT_COMMAND                                                    : 1 << (0*8+5),
+    HCI_CREATE_CONNECTION_CANCEL_COMMAND                                      : 1 << (0*8+7),
+    HCI_ACCEPT_CONNECTION_REQUEST_COMMAND                                     : 1 << (1*8+0),
+    HCI_REJECT_CONNECTION_REQUEST_COMMAND                                     : 1 << (1*8+1),
+    HCI_LINK_KEY_REQUEST_REPLY_COMMAND                                        : 1 << (1*8+2),
+    HCI_LINK_KEY_REQUEST_NEGATIVE_REPLY_COMMAND                               : 1 << (1*8+3),
+    HCI_PIN_CODE_REQUEST_REPLY_COMMAND                                        : 1 << (1*8+4),
+    HCI_PIN_CODE_REQUEST_NEGATIVE_REPLY_COMMAND                               : 1 << (1*8+5),
+    HCI_CHANGE_CONNECTION_PACKET_TYPE_COMMAND                                 : 1 << (1*8+6),
+    HCI_AUTHENTICATION_REQUESTED_COMMAND                                      : 1 << (1*8+7),
+    HCI_SET_CONNECTION_ENCRYPTION_COMMAND                                     : 1 << (2*8+0),
+    HCI_CHANGE_CONNECTION_LINK_KEY_COMMAND                                    : 1 << (2*8+1),
+    HCI_LINK_KEY_SELECTION_COMMAND                                            : 1 << (2*8+2),
+    HCI_REMOTE_NAME_REQUEST_COMMAND                                           : 1 << (2*8+3),
+    HCI_REMOTE_NAME_REQUEST_CANCEL_COMMAND                                    : 1 << (2*8+4),
+    HCI_READ_REMOTE_SUPPORTED_FEATURES_COMMAND                                : 1 << (2*8+5),
+    HCI_READ_REMOTE_EXTENDED_FEATURES_COMMAND                                 : 1 << (2*8+6),
+    HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND                               : 1 << (2*8+7),
+    HCI_READ_CLOCK_OFFSET_COMMAND                                             : 1 << (3*8+0),
+    HCI_READ_LMP_HANDLE_COMMAND                                               : 1 << (3*8+1),
+    HCI_HOLD_MODE_COMMAND                                                     : 1 << (4*8+1),
+    HCI_SNIFF_MODE_COMMAND                                                    : 1 << (4*8+2),
+    HCI_EXIT_SNIFF_MODE_COMMAND                                               : 1 << (4*8+3),
+    HCI_QOS_SETUP_COMMAND                                                     : 1 << (4*8+6),
+    HCI_ROLE_DISCOVERY_COMMAND                                                : 1 << (4*8+7),
+    HCI_SWITCH_ROLE_COMMAND                                                   : 1 << (5*8+0),
+    HCI_READ_LINK_POLICY_SETTINGS_COMMAND                                     : 1 << (5*8+1),
+    HCI_WRITE_LINK_POLICY_SETTINGS_COMMAND                                    : 1 << (5*8+2),
+    HCI_READ_DEFAULT_LINK_POLICY_SETTINGS_COMMAND                             : 1 << (5*8+3),
+    HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND                            : 1 << (5*8+4),
+    HCI_FLOW_SPECIFICATION_COMMAND                                            : 1 << (5*8+5),
+    HCI_SET_EVENT_MASK_COMMAND                                                : 1 << (5*8+6),
+    HCI_RESET_COMMAND                                                         : 1 << (5*8+7),
+    HCI_SET_EVENT_FILTER_COMMAND                                              : 1 << (6*8+0),
+    HCI_FLUSH_COMMAND                                                         : 1 << (6*8+1),
+    HCI_READ_PIN_TYPE_COMMAND                                                 : 1 << (6*8+2),
+    HCI_WRITE_PIN_TYPE_COMMAND                                                : 1 << (6*8+3),
+    HCI_READ_STORED_LINK_KEY_COMMAND                                          : 1 << (6*8+5),
+    HCI_WRITE_STORED_LINK_KEY_COMMAND                                         : 1 << (6*8+6),
+    HCI_DELETE_STORED_LINK_KEY_COMMAND                                        : 1 << (6*8+7),
+    HCI_WRITE_LOCAL_NAME_COMMAND                                              : 1 << (7*8+0),
+    HCI_READ_LOCAL_NAME_COMMAND                                               : 1 << (7*8+1),
+    HCI_READ_CONNECTION_ACCEPT_TIMEOUT_COMMAND                                : 1 << (7*8+2),
+    HCI_WRITE_CONNECTION_ACCEPT_TIMEOUT_COMMAND                               : 1 << (7*8+3),
+    HCI_READ_PAGE_TIMEOUT_COMMAND                                             : 1 << (7*8+4),
+    HCI_WRITE_PAGE_TIMEOUT_COMMAND                                            : 1 << (7*8+5),
+    HCI_READ_SCAN_ENABLE_COMMAND                                              : 1 << (7*8+6),
+    HCI_WRITE_SCAN_ENABLE_COMMAND                                             : 1 << (7*8+7),
+    HCI_READ_PAGE_SCAN_ACTIVITY_COMMAND                                       : 1 << (8*8+0),
+    HCI_WRITE_PAGE_SCAN_ACTIVITY_COMMAND                                      : 1 << (8*8+1),
+    HCI_READ_INQUIRY_SCAN_ACTIVITY_COMMAND                                    : 1 << (8*8+2),
+    HCI_WRITE_INQUIRY_SCAN_ACTIVITY_COMMAND                                   : 1 << (8*8+3),
+    HCI_READ_AUTHENTICATION_ENABLE_COMMAND                                    : 1 << (8*8+4),
+    HCI_WRITE_AUTHENTICATION_ENABLE_COMMAND                                   : 1 << (8*8+5),
+    HCI_READ_CLASS_OF_DEVICE_COMMAND                                          : 1 << (9*8+0),
+    HCI_WRITE_CLASS_OF_DEVICE_COMMAND                                         : 1 << (9*8+1),
+    HCI_READ_VOICE_SETTING_COMMAND                                            : 1 << (9*8+2),
+    HCI_WRITE_VOICE_SETTING_COMMAND                                           : 1 << (9*8+3),
+    HCI_READ_AUTOMATIC_FLUSH_TIMEOUT_COMMAND                                  : 1 << (9*8+4),
+    HCI_WRITE_AUTOMATIC_FLUSH_TIMEOUT_COMMAND                                 : 1 << (9*8+5),
+    HCI_READ_NUM_BROADCAST_RETRANSMISSIONS_COMMAND                            : 1 << (9*8+6),
+    HCI_WRITE_NUM_BROADCAST_RETRANSMISSIONS_COMMAND                           : 1 << (9*8+7),
+    HCI_READ_HOLD_MODE_ACTIVITY_COMMAND                                       : 1 << (10*8+0),
+    HCI_WRITE_HOLD_MODE_ACTIVITY_COMMAND                                      : 1 << (10*8+1),
+    HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND                                     : 1 << (10*8+2),
+    HCI_READ_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND                          : 1 << (10*8+3),
+    HCI_WRITE_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND                         : 1 << (10*8+4),
+    HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND                           : 1 << (10*8+5),
+    HCI_HOST_BUFFER_SIZE_COMMAND                                              : 1 << (10*8+6),
+    HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND                              : 1 << (10*8+7),
+    HCI_READ_LINK_SUPERVISION_TIMEOUT_COMMAND                                 : 1 << (11*8+0),
+    HCI_WRITE_LINK_SUPERVISION_TIMEOUT_COMMAND                                : 1 << (11*8+1),
+    HCI_READ_NUMBER_OF_SUPPORTED_IAC_COMMAND                                  : 1 << (11*8+2),
+    HCI_READ_CURRENT_IAC_LAP_COMMAND                                          : 1 << (11*8+3),
+    HCI_WRITE_CURRENT_IAC_LAP_COMMAND                                         : 1 << (11*8+4),
+    HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND                           : 1 << (12*8+1),
+    HCI_READ_INQUIRY_SCAN_TYPE_COMMAND                                        : 1 << (12*8+4),
+    HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND                                       : 1 << (12*8+5),
+    HCI_READ_INQUIRY_MODE_COMMAND                                             : 1 << (12*8+6),
+    HCI_WRITE_INQUIRY_MODE_COMMAND                                            : 1 << (12*8+7),
+    HCI_READ_PAGE_SCAN_TYPE_COMMAND                                           : 1 << (13*8+0),
+    HCI_WRITE_PAGE_SCAN_TYPE_COMMAND                                          : 1 << (13*8+1),
+    HCI_READ_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND                              : 1 << (13*8+2),
+    HCI_WRITE_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND                             : 1 << (13*8+3),
+    HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND                                : 1 << (14*8+3),
+    HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND                                 : 1 << (14*8+5),
+    HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND                                  : 1 << (14*8+6),
+    HCI_READ_BUFFER_SIZE_COMMAND                                              : 1 << (14*8+7),
+    HCI_READ_BD_ADDR_COMMAND                                                  : 1 << (15*8+1),
+    HCI_READ_FAILED_CONTACT_COUNTER_COMMAND                                   : 1 << (15*8+2),
+    HCI_RESET_FAILED_CONTACT_COUNTER_COMMAND                                  : 1 << (15*8+3),
+    HCI_READ_LINK_QUALITY_COMMAND                                             : 1 << (15*8+4),
+    HCI_READ_RSSI_COMMAND                                                     : 1 << (15*8+5),
+    HCI_READ_AFH_CHANNEL_MAP_COMMAND                                          : 1 << (15*8+6),
+    HCI_READ_CLOCK_COMMAND                                                    : 1 << (15*8+7),
+    HCI_READ_LOOPBACK_MODE_COMMAND                                            : 1 << (16*8+0),
+    HCI_WRITE_LOOPBACK_MODE_COMMAND                                           : 1 << (16*8+1),
+    HCI_ENABLE_DEVICE_UNDER_TEST_MODE_COMMAND                                 : 1 << (16*8+2),
+    HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND                                  : 1 << (16*8+3),
+    HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND                         : 1 << (16*8+4),
+    HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND                         : 1 << (16*8+5),
+    HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND                                : 1 << (17*8+0),
+    HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND                               : 1 << (17*8+1),
+    HCI_REFRESH_ENCRYPTION_KEY_COMMAND                                        : 1 << (17*8+2),
+    HCI_SNIFF_SUBRATING_COMMAND                                               : 1 << (17*8+4),
+    HCI_READ_SIMPLE_PAIRING_MODE_COMMAND                                      : 1 << (17*8+5),
+    HCI_WRITE_SIMPLE_PAIRING_MODE_COMMAND                                     : 1 << (17*8+6),
+    HCI_READ_LOCAL_OOB_DATA_COMMAND                                           : 1 << (17*8+7),
+    HCI_READ_INQUIRY_RESPONSE_TRANSMIT_POWER_LEVEL_COMMAND                    : 1 << (18*8+0),
+    HCI_WRITE_INQUIRY_TRANSMIT_POWER_LEVEL_COMMAND                            : 1 << (18*8+1),
+    HCI_READ_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND                         : 1 << (18*8+2),
+    HCI_WRITE_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND                        : 1 << (18*8+3),
+    HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND                                   : 1 << (18*8+7),
+    HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND                               : 1 << (19*8+0),
+    HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND                      : 1 << (19*8+1),
+    HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND                                    : 1 << (19*8+2),
+    HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND                           : 1 << (19*8+3),
+    HCI_REMOTE_OOB_DATA_REQUEST_REPLY_COMMAND                                 : 1 << (19*8+4),
+    HCI_WRITE_SIMPLE_PAIRING_DEBUG_MODE_COMMAND                               : 1 << (19*8+5),
+    HCI_ENHANCED_FLUSH_COMMAND                                                : 1 << (19*8+6),
+    HCI_REMOTE_OOB_DATA_REQUEST_NEGATIVE_REPLY_COMMAND                        : 1 << (19*8+7),
+    HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND                                    : 1 << (20*8+2),
+    HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND                          : 1 << (20*8+3),
+    HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND                                      : 1 << (20*8+4),
+    HCI_SET_EVENT_MASK_PAGE_2_COMMAND                                         : 1 << (22*8+2),
+    HCI_READ_FLOW_CONTROL_MODE_COMMAND                                        : 1 << (23*8+0),
+    HCI_WRITE_FLOW_CONTROL_MODE_COMMAND                                       : 1 << (23*8+1),
+    HCI_READ_DATA_BLOCK_SIZE_COMMAND                                          : 1 << (23*8+2),
+    HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND                            : 1 << (24*8+0),
+    HCI_READ_LE_HOST_SUPPORT_COMMAND                                          : 1 << (24*8+5),
+    HCI_WRITE_LE_HOST_SUPPORT_COMMAND                                         : 1 << (24*8+6),
+    HCI_LE_SET_EVENT_MASK_COMMAND                                             : 1 << (25*8+0),
+    HCI_LE_READ_BUFFER_SIZE_COMMAND                                           : 1 << (25*8+1),
+    HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND                              : 1 << (25*8+2),
+    HCI_LE_SET_RANDOM_ADDRESS_COMMAND                                         : 1 << (25*8+4),
+    HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND                                 : 1 << (25*8+5),
+    HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND                 : 1 << (25*8+6),
+    HCI_LE_SET_ADVERTISING_DATA_COMMAND                                       : 1 << (25*8+7),
+    HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND                                     : 1 << (26*8+0),
+    HCI_LE_SET_ADVERTISING_ENABLE_COMMAND                                     : 1 << (26*8+1),
+    HCI_LE_SET_SCAN_PARAMETERS_COMMAND                                        : 1 << (26*8+2),
+    HCI_LE_SET_SCAN_ENABLE_COMMAND                                            : 1 << (26*8+3),
+    HCI_LE_CREATE_CONNECTION_COMMAND                                          : 1 << (26*8+4),
+    HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND                                   : 1 << (26*8+5),
+    HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND                               : 1 << (26*8+6),
+    HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND                                   : 1 << (26*8+7),
+    HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND                           : 1 << (27*8+0),
+    HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND                      : 1 << (27*8+1),
+    HCI_LE_CONNECTION_UPDATE_COMMAND                                          : 1 << (27*8+2),
+    HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND                            : 1 << (27*8+3),
+    HCI_LE_READ_CHANNEL_MAP_COMMAND                                           : 1 << (27*8+4),
+    HCI_LE_READ_REMOTE_FEATURES_COMMAND                                       : 1 << (27*8+5),
+    HCI_LE_ENCRYPT_COMMAND                                                    : 1 << (27*8+6),
+    HCI_LE_RAND_COMMAND                                                       : 1 << (27*8+7),
+    HCI_LE_ENABLE_ENCRYPTION_COMMAND                                          : 1 << (28*8+0),
+    HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND                                : 1 << (28*8+1),
+    HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND                       : 1 << (28*8+2),
+    HCI_LE_READ_SUPPORTED_STATES_COMMAND                                      : 1 << (28*8+3),
+    HCI_LE_RECEIVER_TEST_COMMAND                                              : 1 << (28*8+4),
+    HCI_LE_TRANSMITTER_TEST_COMMAND                                           : 1 << (28*8+5),
+    HCI_LE_TEST_END_COMMAND                                                   : 1 << (28*8+6),
+    HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND                         : 1 << (29*8+3),
+    HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND                : 1 << (29*8+4),
+    HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND                                   : 1 << (29*8+5),
+    HCI_SET_MWS_CHANNEL_PARAMETERS_COMMAND                                    : 1 << (29*8+6),
+    HCI_SET_EXTERNAL_FRAME_CONFIGURATION_COMMAND                              : 1 << (29*8+7),
+    HCI_SET_MWS_SIGNALING_COMMAND                                             : 1 << (30*8+0),
+    HCI_SET_MWS_TRANSPORT_LAYER_COMMAND                                       : 1 << (30*8+1),
+    HCI_SET_MWS_SCAN_FREQUENCY_TABLE_COMMAND                                  : 1 << (30*8+2),
+    HCI_GET_MWS_TRANSPORT_LAYER_CONFIGURATION_COMMAND                         : 1 << (30*8+3),
+    HCI_SET_MWS_PATTERN_CONFIGURATION_COMMAND                                 : 1 << (30*8+4),
+    HCI_SET_TRIGGERED_CLOCK_CAPTURE_COMMAND                                   : 1 << (30*8+5),
+    HCI_TRUNCATED_PAGE_COMMAND                                                : 1 << (30*8+6),
+    HCI_TRUNCATED_PAGE_CANCEL_COMMAND                                         : 1 << (30*8+7),
+    HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_COMMAND                       : 1 << (31*8+0),
+    HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVE_COMMAND               : 1 << (31*8+1),
+    HCI_START_SYNCHRONIZATION_TRAIN_COMMAND                                   : 1 << (31*8+2),
+    HCI_RECEIVE_SYNCHRONIZATION_TRAIN_COMMAND                                 : 1 << (31*8+3),
+    HCI_SET_RESERVED_LT_ADDR_COMMAND                                          : 1 << (31*8+4),
+    HCI_DELETE_RESERVED_LT_ADDR_COMMAND                                       : 1 << (31*8+5),
+    HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_DATA_COMMAND                  : 1 << (31*8+6),
+    HCI_READ_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND                         : 1 << (31*8+7),
+    HCI_WRITE_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND                        : 1 << (32*8+0),
+    HCI_REMOTE_OOB_EXTENDED_DATA_REQUEST_REPLY_COMMAND                        : 1 << (32*8+1),
+    HCI_READ_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND                          : 1 << (32*8+2),
+    HCI_WRITE_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND                         : 1 << (32*8+3),
+    HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND                            : 1 << (32*8+4),
+    HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND                           : 1 << (32*8+5),
+    HCI_READ_LOCAL_OOB_EXTENDED_DATA_COMMAND                                  : 1 << (32*8+6),
+    HCI_WRITE_SECURE_CONNECTIONS_TEST_MODE_COMMAND                            : 1 << (32*8+7),
+    HCI_READ_EXTENDED_PAGE_TIMEOUT_COMMAND                                    : 1 << (33*8+0),
+    HCI_WRITE_EXTENDED_PAGE_TIMEOUT_COMMAND                                   : 1 << (33*8+1),
+    HCI_READ_EXTENDED_INQUIRY_LENGTH_COMMAND                                  : 1 << (33*8+2),
+    HCI_WRITE_EXTENDED_INQUIRY_LENGTH_COMMAND                                 : 1 << (33*8+3),
+    HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND                  : 1 << (33*8+4),
+    HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND         : 1 << (33*8+5),
+    HCI_LE_SET_DATA_LENGTH_COMMAND                                            : 1 << (33*8+6),
+    HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND                         : 1 << (33*8+7),
+    HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND                        : 1 << (34*8+0),
+    HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMMAND                                : 1 << (34*8+1),
+    HCI_LE_GENERATE_DHKEY_COMMAND                                             : 1 << (34*8+2),
+    HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND                               : 1 << (34*8+3),
+    HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND                          : 1 << (34*8+4),
+    HCI_LE_CLEAR_RESOLVING_LIST_COMMAND                                       : 1 << (34*8+5),
+    HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND                                   : 1 << (34*8+6),
+    HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND                               : 1 << (34*8+7),
+    HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND                              : 1 << (35*8+0),
+    HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND                              : 1 << (35*8+1),
+    HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND                     : 1 << (35*8+2),
+    HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND                                   : 1 << (35*8+3),
+    HCI_LE_READ_PHY_COMMAND                                                   : 1 << (35*8+4),
+    HCI_LE_SET_DEFAULT_PHY_COMMAND                                            : 1 << (35*8+5),
+    HCI_LE_SET_PHY_COMMAND                                                    : 1 << (35*8+6),
+    HCI_LE_RECEIVER_TEST_V2_COMMAND                                           : 1 << (35*8+7),
+    HCI_LE_TRANSMITTER_TEST_V2_COMMAND                                        : 1 << (36*8+0),
+    HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND                         : 1 << (36*8+1),
+    HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND                        : 1 << (36*8+2),
+    HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND                              : 1 << (36*8+3),
+    HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND                            : 1 << (36*8+4),
+    HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND                            : 1 << (36*8+5),
+    HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND                       : 1 << (36*8+6),
+    HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND                  : 1 << (36*8+7),
+    HCI_LE_REMOVE_ADVERTISING_SET_COMMAND                                     : 1 << (37*8+0),
+    HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND                                     : 1 << (37*8+1),
+    HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_COMMAND                        : 1 << (37*8+2),
+    HCI_LE_SET_PERIODIC_ADVERTISING_DATA_COMMAND                              : 1 << (37*8+3),
+    HCI_LE_SET_PERIODIC_ADVERTISING_ENABLE_COMMAND                            : 1 << (37*8+4),
+    HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND                               : 1 << (37*8+5),
+    HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND                                   : 1 << (37*8+6),
+    HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND                                 : 1 << (37*8+7),
+    HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_COMMAND                           : 1 << (38*8+0),
+    HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_CANCEL_COMMAND                    : 1 << (38*8+1),
+    HCI_LE_PERIODIC_ADVERTISING_TERMINATE_SYNC_COMMAND                        : 1 << (38*8+2),
+    HCI_LE_ADD_DEVICE_TO_PERIODIC_ADVERTISER_LIST_COMMAND                     : 1 << (38*8+3),
+    HCI_LE_REMOVE_DEVICE_FROM_PERIODIC_ADVERTISER_LIST_COMMAND                : 1 << (38*8+4),
+    HCI_LE_CLEAR_PERIODIC_ADVERTISER_LIST_COMMAND                             : 1 << (38*8+5),
+    HCI_LE_READ_PERIODIC_ADVERTISER_LIST_SIZE_COMMAND                         : 1 << (38*8+6),
+    HCI_LE_READ_TRANSMIT_POWER_COMMAND                                        : 1 << (38*8+7),
+    HCI_LE_READ_RF_PATH_COMPENSATION_COMMAND                                  : 1 << (39*8+0),
+    HCI_LE_WRITE_RF_PATH_COMPENSATION_COMMAND                                 : 1 << (39*8+1),
+    HCI_LE_SET_PRIVACY_MODE_COMMAND                                           : 1 << (39*8+2),
+    HCI_LE_RECEIVER_TEST_V3_COMMAND                                           : 1 << (39*8+3),
+    HCI_LE_TRANSMITTER_TEST_V3_COMMAND                                        : 1 << (39*8+4),
+    HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_PARAMETERS_COMMAND                 : 1 << (39*8+5),
+    HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_ENABLE_COMMAND                     : 1 << (39*8+6),
+    HCI_LE_SET_CONNECTIONLESS_IQ_SAMPLING_ENABLE_COMMAND                      : 1 << (39*8+7),
+    HCI_LE_SET_CONNECTION_CTE_RECEIVE_PARAMETERS_COMMAND                      : 1 << (40*8+0),
+    HCI_LE_SET_CONNECTION_CTE_TRANSMIT_PARAMETERS_COMMAND                     : 1 << (40*8+1),
+    HCI_LE_CONNECTION_CTE_REQUEST_ENABLE_COMMAND                              : 1 << (40*8+2),
+    HCI_LE_CONNECTION_CTE_RESPONSE_ENABLE_COMMAND                             : 1 << (40*8+3),
+    HCI_LE_READ_ANTENNA_INFORMATION_COMMAND                                   : 1 << (40*8+4),
+    HCI_LE_SET_PERIODIC_ADVERTISING_RECEIVE_ENABLE_COMMAND                    : 1 << (40*8+5),
+    HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_COMMAND                         : 1 << (40*8+6),
+    HCI_LE_PERIODIC_ADVERTISING_SET_INFO_TRANSFER_COMMAND                     : 1 << (40*8+7),
+    HCI_LE_SET_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND          : 1 << (41*8+0),
+    HCI_LE_SET_DEFAULT_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND  : 1 << (41*8+1),
+    HCI_LE_GENERATE_DHKEY_V2_COMMAND                                          : 1 << (41*8+2),
+    HCI_READ_LOCAL_SIMPLE_PAIRING_OPTIONS_COMMAND                             : 1 << (41*8+3),
+    HCI_LE_MODIFY_SLEEP_CLOCK_ACCURACY_COMMAND                                : 1 << (41*8+4),
+    HCI_LE_READ_BUFFER_SIZE_V2_COMMAND                                        : 1 << (41*8+5),
+    HCI_LE_READ_ISO_TX_SYNC_COMMAND                                           : 1 << (41*8+6),
+    HCI_LE_SET_CIG_PARAMETERS_COMMAND                                         : 1 << (41*8+7),
+    HCI_LE_SET_CIG_PARAMETERS_TEST_COMMAND                                    : 1 << (42*8+0),
+    HCI_LE_CREATE_CIS_COMMAND                                                 : 1 << (42*8+1),
+    HCI_LE_REMOVE_CIG_COMMAND                                                 : 1 << (42*8+2),
+    HCI_LE_ACCEPT_CIS_REQUEST_COMMAND                                         : 1 << (42*8+3),
+    HCI_LE_REJECT_CIS_REQUEST_COMMAND                                         : 1 << (42*8+4),
+    HCI_LE_CREATE_BIG_COMMAND                                                 : 1 << (42*8+5),
+    HCI_LE_CREATE_BIG_TEST_COMMAND                                            : 1 << (42*8+6),
+    HCI_LE_TERMINATE_BIG_COMMAND                                              : 1 << (42*8+7),
+    HCI_LE_BIG_CREATE_SYNC_COMMAND                                            : 1 << (43*8+0),
+    HCI_LE_BIG_TERMINATE_SYNC_COMMAND                                         : 1 << (43*8+1),
+    HCI_LE_REQUEST_PEER_SCA_COMMAND                                           : 1 << (43*8+2),
+    HCI_LE_SETUP_ISO_DATA_PATH_COMMAND                                        : 1 << (43*8+3),
+    HCI_LE_REMOVE_ISO_DATA_PATH_COMMAND                                       : 1 << (43*8+4),
+    HCI_LE_ISO_TRANSMIT_TEST_COMMAND                                          : 1 << (43*8+5),
+    HCI_LE_ISO_RECEIVE_TEST_COMMAND                                           : 1 << (43*8+6),
+    HCI_LE_ISO_READ_TEST_COUNTERS_COMMAND                                     : 1 << (43*8+7),
+    HCI_LE_ISO_TEST_END_COMMAND                                               : 1 << (44*8+0),
+    HCI_LE_SET_HOST_FEATURE_COMMAND                                           : 1 << (44*8+1),
+    HCI_LE_READ_ISO_LINK_QUALITY_COMMAND                                      : 1 << (44*8+2),
+    HCI_LE_ENHANCED_READ_TRANSMIT_POWER_LEVEL_COMMAND                         : 1 << (44*8+3),
+    HCI_LE_READ_REMOTE_TRANSMIT_POWER_LEVEL_COMMAND                           : 1 << (44*8+4),
+    HCI_LE_SET_PATH_LOSS_REPORTING_PARAMETERS_COMMAND                         : 1 << (44*8+5),
+    HCI_LE_SET_PATH_LOSS_REPORTING_ENABLE_COMMAND                             : 1 << (44*8+6),
+    HCI_LE_SET_TRANSMIT_POWER_REPORTING_ENABLE_COMMAND                        : 1 << (44*8+7),
+    HCI_LE_TRANSMITTER_TEST_V4_COMMAND                                        : 1 << (45*8+0),
+    HCI_SET_ECOSYSTEM_BASE_INTERVAL_COMMAND                                   : 1 << (45*8+1),
+    HCI_READ_LOCAL_SUPPORTED_CODECS_V2_COMMAND                                : 1 << (45*8+2),
+    HCI_READ_LOCAL_SUPPORTED_CODEC_CAPABILITIES_COMMAND                       : 1 << (45*8+3),
+    HCI_READ_LOCAL_SUPPORTED_CONTROLLER_DELAY_COMMAND                         : 1 << (45*8+4),
+    HCI_CONFIGURE_DATA_PATH_COMMAND                                           : 1 << (45*8+5),
+    HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND                           : 1 << (45*8+6),
+    HCI_SET_MIN_ENCRYPTION_KEY_SIZE_COMMAND                                   : 1 << (45*8+7),
+    HCI_LE_SET_DEFAULT_SUBRATE_COMMAND                                        : 1 << (46*8+0),
+    HCI_LE_SUBRATE_REQUEST_COMMAND                                            : 1 << (46*8+1),
+    HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND                     : 1 << (46*8+2),
+    HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND                     : 1 << (46*8+5),
+    HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND                     : 1 << (46*8+6),
+    HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND                                 : 1 << (46*8+7),
+    HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND                              : 1 << (47*8+0),
+    HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND                     : 1 << (47*8+1),
+}
 
 # LE Supported Features
-HCI_LE_ENCRYPTION_LE_SUPPORTED_FEATURE                                = 0
-HCI_CONNECTION_PARAMETERS_REQUEST_PROCEDURE_LE_SUPPORTED_FEATURE      = 1
-HCI_EXTENDED_REJECT_INDICATION_LE_SUPPORTED_FEATURE                   = 2
-HCI_PERIPHERAL_INITIATED_FEATURE_EXCHANGE_LE_SUPPORTED_FEATURE        = 3
-HCI_LE_PING_LE_SUPPORTED_FEATURE                                      = 4
-HCI_LE_DATA_PACKET_LENGTH_EXTENSION_LE_SUPPORTED_FEATURE              = 5
-HCI_LL_PRIVACY_LE_SUPPORTED_FEATURE                                   = 6
-HCI_EXTENDED_SCANNER_FILTER_POLICIES_LE_SUPPORTED_FEATURE             = 7
-HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE                                    = 8
-HCI_STABLE_MODULATION_INDEX_TRANSMITTER_LE_SUPPORTED_FEATURE          = 9
-HCI_STABLE_MODULATION_INDEX_RECEIVER_LE_SUPPORTED_FEATURE             = 10
-HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE                                 = 11
-HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE                      = 12
-HCI_LE_PERIODIC_ADVERTISING_LE_SUPPORTED_FEATURE                      = 13
-HCI_CHANNEL_SELECTION_ALGORITHM_2_LE_SUPPORTED_FEATURE                = 14
-HCI_LE_POWER_CLASS_1_LE_SUPPORTED_FEATURE                             = 15
-HCI_MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE_LE_SUPPORTED_FEATURE    = 16
-HCI_CONNECTION_CTE_REQUEST_LE_SUPPORTED_FEATURE                       = 17
-HCI_CONNECTION_CTE_RESPONSE_LE_SUPPORTED_FEATURE                      = 18
-HCI_CONNECTIONLESS_CTE_TRANSMITTER_LE_SUPPORTED_FEATURE               = 19
-HCI_CONNECTIONLESS_CTR_RECEIVER_LE_SUPPORTED_FEATURE                  = 20
-HCI_ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION_LE_SUPPORTED_FEATURE    = 21
-HCI_ANTENNA_SWITCHING_DURING_CTE_RECEPTION_LE_SUPPORTED_FEATURE       = 22
-HCI_RECEIVING_CONSTANT_TONE_EXTENSIONS_LE_SUPPORTED_FEATURE           = 23
-HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER_LE_SUPPORTED_FEATURE    = 24
-HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT_LE_SUPPORTED_FEATURE = 25
-HCI_SLEEP_CLOCK_ACCURACY_UPDATES_LE_SUPPORTED_FEATURE                 = 26
-HCI_REMOTE_PUBLIC_KEY_VALIDATION_LE_SUPPORTED_FEATURE                 = 27
-HCI_CONNECTED_ISOCHRONOUS_STREAM_CENTRAL_LE_SUPPORTED_FEATURE         = 28
-HCI_CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL_LE_SUPPORTED_FEATURE      = 29
-HCI_ISOCHRONOUS_BROADCASTER_LE_SUPPORTED_FEATURE                      = 30
-HCI_SYNCHRONIZED_RECEIVER_LE_SUPPORTED_FEATURE                        = 31
-HCI_CONNECTED_ISOCHRONOUS_STREAM_LE_SUPPORTED_FEATURE                 = 32
-HCI_LE_POWER_CONTROL_REQUEST_LE_SUPPORTED_FEATURE                     = 33
-HCI_LE_POWER_CONTROL_REQUEST_DUP_LE_SUPPORTED_FEATURE                 = 34
-HCI_LE_PATH_LOSS_MONITORING_LE_SUPPORTED_FEATURE                      = 35
-HCI_PERIODIC_ADVERTISING_ADI_SUPPORT_LE_SUPPORTED_FEATURE             = 36
-HCI_CONNECTION_SUBRATING_LE_SUPPORTED_FEATURE                         = 37
-HCI_CONNECTION_SUBRATING_HOST_SUPPORT_LE_SUPPORTED_FEATURE            = 38
-HCI_CHANNEL_CLASSIFICATION_LE_SUPPORTED_FEATURE                       = 39
+# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
+class LeFeature(enum.IntEnum):
+    LE_ENCRYPTION                                  = 0
+    CONNECTION_PARAMETERS_REQUEST_PROCEDURE        = 1
+    EXTENDED_REJECT_INDICATION                     = 2
+    PERIPHERAL_INITIATED_FEATURE_EXCHANGE          = 3
+    LE_PING                                        = 4
+    LE_DATA_PACKET_LENGTH_EXTENSION                = 5
+    LL_PRIVACY                                     = 6
+    EXTENDED_SCANNER_FILTER_POLICIES               = 7
+    LE_2M_PHY                                      = 8
+    STABLE_MODULATION_INDEX_TRANSMITTER            = 9
+    STABLE_MODULATION_INDEX_RECEIVER               = 10
+    LE_CODED_PHY                                   = 11
+    LE_EXTENDED_ADVERTISING                        = 12
+    LE_PERIODIC_ADVERTISING                        = 13
+    CHANNEL_SELECTION_ALGORITHM_2                  = 14
+    LE_POWER_CLASS_1                               = 15
+    MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE      = 16
+    CONNECTION_CTE_REQUEST                         = 17
+    CONNECTION_CTE_RESPONSE                        = 18
+    CONNECTIONLESS_CTE_TRANSMITTER                 = 19
+    CONNECTIONLESS_CTR_RECEIVER                    = 20
+    ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION      = 21
+    ANTENNA_SWITCHING_DURING_CTE_RECEPTION         = 22
+    RECEIVING_CONSTANT_TONE_EXTENSIONS             = 23
+    PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER      = 24
+    PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT   = 25
+    SLEEP_CLOCK_ACCURACY_UPDATES                   = 26
+    REMOTE_PUBLIC_KEY_VALIDATION                   = 27
+    CONNECTED_ISOCHRONOUS_STREAM_CENTRAL           = 28
+    CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL        = 29
+    ISOCHRONOUS_BROADCASTER                        = 30
+    SYNCHRONIZED_RECEIVER                          = 31
+    CONNECTED_ISOCHRONOUS_STREAM                   = 32
+    LE_POWER_CONTROL_REQUEST                       = 33
+    LE_POWER_CONTROL_REQUEST_DUP                   = 34
+    LE_PATH_LOSS_MONITORING                        = 35
+    PERIODIC_ADVERTISING_ADI_SUPPORT               = 36
+    CONNECTION_SUBRATING                           = 37
+    CONNECTION_SUBRATING_HOST_SUPPORT              = 38
+    CHANNEL_CLASSIFICATION                         = 39
+    ADVERTISING_CODING_SELECTION                   = 40
+    ADVERTISING_CODING_SELECTION_HOST_SUPPORT      = 41
+    PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 43
+    PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER    = 44
 
-HCI_LE_SUPPORTED_FEATURES_NAMES = {
-    flag: feature_name for (feature_name, flag) in globals().items()
-    if feature_name.startswith('HCI_') and feature_name.endswith('_LE_SUPPORTED_FEATURE')
-}
+class LeFeatureMask(enum.IntFlag):
+    LE_ENCRYPTION                                  = 1 << LeFeature.LE_ENCRYPTION
+    CONNECTION_PARAMETERS_REQUEST_PROCEDURE        = 1 << LeFeature.CONNECTION_PARAMETERS_REQUEST_PROCEDURE
+    EXTENDED_REJECT_INDICATION                     = 1 << LeFeature.EXTENDED_REJECT_INDICATION
+    PERIPHERAL_INITIATED_FEATURE_EXCHANGE          = 1 << LeFeature.PERIPHERAL_INITIATED_FEATURE_EXCHANGE
+    LE_PING                                        = 1 << LeFeature.LE_PING
+    LE_DATA_PACKET_LENGTH_EXTENSION                = 1 << LeFeature.LE_DATA_PACKET_LENGTH_EXTENSION
+    LL_PRIVACY                                     = 1 << LeFeature.LL_PRIVACY
+    EXTENDED_SCANNER_FILTER_POLICIES               = 1 << LeFeature.EXTENDED_SCANNER_FILTER_POLICIES
+    LE_2M_PHY                                      = 1 << LeFeature.LE_2M_PHY
+    STABLE_MODULATION_INDEX_TRANSMITTER            = 1 << LeFeature.STABLE_MODULATION_INDEX_TRANSMITTER
+    STABLE_MODULATION_INDEX_RECEIVER               = 1 << LeFeature.STABLE_MODULATION_INDEX_RECEIVER
+    LE_CODED_PHY                                   = 1 << LeFeature.LE_CODED_PHY
+    LE_EXTENDED_ADVERTISING                        = 1 << LeFeature.LE_EXTENDED_ADVERTISING
+    LE_PERIODIC_ADVERTISING                        = 1 << LeFeature.LE_PERIODIC_ADVERTISING
+    CHANNEL_SELECTION_ALGORITHM_2                  = 1 << LeFeature.CHANNEL_SELECTION_ALGORITHM_2
+    LE_POWER_CLASS_1                               = 1 << LeFeature.LE_POWER_CLASS_1
+    MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE      = 1 << LeFeature.MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE
+    CONNECTION_CTE_REQUEST                         = 1 << LeFeature.CONNECTION_CTE_REQUEST
+    CONNECTION_CTE_RESPONSE                        = 1 << LeFeature.CONNECTION_CTE_RESPONSE
+    CONNECTIONLESS_CTE_TRANSMITTER                 = 1 << LeFeature.CONNECTIONLESS_CTE_TRANSMITTER
+    CONNECTIONLESS_CTR_RECEIVER                    = 1 << LeFeature.CONNECTIONLESS_CTR_RECEIVER
+    ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION      = 1 << LeFeature.ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION
+    ANTENNA_SWITCHING_DURING_CTE_RECEPTION         = 1 << LeFeature.ANTENNA_SWITCHING_DURING_CTE_RECEPTION
+    RECEIVING_CONSTANT_TONE_EXTENSIONS             = 1 << LeFeature.RECEIVING_CONSTANT_TONE_EXTENSIONS
+    PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER      = 1 << LeFeature.PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER
+    PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT   = 1 << LeFeature.PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT
+    SLEEP_CLOCK_ACCURACY_UPDATES                   = 1 << LeFeature.SLEEP_CLOCK_ACCURACY_UPDATES
+    REMOTE_PUBLIC_KEY_VALIDATION                   = 1 << LeFeature.REMOTE_PUBLIC_KEY_VALIDATION
+    CONNECTED_ISOCHRONOUS_STREAM_CENTRAL           = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM_CENTRAL
+    CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL        = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL
+    ISOCHRONOUS_BROADCASTER                        = 1 << LeFeature.ISOCHRONOUS_BROADCASTER
+    SYNCHRONIZED_RECEIVER                          = 1 << LeFeature.SYNCHRONIZED_RECEIVER
+    CONNECTED_ISOCHRONOUS_STREAM                   = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM
+    LE_POWER_CONTROL_REQUEST                       = 1 << LeFeature.LE_POWER_CONTROL_REQUEST
+    LE_POWER_CONTROL_REQUEST_DUP                   = 1 << LeFeature.LE_POWER_CONTROL_REQUEST_DUP
+    LE_PATH_LOSS_MONITORING                        = 1 << LeFeature.LE_PATH_LOSS_MONITORING
+    PERIODIC_ADVERTISING_ADI_SUPPORT               = 1 << LeFeature.PERIODIC_ADVERTISING_ADI_SUPPORT
+    CONNECTION_SUBRATING                           = 1 << LeFeature.CONNECTION_SUBRATING
+    CONNECTION_SUBRATING_HOST_SUPPORT              = 1 << LeFeature.CONNECTION_SUBRATING_HOST_SUPPORT
+    CHANNEL_CLASSIFICATION                         = 1 << LeFeature.CHANNEL_CLASSIFICATION
+    ADVERTISING_CODING_SELECTION                   = 1 << LeFeature.ADVERTISING_CODING_SELECTION
+    ADVERTISING_CODING_SELECTION_HOST_SUPPORT      = 1 << LeFeature.ADVERTISING_CODING_SELECTION_HOST_SUPPORT
+    PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER
+    PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER    = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER
+
+class LmpFeature(enum.IntEnum):
+    # Page 0 (Legacy LMP features)
+    LMP_3_SLOT_PACKETS                                           = 0
+    LMP_5_SLOT_PACKETS                                           = 1
+    ENCRYPTION                                                   = 2
+    SLOT_OFFSET                                                  = 3
+    TIMING_ACCURACY                                              = 4
+    ROLE_SWITCH                                                  = 5
+    HOLD_MODE                                                    = 6
+    SNIFF_MODE                                                   = 7
+    # PREVIOUSLY_USED                                            = 8
+    POWER_CONTROL_REQUESTS                                       = 9
+    CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR                       = 10
+    SCO_LINK                                                     = 11
+    HV2_PACKETS                                                  = 12
+    HV3_PACKETS                                                  = 13
+    U_LAW_LOG_SYNCHRONOUS_DATA                                   = 14
+    A_LAW_LOG_SYNCHRONOUS_DATA                                   = 15
+    CVSD_SYNCHRONOUS_DATA                                        = 16
+    PAGING_PARAMETER_NEGOTIATION                                 = 17
+    POWER_CONTROL                                                = 18
+    TRANSPARENT_SYNCHRONOUS_DATA                                 = 19
+    FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT                       = 20
+    FLOW_CONTROL_LAG_MIDDLE_BIT                                  = 21
+    FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT                        = 22
+    BROADCAST_ENCRYPTION                                         = 23
+    # RESERVED_FOR_FUTURE_USE                                    = 24
+    ENHANCED_DATA_RATE_ACL_2_MBPS_MODE                           = 25
+    ENHANCED_DATA_RATE_ACL_3_MBPS_MODE                           = 26
+    ENHANCED_INQUIRY_SCAN                                        = 27
+    INTERLACED_INQUIRY_SCAN                                      = 28
+    INTERLACED_PAGE_SCAN                                         = 29
+    RSSI_WITH_INQUIRY_RESULTS                                    = 30
+    EXTENDED_SCO_LINK_EV3_PACKETS                                = 31
+    EV4_PACKETS                                                  = 32
+    EV5_PACKETS                                                  = 33
+    # RESERVED_FOR_FUTURE_USE                                    = 34
+    AFH_CAPABLE_PERIPHERAL                                       = 35
+    AFH_CLASSIFICATION_PERIPHERAL                                = 36
+    BR_EDR_NOT_SUPPORTED                                         = 37
+    LE_SUPPORTED_CONTROLLER                                      = 38
+    LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS                    = 39
+    LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS                    = 40
+    SNIFF_SUBRATING                                              = 41
+    PAUSE_ENCRYPTION                                             = 42
+    AFH_CAPABLE_CENTRAL                                          = 43
+    AFH_CLASSIFICATION_CENTRAL                                   = 44
+    ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE                          = 45
+    ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE                          = 46
+    LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS                   = 47
+    EXTENDED_INQUIRY_RESPONSE                                    = 48
+    SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER = 49
+    # RESERVED_FOR_FUTURE_USE                                    = 50
+    SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT                     = 51
+    ENCAPSULATED_PDU                                             = 52
+    ERRONEOUS_DATA_REPORTING                                     = 53
+    NON_FLUSHABLE_PACKET_BOUNDARY_FLAG                           = 54
+    # RESERVED_FOR_FUTURE_USE                                    = 55
+    HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT                   = 56
+    VARIABLE_INQUIRY_TX_POWER_LEVEL                              = 57
+    ENHANCED_POWER_CONTROL                                       = 58
+    # RESERVED_FOR_FUTURE_USE                                    = 59
+    # RESERVED_FOR_FUTURE_USE                                    = 60
+    # RESERVED_FOR_FUTURE_USE                                    = 61
+    # RESERVED_FOR_FUTURE_USE                                    = 62
+    EXTENDED_FEATURES                                            = 63
+
+    # Page 1
+    SECURE_SIMPLE_PAIRING_HOST_SUPPORT                           = 64
+    LE_SUPPORTED_HOST                                            = 65
+    # PREVIOUSLY_USED                                            = 66
+    SECURE_CONNECTIONS_HOST_SUPPORT                              = 67
+
+    # Page 2
+    CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION    = 128
+    CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION       = 129
+    SYNCHRONIZATION_TRAIN                                        = 130
+    SYNCHRONIZATION_SCAN                                         = 131
+    HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT                      = 132
+    GENERALIZED_INTERLACED_SCAN                                  = 133
+    COARSE_CLOCK_ADJUSTMENT                                      = 134
+    RESERVED_FOR_FUTURE_USE                                      = 135
+    SECURE_CONNECTIONS_CONTROLLER_SUPPORT                        = 136
+    PING                                                         = 137
+    SLOT_AVAILABILITY_MASK                                       = 138
+    TRAIN_NUDGING                                                = 139
+
+class LmpFeatureMask(enum.IntFlag):
+    # Page 0 (Legacy LMP features)
+    LMP_3_SLOT_PACKETS                                           = (1 << LmpFeature.LMP_3_SLOT_PACKETS)
+    LMP_5_SLOT_PACKETS                                           = (1 << LmpFeature.LMP_5_SLOT_PACKETS)
+    ENCRYPTION                                                   = (1 << LmpFeature.ENCRYPTION)
+    SLOT_OFFSET                                                  = (1 << LmpFeature.SLOT_OFFSET)
+    TIMING_ACCURACY                                              = (1 << LmpFeature.TIMING_ACCURACY)
+    ROLE_SWITCH                                                  = (1 << LmpFeature.ROLE_SWITCH)
+    HOLD_MODE                                                    = (1 << LmpFeature.HOLD_MODE)
+    SNIFF_MODE                                                   = (1 << LmpFeature.SNIFF_MODE)
+    # PREVIOUSLY_USED                                            = (1 << LmpFeature.PREVIOUSLY_USED)
+    POWER_CONTROL_REQUESTS                                       = (1 << LmpFeature.POWER_CONTROL_REQUESTS)
+    CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR                       = (1 << LmpFeature.CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR)
+    SCO_LINK                                                     = (1 << LmpFeature.SCO_LINK)
+    HV2_PACKETS                                                  = (1 << LmpFeature.HV2_PACKETS)
+    HV3_PACKETS                                                  = (1 << LmpFeature.HV3_PACKETS)
+    U_LAW_LOG_SYNCHRONOUS_DATA                                   = (1 << LmpFeature.U_LAW_LOG_SYNCHRONOUS_DATA)
+    A_LAW_LOG_SYNCHRONOUS_DATA                                   = (1 << LmpFeature.A_LAW_LOG_SYNCHRONOUS_DATA)
+    CVSD_SYNCHRONOUS_DATA                                        = (1 << LmpFeature.CVSD_SYNCHRONOUS_DATA)
+    PAGING_PARAMETER_NEGOTIATION                                 = (1 << LmpFeature.PAGING_PARAMETER_NEGOTIATION)
+    POWER_CONTROL                                                = (1 << LmpFeature.POWER_CONTROL)
+    TRANSPARENT_SYNCHRONOUS_DATA                                 = (1 << LmpFeature.TRANSPARENT_SYNCHRONOUS_DATA)
+    FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT                       = (1 << LmpFeature.FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT)
+    FLOW_CONTROL_LAG_MIDDLE_BIT                                  = (1 << LmpFeature.FLOW_CONTROL_LAG_MIDDLE_BIT)
+    FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT                        = (1 << LmpFeature.FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT)
+    BROADCAST_ENCRYPTION                                         = (1 << LmpFeature.BROADCAST_ENCRYPTION)
+    # RESERVED_FOR_FUTURE_USE                                    = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    ENHANCED_DATA_RATE_ACL_2_MBPS_MODE                           = (1 << LmpFeature.ENHANCED_DATA_RATE_ACL_2_MBPS_MODE)
+    ENHANCED_DATA_RATE_ACL_3_MBPS_MODE                           = (1 << LmpFeature.ENHANCED_DATA_RATE_ACL_3_MBPS_MODE)
+    ENHANCED_INQUIRY_SCAN                                        = (1 << LmpFeature.ENHANCED_INQUIRY_SCAN)
+    INTERLACED_INQUIRY_SCAN                                      = (1 << LmpFeature.INTERLACED_INQUIRY_SCAN)
+    INTERLACED_PAGE_SCAN                                         = (1 << LmpFeature.INTERLACED_PAGE_SCAN)
+    RSSI_WITH_INQUIRY_RESULTS                                    = (1 << LmpFeature.RSSI_WITH_INQUIRY_RESULTS)
+    EXTENDED_SCO_LINK_EV3_PACKETS                                = (1 << LmpFeature.EXTENDED_SCO_LINK_EV3_PACKETS)
+    EV4_PACKETS                                                  = (1 << LmpFeature.EV4_PACKETS)
+    EV5_PACKETS                                                  = (1 << LmpFeature.EV5_PACKETS)
+    # RESERVED_FOR_FUTURE_USE                                    = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    AFH_CAPABLE_PERIPHERAL                                       = (1 << LmpFeature.AFH_CAPABLE_PERIPHERAL)
+    AFH_CLASSIFICATION_PERIPHERAL                                = (1 << LmpFeature.AFH_CLASSIFICATION_PERIPHERAL)
+    BR_EDR_NOT_SUPPORTED                                         = (1 << LmpFeature.BR_EDR_NOT_SUPPORTED)
+    LE_SUPPORTED_CONTROLLER                                      = (1 << LmpFeature.LE_SUPPORTED_CONTROLLER)
+    LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS                    = (1 << LmpFeature.LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS)
+    LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS                    = (1 << LmpFeature.LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS)
+    SNIFF_SUBRATING                                              = (1 << LmpFeature.SNIFF_SUBRATING)
+    PAUSE_ENCRYPTION                                             = (1 << LmpFeature.PAUSE_ENCRYPTION)
+    AFH_CAPABLE_CENTRAL                                          = (1 << LmpFeature.AFH_CAPABLE_CENTRAL)
+    AFH_CLASSIFICATION_CENTRAL                                   = (1 << LmpFeature.AFH_CLASSIFICATION_CENTRAL)
+    ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE                          = (1 << LmpFeature.ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE)
+    ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE                          = (1 << LmpFeature.ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE)
+    LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS                   = (1 << LmpFeature.LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS)
+    EXTENDED_INQUIRY_RESPONSE                                    = (1 << LmpFeature.EXTENDED_INQUIRY_RESPONSE)
+    SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER = (1 << LmpFeature.SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER)
+    # RESERVED_FOR_FUTURE_USE                                    = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT                     = (1 << LmpFeature.SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT)
+    ENCAPSULATED_PDU                                             = (1 << LmpFeature.ENCAPSULATED_PDU)
+    ERRONEOUS_DATA_REPORTING                                     = (1 << LmpFeature.ERRONEOUS_DATA_REPORTING)
+    NON_FLUSHABLE_PACKET_BOUNDARY_FLAG                           = (1 << LmpFeature.NON_FLUSHABLE_PACKET_BOUNDARY_FLAG)
+    # RESERVED_FOR_FUTURE_USE                                    = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT                   = (1 << LmpFeature.HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT)
+    VARIABLE_INQUIRY_TX_POWER_LEVEL                              = (1 << LmpFeature.VARIABLE_INQUIRY_TX_POWER_LEVEL)
+    ENHANCED_POWER_CONTROL                                       = (1 << LmpFeature.ENHANCED_POWER_CONTROL)
+    # RESERVED_FOR_FUTURE_USE                                    = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    # RESERVED_FOR_FUTURE_USE                                    = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    # RESERVED_FOR_FUTURE_USE                                    = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    # RESERVED_FOR_FUTURE_USE                                    = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    EXTENDED_FEATURES                                            = (1 << LmpFeature.EXTENDED_FEATURES)
+
+    # Page 1
+    SECURE_SIMPLE_PAIRING_HOST_SUPPORT                           = (1 << LmpFeature.SECURE_SIMPLE_PAIRING_HOST_SUPPORT)
+    LE_SUPPORTED_HOST                                            = (1 << LmpFeature.LE_SUPPORTED_HOST)
+    # PREVIOUSLY_USED                                            = (1 << LmpFeature.PREVIOUSLY_USED)
+    SECURE_CONNECTIONS_HOST_SUPPORT                              = (1 << LmpFeature.SECURE_CONNECTIONS_HOST_SUPPORT)
+
+    # Page 2
+    CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION    = (1 << LmpFeature.CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION)
+    CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION       = (1 << LmpFeature.CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION)
+    SYNCHRONIZATION_TRAIN                                        = (1 << LmpFeature.SYNCHRONIZATION_TRAIN)
+    SYNCHRONIZATION_SCAN                                         = (1 << LmpFeature.SYNCHRONIZATION_SCAN)
+    HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT                      = (1 << LmpFeature.HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT)
+    GENERALIZED_INTERLACED_SCAN                                  = (1 << LmpFeature.GENERALIZED_INTERLACED_SCAN)
+    COARSE_CLOCK_ADJUSTMENT                                      = (1 << LmpFeature.COARSE_CLOCK_ADJUSTMENT)
+    RESERVED_FOR_FUTURE_USE                                      = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+    SECURE_CONNECTIONS_CONTROLLER_SUPPORT                        = (1 << LmpFeature.SECURE_CONNECTIONS_CONTROLLER_SUPPORT)
+    PING                                                         = (1 << LmpFeature.PING)
+    SLOT_AVAILABILITY_MASK                                       = (1 << LmpFeature.SLOT_AVAILABILITY_MASK)
+    TRAIN_NUDGING                                                = (1 << LmpFeature.TRAIN_NUDGING)
+
 
 # fmt: on
 # pylint: enable=line-too-long
@@ -1379,6 +1380,45 @@
 STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
 
 
+class CodecID(enum.IntEnum):
+    # fmt: off
+    U_LOG           = 0x00
+    A_LOG           = 0x01
+    CVSD            = 0x02
+    TRANSPARENT     = 0x03
+    LINEAR_PCM      = 0x04
+    MSBC            = 0x05
+    LC3             = 0x06
+    G729A           = 0x07
+    VENDOR_SPECIFIC = 0xFF
+
+
+@dataclasses.dataclass(frozen=True)
+class CodingFormat:
+    codec_id: CodecID
+    company_id: int = 0
+    vendor_specific_codec_id: int = 0
+
+    @classmethod
+    def parse_from_bytes(cls, data: bytes, offset: int):
+        (codec_id, company_id, vendor_specific_codec_id) = struct.unpack_from(
+            '<BHH', data, offset
+        )
+        return offset + 5, cls(
+            codec_id=CodecID(codec_id),
+            company_id=company_id,
+            vendor_specific_codec_id=vendor_specific_codec_id,
+        )
+
+    def to_bytes(self) -> bytes:
+        return struct.pack(
+            '<BHH', self.codec_id, self.company_id, self.vendor_specific_codec_id
+        )
+
+    def __bytes__(self) -> bytes:
+        return self.to_bytes()
+
+
 # -----------------------------------------------------------------------------
 class HCI_Constant:
     @staticmethod
@@ -1474,6 +1514,12 @@
             # The rest of the bytes
             field_value = data[offset:]
             return (field_value, len(field_value))
+        if field_type == 'v':
+            # Variable-length bytes field, with 1-byte length at the beginning
+            field_length = data[offset]
+            offset += 1
+            field_value = data[offset : offset + field_length]
+            return (field_value, field_length + 1)
         if field_type == 1:
             # 8-bit unsigned
             return (data[offset], 1)
@@ -1578,6 +1624,11 @@
                     raise ValueError('value too large for *-typed field')
             else:
                 field_bytes = bytes(field_value)
+        elif field_type == 'v':
+            # Variable-length bytes field, with 1-byte length at the beginning
+            field_bytes = bytes(field_value)
+            field_length = len(field_bytes)
+            field_bytes = bytes([field_length]) + field_bytes
         elif isinstance(field_value, (bytes, bytearray)) or hasattr(
             field_value, 'to_bytes'
         ):
@@ -1792,6 +1843,43 @@
         address_type = data[offset - 1]
         return Address.parse_address_with_type(data, offset, address_type)
 
+    @classmethod
+    def generate_static_address(cls) -> Address:
+        '''Generates Random Static Address, with the 2 most significant bits of 0b11.
+
+        See Bluetooth spec, Vol 6, Part B - Table 1.2.
+        '''
+        address_bytes = secrets.token_bytes(6)
+        address_bytes = address_bytes[:5] + bytes([address_bytes[5] | 0b11000000])
+        return Address(
+            address=address_bytes, address_type=Address.RANDOM_DEVICE_ADDRESS
+        )
+
+    @classmethod
+    def generate_private_address(cls, irk: bytes = b'') -> Address:
+        '''Generates Random Private MAC Address.
+
+        If IRK is present, a Resolvable Private Address, with the 2 most significant
+        bits of 0b01 will be generated. Otherwise, a Non-resolvable Private Address,
+        with the 2 most significant bits of 0b00 will be generated.
+
+        See Bluetooth spec, Vol 6, Part B - Table 1.2.
+
+        Args:
+            irk: Local Identity Resolving Key(IRK), in little-endian. If not set, a
+            non-resolvable address will be generated.
+        '''
+        if irk:
+            prand = crypto.generate_prand()
+            address_bytes = crypto.ah(irk, prand) + prand
+        else:
+            address_bytes = secrets.token_bytes(6)
+            address_bytes = address_bytes[:5] + bytes([address_bytes[5] & 0b00111111])
+
+        return Address(
+            address=address_bytes, address_type=Address.RANDOM_DEVICE_ADDRESS
+        )
+
     def __init__(
         self, address: Union[bytes, str], address_type: int = RANDOM_DEVICE_ADDRESS
     ):
@@ -1885,26 +1973,28 @@
 Address.ANY = Address(b"\x00\x00\x00\x00\x00\x00", Address.PUBLIC_DEVICE_ADDRESS)
 Address.ANY_RANDOM = Address(b"\x00\x00\x00\x00\x00\x00", Address.RANDOM_DEVICE_ADDRESS)
 
+
 # -----------------------------------------------------------------------------
-class OwnAddressType:
+class OwnAddressType(enum.IntEnum):
     PUBLIC = 0
     RANDOM = 1
     RESOLVABLE_OR_PUBLIC = 2
     RESOLVABLE_OR_RANDOM = 3
 
-    TYPE_NAMES = {
-        PUBLIC: 'PUBLIC',
-        RANDOM: 'RANDOM',
-        RESOLVABLE_OR_PUBLIC: 'RESOLVABLE_OR_PUBLIC',
-        RESOLVABLE_OR_RANDOM: 'RESOLVABLE_OR_RANDOM',
-    }
+    @classmethod
+    def type_spec(cls):
+        return {'size': 1, 'mapper': lambda x: OwnAddressType(x).name}
 
-    @staticmethod
-    def type_name(type_id):
-        return name_or_number(OwnAddressType.TYPE_NAMES, type_id)
 
-    # pylint: disable-next=unnecessary-lambda
-    TYPE_SPEC = {'size': 1, 'mapper': lambda x: OwnAddressType.type_name(x)}
+# -----------------------------------------------------------------------------
+class LoopbackMode(enum.IntEnum):
+    DISABLED = 0
+    LOCAL = 1
+    REMOTE = 2
+
+    @classmethod
+    def type_spec(cls):
+        return {'size': 1, 'mapper': lambda x: LoopbackMode(x).name}
 
 
 # -----------------------------------------------------------------------------
@@ -1925,9 +2015,15 @@
         if packet_type == HCI_ACL_DATA_PACKET:
             return HCI_AclDataPacket.from_bytes(packet)
 
+        if packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
+            return HCI_SynchronousDataPacket.from_bytes(packet)
+
         if packet_type == HCI_EVENT_PACKET:
             return HCI_Event.from_bytes(packet)
 
+        if packet_type == HCI_ISO_DATA_PACKET:
+            return HCI_IsoDataPacket.from_bytes(packet)
+
         return HCI_CustomPacket(packet)
 
     def __init__(self, name):
@@ -1960,6 +2056,7 @@
     hci_packet_type = HCI_COMMAND_PACKET
     command_names: Dict[int, str] = {}
     command_classes: Dict[int, Type[HCI_Command]] = {}
+    op_code: int
 
     @staticmethod
     def command(fields=(), return_parameters_fields=()):
@@ -2045,7 +2142,11 @@
         return_parameters.fields = cls.return_parameters_fields
         return return_parameters
 
-    def __init__(self, op_code, parameters=None, **kwargs):
+    def __init__(self, op_code=-1, parameters=None, **kwargs):
+        # Since the legacy implementation relies on an __init__ injector, typing always
+        # complains that positional argument op_code is not passed, so here sets a
+        # default value to allow building derived HCI_Command without op_code.
+        assert op_code != -1
         super().__init__(HCI_Command.command_name(op_code))
         if (fields := getattr(self, 'fields', None)) and kwargs:
             HCI_Object.init_from_fields(self, fields, kwargs)
@@ -2297,6 +2398,19 @@
 @HCI_Command.command(
     fields=[
         ('bd_addr', Address.parse_address),
+        ('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
+    ],
+)
+class HCI_Reject_Synchronous_Connection_Request_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.28 Reject Synchronous Connection Request Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('bd_addr', Address.parse_address),
         ('io_capability', {'size': 1, 'mapper': HCI_Constant.io_capability_name}),
         ('oob_data_present', 1),
         (
@@ -2426,14 +2540,14 @@
         ('connection_handle', 2),
         ('transmit_bandwidth', 4),
         ('receive_bandwidth', 4),
-        ('transmit_coding_format', 5),
-        ('receive_coding_format', 5),
+        ('transmit_coding_format', CodingFormat.parse_from_bytes),
+        ('receive_coding_format', CodingFormat.parse_from_bytes),
         ('transmit_codec_frame_size', 2),
         ('receive_codec_frame_size', 2),
         ('input_bandwidth', 4),
         ('output_bandwidth', 4),
-        ('input_coding_format', 5),
-        ('output_coding_format', 5),
+        ('input_coding_format', CodingFormat.parse_from_bytes),
+        ('output_coding_format', CodingFormat.parse_from_bytes),
         ('input_coded_data_size', 2),
         ('output_coded_data_size', 2),
         ('input_pcm_data_format', 1),
@@ -2454,6 +2568,35 @@
     See Bluetooth spec @ 7.1.45 Enhanced Setup Synchronous Connection Command
     '''
 
+    class PcmDataFormat(enum.IntEnum):
+        NA = 0x00
+        ONES_COMPLEMENT = 0x01
+        TWOS_COMPLEMENT = 0x02
+        SIGN_MAGNITUDE = 0x03
+        UNSIGNED = 0x04
+
+    class DataPath(enum.IntEnum):
+        HCI = 0x00
+        PCM = 0x01
+
+    class RetransmissionEffort(enum.IntEnum):
+        NO_RETRANSMISSION = 0x00
+        OPTIMIZE_FOR_POWER = 0x01
+        OPTIMIZE_FOR_QUALITY = 0x02
+        DONT_CARE = 0xFF
+
+    class PacketType(enum.IntFlag):
+        HV1 = 0x0001
+        HV2 = 0x0002
+        HV3 = 0x0004
+        EV3 = 0x0008
+        EV4 = 0x0010
+        EV5 = 0x0020
+        NO_2_EV3 = 0x0040
+        NO_3_EV3 = 0x0080
+        NO_2_EV5 = 0x0100
+        NO_3_EV5 = 0x0200
+
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
@@ -2461,14 +2604,14 @@
         ('bd_addr', Address.parse_address),
         ('transmit_bandwidth', 4),
         ('receive_bandwidth', 4),
-        ('transmit_coding_format', 5),
-        ('receive_coding_format', 5),
+        ('transmit_coding_format', CodingFormat.parse_from_bytes),
+        ('receive_coding_format', CodingFormat.parse_from_bytes),
         ('transmit_codec_frame_size', 2),
         ('receive_codec_frame_size', 2),
         ('input_bandwidth', 4),
         ('output_bandwidth', 4),
-        ('input_coding_format', 5),
-        ('output_coding_format', 5),
+        ('input_coding_format', CodingFormat.parse_from_bytes),
+        ('output_coding_format', CodingFormat.parse_from_bytes),
         ('input_coded_data_size', 2),
         ('output_coded_data_size', 2),
         ('input_pcm_data_format', 1),
@@ -2685,6 +2828,20 @@
     See Bluetooth spec @ 7.3.1 Set Event Mask Command
     '''
 
+    @staticmethod
+    def mask(event_codes: Iterable[int]) -> bytes:
+        '''
+        Compute the event mask value for a list of events.
+        '''
+        # NOTE: this implementation takes advantage of the fact that as of version 5.4
+        # of the core specification, the bit number for each event code is equal to one
+        # less than the event code.
+        # If future versions of the specification deviate from that, a different
+        # implementation would be needed.
+        return sum((1 << event_code - 1) for event_code in event_codes).to_bytes(
+            8, 'little'
+        )
+
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command()
@@ -3094,7 +3251,12 @@
 
 
 # -----------------------------------------------------------------------------
-@HCI_Command.command()
+@HCI_Command.command(
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('lmp_features', 8),
+    ]
+)
 class HCI_Read_Local_Supported_Features_Command(HCI_Command):
     '''
     See Bluetooth spec @ 7.4.3 Read Local Supported Features Command
@@ -3181,12 +3343,47 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Command.command(
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('loopback_mode', LoopbackMode.type_spec()),
+    ],
+)
+class HCI_Read_Loopback_Mode_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.6.1 Read Loopback Mode Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([('loopback_mode', 1)])
+class HCI_Write_Loopback_Mode_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.6.2 Write Loopback Mode Command
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Command.command([('le_event_mask', 8)])
 class HCI_LE_Set_Event_Mask_Command(HCI_Command):
     '''
     See Bluetooth spec @ 7.8.1 LE Set Event Mask Command
     '''
 
+    @staticmethod
+    def mask(event_codes: Iterable[int]) -> bytes:
+        '''
+        Compute the event mask value for a list of events.
+        '''
+        # NOTE: this implementation takes advantage of the fact that as of version 5.4
+        # of the core specification, the bit number for each event code is equal to one
+        # less than the event code.
+        # If future versions of the specification deviate from that, a different
+        # implementation would be needed.
+        return sum((1 << event_code - 1) for event_code in event_codes).to_bytes(
+            8, 'little'
+        )
+
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
@@ -3244,7 +3441,7 @@
                 ),
             },
         ),
-        ('own_address_type', OwnAddressType.TYPE_SPEC),
+        ('own_address_type', OwnAddressType.type_spec()),
         ('peer_address_type', Address.ADDRESS_TYPE_SPEC),
         ('peer_address', Address.parse_address_preceded_by_type),
         ('advertising_channel_map', 1),
@@ -3337,7 +3534,7 @@
         ('le_scan_type', 1),
         ('le_scan_interval', 2),
         ('le_scan_window', 2),
-        ('own_address_type', OwnAddressType.TYPE_SPEC),
+        ('own_address_type', OwnAddressType.type_spec()),
         ('scanning_filter_policy', 1),
     ]
 )
@@ -3376,7 +3573,7 @@
         ('initiator_filter_policy', 1),
         ('peer_address_type', Address.ADDRESS_TYPE_SPEC),
         ('peer_address', Address.parse_address_preceded_by_type),
-        ('own_address_type', OwnAddressType.TYPE_SPEC),
+        ('own_address_type', OwnAddressType.type_spec()),
         ('connection_interval_min', 2),
         ('connection_interval_max', 2),
         ('max_latency', 2),
@@ -3765,8 +3962,10 @@
             'advertising_event_properties',
             {
                 'size': 2,
-                'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.advertising_properties_string(
-                    x
+                'mapper': lambda x: str(
+                    HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(
+                        x
+                    )
                 ),
             },
         ),
@@ -3776,12 +3975,12 @@
             'primary_advertising_channel_map',
             {
                 'size': 1,
-                'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.channel_map_string(
-                    x
+                'mapper': lambda x: str(
+                    HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap(x)
                 ),
             },
         ),
-        ('own_address_type', OwnAddressType.TYPE_SPEC),
+        ('own_address_type', OwnAddressType.type_spec()),
         ('peer_address_type', Address.ADDRESS_TYPE_SPEC),
         ('peer_address', Address.parse_address_preceded_by_type),
         ('advertising_filter_policy', 1),
@@ -3792,45 +3991,43 @@
         ('advertising_sid', 1),
         ('scan_request_notification_enable', 1),
     ],
-    return_parameters_fields=[('status', STATUS_SPEC), ('selected_tx__power', 1)],
+    return_parameters_fields=[('status', STATUS_SPEC), ('selected_tx_power', 1)],
 )
 class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
     '''
     See Bluetooth spec @ 7.8.53 LE Set Extended Advertising Parameters Command
     '''
 
-    CONNECTABLE_ADVERTISING = 0
-    SCANNABLE_ADVERTISING = 1
-    DIRECTED_ADVERTISING = 2
-    HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 3
-    USE_LEGACY_ADVERTISING_PDUS = 4
-    ANONYMOUS_ADVERTISING = 5
-    INCLUDE_TX_POWER = 6
+    TX_POWER_NO_PREFERENCE = 0x7F
+    SHOULD_NOT_FRAGMENT = 0x01
 
-    ADVERTISING_PROPERTIES_NAMES = (
-        'CONNECTABLE_ADVERTISING',
-        'SCANNABLE_ADVERTISING',
-        'DIRECTED_ADVERTISING',
-        'HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING',
-        'USE_LEGACY_ADVERTISING_PDUS',
-        'ANONYMOUS_ADVERTISING',
-        'INCLUDE_TX_POWER',
-    )
+    class AdvertisingProperties(enum.IntFlag):
+        CONNECTABLE_ADVERTISING = 1 << 0
+        SCANNABLE_ADVERTISING = 1 << 1
+        DIRECTED_ADVERTISING = 1 << 2
+        HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 1 << 3
+        USE_LEGACY_ADVERTISING_PDUS = 1 << 4
+        ANONYMOUS_ADVERTISING = 1 << 5
+        INCLUDE_TX_POWER = 1 << 6
 
-    CHANNEL_37 = 0
-    CHANNEL_38 = 1
-    CHANNEL_39 = 2
+        def __str__(self) -> str:
+            return '|'.join(
+                flag.name
+                for flag in HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties
+                if self.value & flag.value and flag.name is not None
+            )
 
-    CHANNEL_NAMES = ('37', '38', '39')
+    class ChannelMap(enum.IntFlag):
+        CHANNEL_37 = 1 << 0
+        CHANNEL_38 = 1 << 1
+        CHANNEL_39 = 1 << 2
 
-    @classmethod
-    def advertising_properties_string(cls, properties):
-        # pylint: disable=line-too-long
-        return f'[{",".join(bit_flags_to_strings(properties, cls.ADVERTISING_PROPERTIES_NAMES))}]'
-
-    @classmethod
-    def channel_map_string(cls, channel_map):
-        return f'[{",".join(bit_flags_to_strings(channel_map, cls.CHANNEL_NAMES))}]'
+        def __str__(self) -> str:
+            return '|'.join(
+                flag.name
+                for flag in HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
+                if self.value & flag.value and flag.name is not None
+            )
 
 
 # -----------------------------------------------------------------------------
@@ -3842,9 +4039,9 @@
             'operation',
             {
                 'size': 1,
-                'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(
+                'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
                     x
-                ),
+                ).name,
             },
         ),
         ('fragment_preference', 1),
@@ -3862,23 +4059,12 @@
     See Bluetooth spec @ 7.8.54 LE Set Extended Advertising Data Command
     '''
 
-    INTERMEDIATE_FRAGMENT = 0x00
-    FIRST_FRAGMENT = 0x01
-    LAST_FRAGMENT = 0x02
-    COMPLETE_DATA = 0x03
-    UNCHANGED_DATA = 0x04
-
-    OPERATION_NAMES = {
-        INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
-        FIRST_FRAGMENT: 'FIRST_FRAGMENT',
-        LAST_FRAGMENT: 'LAST_FRAGMENT',
-        COMPLETE_DATA: 'COMPLETE_DATA',
-        UNCHANGED_DATA: 'UNCHANGED_DATA',
-    }
-
-    @classmethod
-    def operation_name(cls, operation):
-        return name_or_number(cls.OPERATION_NAMES, operation)
+    class Operation(enum.IntEnum):
+        INTERMEDIATE_FRAGMENT = 0x00
+        FIRST_FRAGMENT = 0x01
+        LAST_FRAGMENT = 0x02
+        COMPLETE_DATA = 0x03
+        UNCHANGED_DATA = 0x04
 
 
 # -----------------------------------------------------------------------------
@@ -3890,9 +4076,9 @@
             'operation',
             {
                 'size': 1,
-                'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(
+                'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
                     x
-                ),
+                ).name,
             },
         ),
         ('fragment_preference', 1),
@@ -3910,22 +4096,6 @@
     See Bluetooth spec @ 7.8.55 LE Set Extended Scan Response Data Command
     '''
 
-    INTERMEDIATE_FRAGMENT = 0x00
-    FIRST_FRAGMENT = 0x01
-    LAST_FRAGMENT = 0x02
-    COMPLETE_DATA = 0x03
-
-    OPERATION_NAMES = {
-        INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
-        FIRST_FRAGMENT: 'FIRST_FRAGMENT',
-        LAST_FRAGMENT: 'LAST_FRAGMENT',
-        COMPLETE_DATA: 'COMPLETE_DATA',
-    }
-
-    @classmethod
-    def operation_name(cls, operation):
-        return name_or_number(cls.OPERATION_NAMES, operation)
-
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
@@ -4075,7 +4245,7 @@
             ('scanning_filter_policy:', self.scanning_filter_policy),
             ('scanning_phys:         ', ','.join(scanning_phys_strs)),
         ]
-        for (i, scanning_phy_str) in enumerate(scanning_phys_strs):
+        for i, scanning_phy_str in enumerate(scanning_phys_strs):
             fields.append(
                 (
                     f'{scanning_phy_str}.scan_type:    ',
@@ -4209,7 +4379,7 @@
             ('initiator_filter_policy:', self.initiator_filter_policy),
             (
                 'own_address_type:       ',
-                OwnAddressType.type_name(self.own_address_type),
+                OwnAddressType(self.own_address_type).name,
             ),
             (
                 'peer_address_type:      ',
@@ -4218,7 +4388,7 @@
             ('peer_address:           ', str(self.peer_address)),
             ('initiating_phys:        ', ','.join(initiating_phys_strs)),
         ]
-        for (i, initiating_phys_str) in enumerate(initiating_phys_strs):
+        for i, initiating_phys_str in enumerate(initiating_phys_strs):
             fields.append(
                 (
                     f'{initiating_phys_str}.scan_interval:          ',
@@ -4324,6 +4494,166 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('cig_id', 1),
+        ('sdu_interval_c_to_p', 3),
+        ('sdu_interval_p_to_c', 3),
+        ('worst_case_sca', 1),
+        ('packing', 1),
+        ('framing', 1),
+        ('max_transport_latency_c_to_p', 2),
+        ('max_transport_latency_p_to_c', 2),
+        [
+            ('cis_id', 1),
+            ('max_sdu_c_to_p', 2),
+            ('max_sdu_p_to_c', 2),
+            ('phy_c_to_p', 1),
+            ('phy_p_to_c', 1),
+            ('rtn_c_to_p', 1),
+            ('rtn_p_to_c', 1),
+        ],
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('cig_id', 1),
+        [('connection_handle', 2)],
+    ],
+)
+class HCI_LE_Set_CIG_Parameters_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.97 LE Set CIG Parameters Command
+    '''
+
+    cig_id: int
+    sdu_interval_c_to_p: int
+    sdu_interval_p_to_c: int
+    worst_case_sca: int
+    packing: int
+    framing: int
+    max_transport_latency_c_to_p: int
+    max_transport_latency_p_to_c: int
+    cis_id: List[int]
+    max_sdu_c_to_p: List[int]
+    max_sdu_p_to_c: List[int]
+    phy_c_to_p: List[int]
+    phy_p_to_c: List[int]
+    rtn_c_to_p: List[int]
+    rtn_p_to_c: List[int]
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        [
+            ('cis_connection_handle', 2),
+            ('acl_connection_handle', 2),
+        ],
+    ],
+)
+class HCI_LE_Create_CIS_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.99 LE Create CIS command
+    '''
+
+    cis_connection_handle: List[int]
+    acl_connection_handle: List[int]
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[('cig_id', 1)],
+    return_parameters_fields=[('status', STATUS_SPEC), ('cig_id', 1)],
+)
+class HCI_LE_Remove_CIG_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.100 LE Remove CIG command
+    '''
+
+    cig_id: int
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[('connection_handle', 2)],
+)
+class HCI_LE_Accept_CIS_Request_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.101 LE Accept CIS Request command
+    '''
+
+    connection_handle: int
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('connection_handle', 2),
+        ('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
+    ],
+)
+class HCI_LE_Reject_CIS_Request_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.102 LE Reject CIS Request command
+    '''
+
+    connection_handle: int
+    reason: int
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('connection_handle', 2),
+        ('data_path_direction', 1),
+        ('data_path_id', 1),
+        ('codec_id', CodingFormat.parse_from_bytes),
+        ('controller_delay', 3),
+        ('codec_configuration', 'v'),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('connection_handle', 2),
+    ],
+)
+class HCI_LE_Setup_ISO_Data_Path_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.109 LE Setup ISO Data Path command
+    '''
+
+    class Direction(enum.IntEnum):
+        HOST_TO_CONTROLLER = 0x00
+        CONTROLLER_TO_HOST = 0x01
+
+    connection_handle: int
+    data_path_direction: int
+    data_path_id: int
+    codec_id: CodingFormat
+    controller_delay: int
+    codec_configuration: bytes
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('connection_handle', 2),
+        ('data_path_direction', 1),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('connection_handle', 2),
+    ],
+)
+class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.110 LE Remove ISO Data Path command
+    '''
+
+    connection_handle: int
+    data_path_direction: int
+
+
+# -----------------------------------------------------------------------------
 # HCI Events
 # -----------------------------------------------------------------------------
 class HCI_Event(HCI_Packet):
@@ -4431,7 +4761,11 @@
             HCI_Object.init_from_bytes(self, parameters, 0, fields)
         return self
 
-    def __init__(self, event_code, parameters=None, **kwargs):
+    def __init__(self, event_code=-1, parameters=None, **kwargs):
+        # Since the legacy implementation relies on an __init__ injector, typing always
+        # complains that positional argument event_code is not passed, so here sets a
+        # default value to allow building derived HCI_Event without event_code.
+        assert event_code != -1
         super().__init__(HCI_Event.event_name(event_code))
         if (fields := getattr(self, 'fields', None)) and kwargs:
             HCI_Object.init_from_fields(self, fields, kwargs)
@@ -4525,7 +4859,8 @@
             HCI_Object.init_from_bytes(self, parameters, 1, fields)
         return self
 
-    def __init__(self, subevent_code, parameters, **kwargs):
+    def __init__(self, subevent_code=None, parameters=None, **kwargs):
+        assert subevent_code is not None
         self.subevent_code = subevent_code
         if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
             parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
@@ -4935,6 +5270,21 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
+        ('status', 1),
+        ('advertising_handle', 1),
+        ('connection_handle', 2),
+        ('num_completed_extended_advertising_events', 1),
+    ]
+)
+class HCI_LE_Advertising_Set_Terminated_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.18 LE Advertising Set Terminated Event
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_LE_Meta_Event.event([('connection_handle', 2), ('channel_selection_algorithm', 1)])
 class HCI_LE_Channel_Selection_Algorithm_Event(HCI_LE_Meta_Event):
     '''
@@ -4943,6 +5293,48 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
+        ('status', STATUS_SPEC),
+        ('connection_handle', 2),
+        ('cig_sync_delay', 3),
+        ('cis_sync_delay', 3),
+        ('transport_latency_c_to_p', 3),
+        ('transport_latency_p_to_c', 3),
+        ('phy_c_to_p', 1),
+        ('phy_p_to_c', 1),
+        ('nse', 1),
+        ('bn_c_to_p', 1),
+        ('bn_p_to_c', 1),
+        ('ft_c_to_p', 1),
+        ('ft_p_to_c', 1),
+        ('max_pdu_c_to_p', 2),
+        ('max_pdu_p_to_c', 2),
+        ('iso_interval', 2),
+    ]
+)
+class HCI_LE_CIS_Established_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.25 LE CIS Established Event
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
+        ('acl_connection_handle', 2),
+        ('cis_connection_handle', 2),
+        ('cig_id', 1),
+        ('cis_id', 1),
+    ]
+)
+class HCI_LE_CIS_Request_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.26 LE CIS Request Event
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Event.event([('status', STATUS_SPEC)])
 class HCI_Inquiry_Complete_Event(HCI_Event):
     '''
@@ -5068,6 +5460,10 @@
     See Bluetooth spec @ 7.7.5 Disconnection Complete Event
     '''
 
+    status: int
+    connection_handle: int
+    reason: int
+
 
 # -----------------------------------------------------------------------------
 @HCI_Event.event([('status', STATUS_SPEC), ('connection_handle', 2)])
@@ -5739,6 +6135,168 @@
 
 
 # -----------------------------------------------------------------------------
+class HCI_SynchronousDataPacket(HCI_Packet):
+    '''
+    See Bluetooth spec @ 5.4.3 HCI SCO Data Packets
+    '''
+
+    hci_packet_type = HCI_SYNCHRONOUS_DATA_PACKET
+
+    @staticmethod
+    def from_bytes(packet: bytes) -> HCI_SynchronousDataPacket:
+        # Read the header
+        h, data_total_length = struct.unpack_from('<HB', packet, 1)
+        connection_handle = h & 0xFFF
+        packet_status = (h >> 12) & 0b11
+        data = packet[4:]
+        if len(data) != data_total_length:
+            raise ValueError(
+                f'invalid packet length {len(data)} != {data_total_length}'
+            )
+        return HCI_SynchronousDataPacket(
+            connection_handle, packet_status, data_total_length, data
+        )
+
+    def to_bytes(self) -> bytes:
+        h = (self.packet_status << 12) | self.connection_handle
+        return (
+            struct.pack('<BHB', HCI_SYNCHRONOUS_DATA_PACKET, h, self.data_total_length)
+            + self.data
+        )
+
+    def __init__(
+        self,
+        connection_handle: int,
+        packet_status: int,
+        data_total_length: int,
+        data: bytes,
+    ) -> None:
+        self.connection_handle = connection_handle
+        self.packet_status = packet_status
+        self.data_total_length = data_total_length
+        self.data = data
+
+    def __bytes__(self) -> bytes:
+        return self.to_bytes()
+
+    def __str__(self) -> str:
+        return (
+            f'{color("SCO", "blue")}: '
+            f'handle=0x{self.connection_handle:04x}, '
+            f'ps={self.packet_status}, '
+            f'data_total_length={self.data_total_length}, '
+            f'data={self.data.hex()}'
+        )
+
+
+# -----------------------------------------------------------------------------
+class HCI_IsoDataPacket(HCI_Packet):
+    '''
+    See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
+    '''
+
+    hci_packet_type = HCI_ISO_DATA_PACKET
+
+    @staticmethod
+    def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
+        time_stamp: Optional[int] = None
+        packet_sequence_number: Optional[int] = None
+        iso_sdu_length: Optional[int] = None
+        packet_status_flag: Optional[int] = None
+
+        pos = 1
+        pdu_info, data_total_length = struct.unpack_from('<HH', packet, pos)
+        connection_handle = pdu_info & 0xFFF
+        pb_flag = (pdu_info >> 12) & 0b11
+        ts_flag = (pdu_info >> 14) & 0b01
+        pos += 4
+
+        # pb_flag in (0b00, 0b10) but faster
+        should_include_sdu_info = not (pb_flag & 0b01)
+
+        if ts_flag:
+            if not should_include_sdu_info:
+                logger.warning(f'Timestamp included when pb_flag={bin(pb_flag)}')
+            time_stamp, *_ = struct.unpack_from('<I', packet, pos)
+            pos += 4
+
+        if should_include_sdu_info:
+            packet_sequence_number, sdu_info = struct.unpack_from('<HH', packet, pos)
+            iso_sdu_length = sdu_info & 0xFFF
+            packet_status_flag = sdu_info >> 14
+            pos += 4
+
+        iso_sdu_fragment = packet[pos:]
+        return HCI_IsoDataPacket(
+            connection_handle=connection_handle,
+            pb_flag=pb_flag,
+            ts_flag=ts_flag,
+            data_total_length=data_total_length,
+            time_stamp=time_stamp,
+            packet_sequence_number=packet_sequence_number,
+            iso_sdu_length=iso_sdu_length,
+            packet_status_flag=packet_status_flag,
+            iso_sdu_fragment=iso_sdu_fragment,
+        )
+
+    def __init__(
+        self,
+        connection_handle: int,
+        pb_flag: int,
+        ts_flag: int,
+        data_total_length: int,
+        time_stamp: Optional[int],
+        packet_sequence_number: Optional[int],
+        iso_sdu_length: Optional[int],
+        packet_status_flag: Optional[int],
+        iso_sdu_fragment: bytes,
+    ) -> None:
+        self.connection_handle = connection_handle
+        self.pb_flag = pb_flag
+        self.ts_flag = ts_flag
+        self.data_total_length = data_total_length
+        self.time_stamp = time_stamp
+        self.packet_sequence_number = packet_sequence_number
+        self.iso_sdu_length = iso_sdu_length
+        self.packet_status_flag = packet_status_flag
+        self.iso_sdu_fragment = iso_sdu_fragment
+
+    def __bytes__(self) -> bytes:
+        return self.to_bytes()
+
+    def to_bytes(self) -> bytes:
+        fmt = '<BHH'
+        args = [
+            HCI_ISO_DATA_PACKET,
+            self.ts_flag << 14 | self.pb_flag << 12 | self.connection_handle,
+            self.data_total_length,
+        ]
+        if self.time_stamp is not None:
+            fmt += 'I'
+            args.append(self.time_stamp)
+        if (
+            self.packet_sequence_number is not None
+            and self.iso_sdu_length is not None
+            and self.packet_status_flag is not None
+        ):
+            fmt += 'HH'
+            args += [
+                self.packet_sequence_number,
+                self.iso_sdu_length | self.packet_status_flag << 14,
+            ]
+        return struct.pack(fmt, *args) + self.iso_sdu_fragment
+
+    def __str__(self) -> str:
+        return (
+            f'{color("ISO", "blue")}: '
+            f'handle=0x{self.connection_handle:04x}, '
+            f'ps={self.packet_status_flag}, '
+            f'data_total_length={self.data_total_length}, '
+            f'sdu={self.iso_sdu_fragment.hex()}'
+        )
+
+
+# -----------------------------------------------------------------------------
 class HCI_AclDataPacketAssembler:
     current_data: Optional[bytes]
 
@@ -5771,7 +6329,7 @@
             self.current_data = None
             self.l2cap_pdu_length = 0
         else:
-            # Sanity check
+            # Compliance check
             if len(self.current_data) > self.l2cap_pdu_length + 4:
                 logger.warning('!!! ACL data exceeds L2CAP PDU')
                 self.current_data = None
diff --git a/bumble/helpers.py b/bumble/helpers.py
index 83c7c6d..80a376e 100644
--- a/bumble/helpers.py
+++ b/bumble/helpers.py
@@ -15,30 +15,46 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
+
+from collections.abc import Callable, MutableMapping
+import datetime
+from typing import cast, Any, Optional
 import logging
 
-from .colors import color
-from .att import ATT_CID, ATT_PDU
-from .smp import SMP_CID, SMP_Command
-from .core import name_or_number
-from .l2cap import (
+from bumble import avc
+from bumble import avctp
+from bumble import avdtp
+from bumble import avrcp
+from bumble import crypto
+from bumble import rfcomm
+from bumble import sdp
+from bumble.colors import color
+from bumble.att import ATT_CID, ATT_PDU
+from bumble.smp import SMP_CID, SMP_Command
+from bumble.core import name_or_number
+from bumble.l2cap import (
     L2CAP_PDU,
     L2CAP_CONNECTION_REQUEST,
     L2CAP_CONNECTION_RESPONSE,
     L2CAP_SIGNALING_CID,
     L2CAP_LE_SIGNALING_CID,
     L2CAP_Control_Frame,
+    L2CAP_Connection_Request,
     L2CAP_Connection_Response,
 )
-from .hci import (
+from bumble.hci import (
+    Address,
     HCI_EVENT_PACKET,
     HCI_ACL_DATA_PACKET,
     HCI_DISCONNECTION_COMPLETE_EVENT,
     HCI_AclDataPacketAssembler,
+    HCI_Packet,
+    HCI_Event,
+    HCI_AclDataPacket,
+    HCI_Disconnection_Complete_Event,
 )
-from .rfcomm import RFCOMM_Frame, RFCOMM_PSM
-from .sdp import SDP_PDU, SDP_PSM
-from .avdtp import MessageAssembler as AVDTP_MessageAssembler, AVDTP_PSM
+
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -48,26 +64,36 @@
 
 # -----------------------------------------------------------------------------
 PSM_NAMES = {
-    RFCOMM_PSM: 'RFCOMM',
-    SDP_PSM: 'SDP',
-    AVDTP_PSM: 'AVDTP'
+    rfcomm.RFCOMM_PSM: 'RFCOMM',
+    sdp.SDP_PSM: 'SDP',
+    avdtp.AVDTP_PSM: 'AVDTP',
+    avctp.AVCTP_PSM: 'AVCTP',
     # TODO: add more PSM values
 }
 
+AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
+
 
 # -----------------------------------------------------------------------------
 class PacketTracer:
     class AclStream:
-        def __init__(self, analyzer):
+        psms: MutableMapping[int, int]
+        peer: Optional[PacketTracer.AclStream]
+        avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
+        avctp_assemblers: MutableMapping[int, avctp.MessageAssembler]
+
+        def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
             self.analyzer = analyzer
             self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
             self.avdtp_assemblers = {}  # AVDTP assemblers, by source_cid
+            self.avctp_assemblers = {}  # AVCTP assemblers, by source_cid
             self.psms = {}  # PSM, by source_cid
-            self.peer = None  # ACL stream in the other direction
+            self.peer = None
 
         # pylint: disable=too-many-nested-blocks
-        def on_acl_pdu(self, pdu):
+        def on_acl_pdu(self, pdu: bytes) -> None:
             l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
+            self.analyzer.emit(l2cap_pdu)
 
             if l2cap_pdu.cid == ATT_CID:
                 att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
@@ -81,46 +107,59 @@
 
                 # Check if this signals a new channel
                 if control_frame.code == L2CAP_CONNECTION_REQUEST:
-                    self.psms[control_frame.source_cid] = control_frame.psm
+                    connection_request = cast(L2CAP_Connection_Request, control_frame)
+                    self.psms[connection_request.source_cid] = connection_request.psm
                 elif control_frame.code == L2CAP_CONNECTION_RESPONSE:
+                    connection_response = cast(L2CAP_Connection_Response, control_frame)
                     if (
-                        control_frame.result
+                        connection_response.result
                         == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
                     ):
-                        if self.peer:
-                            if psm := self.peer.psms.get(control_frame.source_cid):
-                                # Found a pending connection
-                                self.psms[control_frame.destination_cid] = psm
+                        if self.peer and (
+                            psm := self.peer.psms.get(connection_response.source_cid)
+                        ):
+                            # Found a pending connection
+                            self.psms[connection_response.destination_cid] = psm
 
-                                # For AVDTP connections, create a packet assembler for
-                                # each direction
-                                if psm == AVDTP_PSM:
-                                    self.avdtp_assemblers[
-                                        control_frame.source_cid
-                                    ] = AVDTP_MessageAssembler(self.on_avdtp_message)
-                                    self.peer.avdtp_assemblers[
-                                        control_frame.destination_cid
-                                    ] = AVDTP_MessageAssembler(
-                                        self.peer.on_avdtp_message
-                                    )
-
+                            # For AVDTP connections, create a packet assembler for
+                            # each direction
+                            if psm == avdtp.AVDTP_PSM:
+                                self.avdtp_assemblers[
+                                    connection_response.source_cid
+                                ] = avdtp.MessageAssembler(self.on_avdtp_message)
+                                self.peer.avdtp_assemblers[
+                                    connection_response.destination_cid
+                                ] = avdtp.MessageAssembler(self.peer.on_avdtp_message)
+                            elif psm == avctp.AVCTP_PSM:
+                                self.avctp_assemblers[
+                                    connection_response.source_cid
+                                ] = avctp.MessageAssembler(self.on_avctp_message)
+                                self.peer.avctp_assemblers[
+                                    connection_response.destination_cid
+                                ] = avctp.MessageAssembler(self.peer.on_avctp_message)
             else:
                 # Try to find the PSM associated with this PDU
                 if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
-                    if psm == SDP_PSM:
-                        sdp_pdu = SDP_PDU.from_bytes(l2cap_pdu.payload)
+                    if psm == sdp.SDP_PSM:
+                        sdp_pdu = sdp.SDP_PDU.from_bytes(l2cap_pdu.payload)
                         self.analyzer.emit(sdp_pdu)
-                    elif psm == RFCOMM_PSM:
-                        rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
+                    elif psm == rfcomm.RFCOMM_PSM:
+                        rfcomm_frame = rfcomm.RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
                         self.analyzer.emit(rfcomm_frame)
-                    elif psm == AVDTP_PSM:
+                    elif psm == avdtp.AVDTP_PSM:
                         self.analyzer.emit(
                             f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
                             f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
                         )
-                        assembler = self.avdtp_assemblers.get(l2cap_pdu.cid)
-                        if assembler:
-                            assembler.on_pdu(l2cap_pdu.payload)
+                        if avdtp_assembler := self.avdtp_assemblers.get(l2cap_pdu.cid):
+                            avdtp_assembler.on_pdu(l2cap_pdu.payload)
+                    elif psm == avctp.AVCTP_PSM:
+                        self.analyzer.emit(
+                            f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
+                            f'PSM=AVCTP]: {l2cap_pdu.payload.hex()}'
+                        )
+                        if avctp_assembler := self.avctp_assemblers.get(l2cap_pdu.cid):
+                            avctp_assembler.on_pdu(l2cap_pdu.payload)
                     else:
                         psm_string = name_or_number(PSM_NAMES, psm)
                         self.analyzer.emit(
@@ -130,22 +169,49 @@
                 else:
                     self.analyzer.emit(l2cap_pdu)
 
-        def on_avdtp_message(self, transaction_label, message):
+        def on_avdtp_message(
+            self, transaction_label: int, message: avdtp.Message
+        ) -> None:
             self.analyzer.emit(
                 f'{color("AVDTP", "green")} [{transaction_label}] {message}'
             )
 
-        def feed_packet(self, packet):
+        def on_avctp_message(
+            self,
+            transaction_label: int,
+            is_command: bool,
+            ipid: bool,
+            pid: int,
+            payload: bytes,
+        ):
+            if pid == avrcp.AVRCP_PID:
+                avc_frame = avc.Frame.from_bytes(payload)
+                details = str(avc_frame)
+            else:
+                details = payload.hex()
+
+            c_r = 'Command' if is_command else 'Response'
+            self.analyzer.emit(
+                f'{color("AVCTP", "green")} '
+                f'{c_r}[{transaction_label}][{name_or_number(AVCTP_PID_NAMES, pid)}] '
+                f'{"#" if ipid else ""}'
+                f'{details}'
+            )
+
+        def feed_packet(self, packet: HCI_AclDataPacket) -> None:
             self.packet_assembler.feed_packet(packet)
 
     class Analyzer:
-        def __init__(self, label, emit_message):
+        acl_streams: MutableMapping[int, PacketTracer.AclStream]
+        peer: PacketTracer.Analyzer
+
+        def __init__(self, label: str, emit_message: Callable[..., None]) -> None:
             self.label = label
             self.emit_message = emit_message
             self.acl_streams = {}  # ACL streams, by connection handle
-            self.peer = None  # Analyzer in the other direction
+            self.packet_timestamp: Optional[datetime.datetime] = None
 
-        def start_acl_stream(self, connection_handle):
+        def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
             logger.info(
                 f'[{self.label}] +++ Creating ACL stream for connection '
                 f'0x{connection_handle:04X}'
@@ -160,7 +226,7 @@
 
             return stream
 
-        def end_acl_stream(self, connection_handle):
+        def end_acl_stream(self, connection_handle: int) -> None:
             if connection_handle in self.acl_streams:
                 logger.info(
                     f'[{self.label}] --- Removing ACL stream for connection '
@@ -171,34 +237,52 @@
                 # Let the other forwarder know so it can cleanup its stream as well
                 self.peer.end_acl_stream(connection_handle)
 
-        def on_packet(self, packet):
+        def on_packet(
+            self, timestamp: Optional[datetime.datetime], packet: HCI_Packet
+        ) -> None:
+            self.packet_timestamp = timestamp
             self.emit(packet)
 
             if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
+                acl_packet = cast(HCI_AclDataPacket, packet)
                 # Look for an existing stream for this handle, create one if it is the
                 # first ACL packet for that connection handle
-                if (stream := self.acl_streams.get(packet.connection_handle)) is None:
-                    stream = self.start_acl_stream(packet.connection_handle)
-                stream.feed_packet(packet)
+                if (
+                    stream := self.acl_streams.get(acl_packet.connection_handle)
+                ) is None:
+                    stream = self.start_acl_stream(acl_packet.connection_handle)
+                stream.feed_packet(acl_packet)
             elif packet.hci_packet_type == HCI_EVENT_PACKET:
-                if packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT:
-                    self.end_acl_stream(packet.connection_handle)
+                event_packet = cast(HCI_Event, packet)
+                if event_packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT:
+                    self.end_acl_stream(
+                        cast(HCI_Disconnection_Complete_Event, packet).connection_handle
+                    )
 
-        def emit(self, message):
-            self.emit_message(f'[{self.label}] {message}')
+        def emit(self, message: Any) -> None:
+            if self.packet_timestamp:
+                prefix = f"[{self.packet_timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')}]"
+            else:
+                prefix = ""
+            self.emit_message(f'{prefix}[{self.label}] {message}')
 
-    def trace(self, packet, direction=0):
+    def trace(
+        self,
+        packet: HCI_Packet,
+        direction: int = 0,
+        timestamp: Optional[datetime.datetime] = None,
+    ) -> None:
         if direction == 0:
-            self.host_to_controller_analyzer.on_packet(packet)
+            self.host_to_controller_analyzer.on_packet(timestamp, packet)
         else:
-            self.controller_to_host_analyzer.on_packet(packet)
+            self.controller_to_host_analyzer.on_packet(timestamp, packet)
 
     def __init__(
         self,
-        host_to_controller_label=color('HOST->CONTROLLER', 'blue'),
-        controller_to_host_label=color('CONTROLLER->HOST', 'cyan'),
-        emit_message=logger.info,
-    ):
+        host_to_controller_label: str = color('HOST->CONTROLLER', 'blue'),
+        controller_to_host_label: str = color('CONTROLLER->HOST', 'cyan'),
+        emit_message: Callable[..., None] = logger.info,
+    ) -> None:
         self.host_to_controller_analyzer = PacketTracer.Analyzer(
             host_to_controller_label, emit_message
         )
@@ -207,3 +291,15 @@
         )
         self.host_to_controller_analyzer.peer = self.controller_to_host_analyzer
         self.controller_to_host_analyzer.peer = self.host_to_controller_analyzer
+
+
+def generate_irk() -> bytes:
+    return crypto.r()
+
+
+def verify_rpa_with_irk(rpa: Address, irk: bytes) -> bool:
+    rpa_bytes = bytes(rpa)
+    prand_given = rpa_bytes[3:]
+    hash_given = rpa_bytes[:3]
+    hash_local = crypto.ah(irk, prand_given)
+    return hash_local[:3] == hash_given
diff --git a/bumble/hfp.py b/bumble/hfp.py
index bb00920..27bb097 100644
--- a/bumble/hfp.py
+++ b/bumble/hfp.py
@@ -21,12 +21,11 @@
 import dataclasses
 import enum
 import traceback
-import warnings
-from typing import Dict, List, Union, Set, TYPE_CHECKING
+import pyee
+from typing import Dict, List, Union, Set, Any, Optional, TYPE_CHECKING
 
-from . import at
-from . import rfcomm
-
+from bumble import at
+from bumble import rfcomm
 from bumble.colors import color
 from bumble.core import (
     ProtocolError,
@@ -35,6 +34,11 @@
     BT_L2CAP_PROTOCOL_ID,
     BT_RFCOMM_PROTOCOL_ID,
 )
+from bumble.hci import (
+    HCI_Enhanced_Setup_Synchronous_Connection_Command,
+    CodingFormat,
+    CodecID,
+)
 from bumble.sdp import (
     DataElement,
     ServiceAttribute,
@@ -65,6 +69,7 @@
 # Protocol Support
 # -----------------------------------------------------------------------------
 
+
 # -----------------------------------------------------------------------------
 class HfpProtocol:
     dlc: rfcomm.DLC
@@ -73,7 +78,6 @@
     lines_available: asyncio.Event
 
     def __init__(self, dlc: rfcomm.DLC) -> None:
-        warnings.warn("See HfProtocol", DeprecationWarning)
         self.dlc = dlc
         self.buffer = ''
         self.lines = collections.deque()
@@ -122,10 +126,13 @@
 # -----------------------------------------------------------------------------
 
 
-# HF supported features (AT+BRSF=) (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
 class HfFeature(enum.IntFlag):
+    """
+    HF supported features (AT+BRSF=) (normative).
+
+    Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+    """
+
     EC_NR = 0x001  # Echo Cancel & Noise reduction
     THREE_WAY_CALLING = 0x002
     CLI_PRESENTATION_CAPABILITY = 0x004
@@ -140,10 +147,13 @@
     VOICE_RECOGNITION_TEST = 0x800
 
 
-# AG supported features (+BRSF:) (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
 class AgFeature(enum.IntFlag):
+    """
+    AG supported features (+BRSF:) (normative).
+
+    Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+    """
+
     THREE_WAY_CALLING = 0x001
     EC_NR = 0x002  # Echo Cancel & Noise reduction
     VOICE_RECOGNITION_FUNCTION = 0x004
@@ -160,52 +170,90 @@
     VOICE_RECOGNITION_TEST = 0x2000
 
 
-# Audio Codec IDs (normative).
-# Hands-Free Profile v1.8, 10 Appendix B
 class AudioCodec(enum.IntEnum):
+    """
+    Audio Codec IDs (normative).
+
+    Hands-Free Profile v1.9, 11 Appendix B
+    """
+
     CVSD = 0x01  # Support for CVSD audio codec
     MSBC = 0x02  # Support for mSBC audio codec
+    LC3_SWB = 0x03  # Support for LC3-SWB audio codec
 
 
-# HF Indicators (normative).
-# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
 class HfIndicator(enum.IntEnum):
+    """
+    HF Indicators (normative).
+
+    Bluetooth Assigned Numbers, 6.10.1 HF Indicators.
+    """
+
     ENHANCED_SAFETY = 0x01  # Enhanced safety feature
     BATTERY_LEVEL = 0x02  # Battery level feature
 
 
-# Call Hold supported operations (normative).
-# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
 class CallHoldOperation(enum.IntEnum):
+    """
+    Call Hold supported operations (normative).
+
+    AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services.
+    """
+
     RELEASE_ALL_HELD_CALLS = 0  # Release all held calls
     RELEASE_ALL_ACTIVE_CALLS = 1  # Release all active calls, accept other
     HOLD_ALL_ACTIVE_CALLS = 2  # Place all active calls on hold, accept other
     ADD_HELD_CALL = 3  # Adds a held call to conversation
 
 
-# Response Hold status (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
 class ResponseHoldStatus(enum.IntEnum):
+    """
+    Response Hold status (normative).
+
+    Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+    """
+
     INC_CALL_HELD = 0  # Put incoming call on hold
     HELD_CALL_ACC = 1  # Accept a held incoming call
     HELD_CALL_REJ = 2  # Reject a held incoming call
 
 
-# Values for the Call Setup AG indicator (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
+class AgIndicator(enum.Enum):
+    """
+    Values for the AG indicator (normative).
+
+    Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+    """
+
+    SERVICE = 'service'
+    CALL = 'call'
+    CALL_SETUP = 'callsetup'
+    CALL_HELD = 'callheld'
+    SIGNAL = 'signal'
+    ROAM = 'roam'
+    BATTERY_CHARGE = 'battchg'
+
+
 class CallSetupAgIndicator(enum.IntEnum):
+    """
+    Values for the Call Setup AG indicator (normative).
+
+    Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+    """
+
     NOT_IN_CALL_SETUP = 0
     INCOMING_CALL_PROCESS = 1
     OUTGOING_CALL_SETUP = 2
     REMOTE_ALERTED = 3  # Remote party alerted in an outgoing call
 
 
-# Values for the Call Held AG indicator (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
 class CallHeldAgIndicator(enum.IntEnum):
+    """
+    Values for the Call Held AG indicator (normative).
+
+    Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+    """
+
     NO_CALLS_HELD = 0
     # Call is placed on hold or active/held calls swapped
     # (The AG has both an active AND a held call)
@@ -213,16 +261,24 @@
     CALL_ON_HOLD_NO_ACTIVE_CALL = 2  # Call on hold, no active call
 
 
-# Call Info direction (normative).
-# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
 class CallInfoDirection(enum.IntEnum):
+    """
+    Call Info direction (normative).
+
+    AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+    """
+
     MOBILE_ORIGINATED_CALL = 0
     MOBILE_TERMINATED_CALL = 1
 
 
-# Call Info status (normative).
-# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
 class CallInfoStatus(enum.IntEnum):
+    """
+    Call Info status (normative).
+
+    AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+    """
+
     ACTIVE = 0
     HELD = 1
     DIALING = 2
@@ -231,15 +287,47 @@
     WAITING = 5
 
 
-# Call Info mode (normative).
-# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
 class CallInfoMode(enum.IntEnum):
+    """
+    Call Info mode (normative).
+
+    AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+    """
+
     VOICE = 0
     DATA = 1
     FAX = 2
     UNKNOWN = 9
 
 
+class CallInfoMultiParty(enum.IntEnum):
+    """
+    Call Info Multi-Party state (normative).
+
+    AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+    """
+
+    NOT_IN_CONFERENCE = 0
+    IN_CONFERENCE = 1
+
+
+@dataclasses.dataclass
+class CallInfo:
+    """
+    Enhanced call status.
+
+    AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+    """
+
+    index: int
+    direction: CallInfoDirection
+    status: CallInfoStatus
+    mode: CallInfoMode
+    multi_party: CallInfoMultiParty
+    number: Optional[int] = None
+    type: Optional[int] = None
+
+
 # -----------------------------------------------------------------------------
 # Hands-Free Control Interoperability Requirements
 # -----------------------------------------------------------------------------
@@ -320,8 +408,9 @@
 
 
 class AtResponseType(enum.Enum):
-    """Indicate if a response is expected from an AT command, and if multiple
-    responses are accepted."""
+    """
+    Indicates if a response is expected from an AT command, and if multiple responses are accepted.
+    """
 
     NONE = 0
     SINGLE = 1
@@ -355,9 +444,20 @@
     enabled: bool = False
 
 
-class HfProtocol:
-    """Implementation for the Hands-Free side of the Hands-Free profile.
-    Reference specification Hands-Free Profile v1.8"""
+class HfProtocol(pyee.EventEmitter):
+    """
+    Implementation for the Hands-Free side of the Hands-Free profile.
+
+    Reference specification Hands-Free Profile v1.8.
+
+    Emitted events:
+        codec_negotiation: When codec is renegotiated, notify the new codec.
+            Args:
+                active_codec: AudioCodec
+        ag_indicator: When AG update their indicators, notify the new state.
+            Args:
+                ag_indicator: AgIndicator
+    """
 
     supported_hf_features: int
     supported_audio_codecs: List[AudioCodec]
@@ -377,14 +477,18 @@
         response_queue: asyncio.Queue
         unsolicited_queue: asyncio.Queue
     read_buffer: bytearray
+    active_codec: AudioCodec
 
-    def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
+    def __init__(self, dlc: rfcomm.DLC, configuration: Configuration) -> None:
+        super().__init__()
+
         # Configure internal state.
         self.dlc = dlc
         self.command_lock = asyncio.Lock()
         self.response_queue = asyncio.Queue()
         self.unsolicited_queue = asyncio.Queue()
         self.read_buffer = bytearray()
+        self.active_codec = AudioCodec.CVSD
 
         # Build local features.
         self.supported_hf_features = sum(configuration.supported_hf_features)
@@ -409,10 +513,12 @@
     def supports_ag_feature(self, feature: AgFeature) -> bool:
         return (self.supported_ag_features & feature) != 0
 
-    # Read AT messages from the RFCOMM channel.
-    # Enqueue AT commands, responses, unsolicited responses to their
-    # respective queues, and set the corresponding event.
     def _read_at(self, data: bytes):
+        """
+        Reads AT messages from the RFCOMM channel.
+
+        Enqueues AT commands, responses, unsolicited responses to their respective queues, and set the corresponding event.
+        """
         # Append to the read buffer.
         self.read_buffer.extend(data)
 
@@ -440,17 +546,25 @@
         else:
             logger.warning(f"dropping unexpected response with code '{response.code}'")
 
-    # Send an AT command and wait for the peer response.
-    # Wait for the AT responses sent by the peer, to the status code.
-    # Raises asyncio.TimeoutError if the status is not received
-    # after a timeout (default 1 second).
-    # Raises ProtocolError if the status is not OK.
     async def execute_command(
         self,
         cmd: str,
         timeout: float = 1.0,
         response_type: AtResponseType = AtResponseType.NONE,
     ) -> Union[None, AtResponse, List[AtResponse]]:
+        """
+        Sends an AT command and wait for the peer response.
+        Wait for the AT responses sent by the peer, to the status code.
+
+        Args:
+            cmd: the AT command in string to execute.
+            timeout: timeout in float seconds.
+            response_type: type of response.
+
+        Raises:
+            asyncio.TimeoutError: the status is not received after a timeout (default 1 second).
+            ProtocolError: the status is not OK.
+        """
         async with self.command_lock:
             logger.debug(f">>> {cmd}")
             self.dlc.write(cmd + '\r')
@@ -473,8 +587,9 @@
                     raise HfpProtocolError(result.code)
                 responses.append(result)
 
-    # 4.2.1 Service Level Connection Initialization.
     async def initiate_slc(self):
+        """4.2.1 Service Level Connection Initialization."""
+
         # 4.2.1.1 Supported features exchange
         # First, in the initialization procedure, the HF shall send the
         # AT+BRSF=<HF supported features> command to the AG to both notify
@@ -614,16 +729,17 @@
 
         logger.info("SLC setup completed")
 
-    # 4.11.2 Audio Connection Setup by HF
     async def setup_audio_connection(self):
+        """4.11.2 Audio Connection Setup by HF."""
+
         # When the HF triggers the establishment of the Codec Connection it
         # shall send the AT command AT+BCC to the AG. The AG shall respond with
         # OK if it will start the Codec Connection procedure, and with ERROR
         # if it cannot start the Codec Connection procedure.
         await self.execute_command("AT+BCC")
 
-    # 4.11.3 Codec Connection Setup
     async def setup_codec_connection(self, codec_id: int):
+        """4.11.3 Codec Connection Setup."""
         # The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
         # The HF shall then respond to the incoming unsolicited response with
         # the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
@@ -641,27 +757,29 @@
         # Synchronous Connection with the settings that are determined by the
         # ID. The HF shall be ready to accept the synchronous connection
         # establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
+        self.active_codec = AudioCodec(codec_id)
+        self.emit('codec_negotiation', self.active_codec)
 
         logger.info("codec connection setup completed")
 
-    # 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
     async def answer_incoming_call(self):
+        """4.13.1 Answer Incoming Call from the HF - In-Band Ringing."""
         # The user accepts the incoming voice call by using the proper means
         # provided by the HF. The HF shall then send the ATA command
         # (see Section 4.34) to the AG. The AG shall then begin the procedure for
         # accepting the incoming call.
         await self.execute_command("ATA")
 
-    # 4.14.1 Reject an Incoming Call from the HF
     async def reject_incoming_call(self):
+        """4.14.1 Reject an Incoming Call from the HF."""
         # The user rejects the incoming call by using the User Interface on the
         # Hands-Free unit. The HF shall then send the AT+CHUP command
         # (see Section 4.34) to the AG. This may happen at any time during the
         # procedures described in Sections 4.13.1 and 4.13.2.
         await self.execute_command("AT+CHUP")
 
-    # 4.15.1 Terminate a Call Process from the HF
     async def terminate_call(self):
+        """4.15.1 Terminate a Call Process from the HF."""
         # The user may abort the ongoing call process using whatever means
         # provided by the Hands-Free unit. The HF shall send AT+CHUP command
         # (see Section 4.34) to the AG, and the AG shall then start the
@@ -670,8 +788,35 @@
         # code, with the value indicating (call=0).
         await self.execute_command("AT+CHUP")
 
+    async def query_current_calls(self) -> List[CallInfo]:
+        """4.32.1 Query List of Current Calls in AG.
+
+        Return:
+            List of current calls in AG.
+        """
+        responses = await self.execute_command(
+            "AT+CLCC", response_type=AtResponseType.MULTIPLE
+        )
+        assert isinstance(responses, list)
+
+        calls = []
+        for response in responses:
+            call_info = CallInfo(
+                index=int(response.parameters[0]),
+                direction=CallInfoDirection(int(response.parameters[1])),
+                status=CallInfoStatus(int(response.parameters[2])),
+                mode=CallInfoMode(int(response.parameters[3])),
+                multi_party=CallInfoMultiParty(int(response.parameters[4])),
+            )
+            if len(response.parameters) >= 7:
+                call_info.number = int(response.parameters[5])
+                call_info.type = int(response.parameters[6])
+            calls.append(call_info)
+        return calls
+
     async def update_ag_indicator(self, index: int, value: int):
         self.ag_indicators[index].current_status = value
+        self.emit('ag_indicator', self.ag_indicators[index])
         logger.info(
             f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
         )
@@ -689,9 +834,11 @@
             logging.info(f"unhandled unsolicited response {result.code}")
 
     async def run(self):
-        """Main rountine for the Hands-Free side of the HFP protocol.
-        Initiates the service level connection then loops handling
-        unsolicited AG responses."""
+        """
+        Main routine for the Hands-Free side of the HFP protocol.
+
+        Initiates the service level connection then loops handling unsolicited AG responses.
+        """
 
         try:
             await self.initiate_slc()
@@ -707,9 +854,13 @@
 # -----------------------------------------------------------------------------
 
 
-# Profile version (normative).
-# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
 class ProfileVersion(enum.IntEnum):
+    """
+    Profile version (normative).
+
+    Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
+    """
+
     V1_5 = 0x0105
     V1_6 = 0x0106
     V1_7 = 0x0107
@@ -717,9 +868,13 @@
     V1_9 = 0x0109
 
 
-# HF supported features (normative).
-# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
 class HfSdpFeature(enum.IntFlag):
+    """
+    HF supported features (normative).
+
+    Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
+    """
+
     EC_NR = 0x01  # Echo Cancel & Noise reduction
     THREE_WAY_CALLING = 0x02
     CLI_PRESENTATION_CAPABILITY = 0x04
@@ -730,9 +885,13 @@
     VOICE_RECOGNITION_TEST = 0x80
 
 
-# AG supported features (normative).
-# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
 class AgSdpFeature(enum.IntFlag):
+    """
+    AG supported features (normative).
+
+    Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
+    """
+
     THREE_WAY_CALLING = 0x01
     EC_NR = 0x02  # Echo Cancel & Noise reduction
     VOICE_RECOGNITION_FUNCTION = 0x04
@@ -746,9 +905,12 @@
 def sdp_records(
     service_record_handle: int, rfcomm_channel: int, configuration: Configuration
 ) -> List[ServiceAttribute]:
-    """Generate the SDP record for HFP Hands-Free support.
+    """
+    Generates the SDP record for HFP Hands-Free support.
+
     The record exposes the features supported in the input configuration,
-    and the allocated RFCOMM channel."""
+    and the allocated RFCOMM channel.
+    """
 
     hf_supported_features = 0
 
@@ -819,3 +981,175 @@
             DataElement.unsigned_integer_16(hf_supported_features),
         ),
     ]
+
+
+# -----------------------------------------------------------------------------
+# ESCO Codec Default Parameters
+# -----------------------------------------------------------------------------
+
+
+# Hands-Free Profile v1.8, 5.7 Codec Interoperability Requirements
+class DefaultCodecParameters(enum.IntEnum):
+    SCO_CVSD_D0 = enum.auto()
+    SCO_CVSD_D1 = enum.auto()
+    ESCO_CVSD_S1 = enum.auto()
+    ESCO_CVSD_S2 = enum.auto()
+    ESCO_CVSD_S3 = enum.auto()
+    ESCO_CVSD_S4 = enum.auto()
+    ESCO_MSBC_T1 = enum.auto()
+    ESCO_MSBC_T2 = enum.auto()
+
+
+@dataclasses.dataclass
+class EscoParameters:
+    # Codec specific
+    transmit_coding_format: CodingFormat
+    receive_coding_format: CodingFormat
+    packet_type: HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType
+    retransmission_effort: HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort
+    max_latency: int
+
+    # Common
+    input_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
+    output_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
+    input_coded_data_size: int = 16
+    output_coded_data_size: int = 16
+    input_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
+    )
+    output_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
+    )
+    input_pcm_sample_payload_msb_position: int = 0
+    output_pcm_sample_payload_msb_position: int = 0
+    input_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
+    )
+    output_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
+    )
+    input_transport_unit_size: int = 0
+    output_transport_unit_size: int = 0
+    input_bandwidth: int = 16000
+    output_bandwidth: int = 16000
+    transmit_bandwidth: int = 8000
+    receive_bandwidth: int = 8000
+    transmit_codec_frame_size: int = 60
+    receive_codec_frame_size: int = 60
+
+    def asdict(self) -> Dict[str, Any]:
+        # dataclasses.asdict() will recursively deep-copy the entire object,
+        # which is expensive and breaks CodingFormat object, so let it simply copy here.
+        return self.__dict__
+
+
+_ESCO_PARAMETERS_CVSD_D0 = EscoParameters(
+    transmit_coding_format=CodingFormat(CodecID.CVSD),
+    receive_coding_format=CodingFormat(CodecID.CVSD),
+    max_latency=0xFFFF,
+    packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV1,
+    retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
+)
+
+_ESCO_PARAMETERS_CVSD_D1 = EscoParameters(
+    transmit_coding_format=CodingFormat(CodecID.CVSD),
+    receive_coding_format=CodingFormat(CodecID.CVSD),
+    max_latency=0xFFFF,
+    packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV3,
+    retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
+)
+
+_ESCO_PARAMETERS_CVSD_S1 = EscoParameters(
+    transmit_coding_format=CodingFormat(CodecID.CVSD),
+    receive_coding_format=CodingFormat(CodecID.CVSD),
+    max_latency=0x0007,
+    packet_type=(
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+    ),
+    retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
+)
+
+_ESCO_PARAMETERS_CVSD_S2 = EscoParameters(
+    transmit_coding_format=CodingFormat(CodecID.CVSD),
+    receive_coding_format=CodingFormat(CodecID.CVSD),
+    max_latency=0x0007,
+    packet_type=(
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+    ),
+    retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
+)
+
+_ESCO_PARAMETERS_CVSD_S3 = EscoParameters(
+    transmit_coding_format=CodingFormat(CodecID.CVSD),
+    receive_coding_format=CodingFormat(CodecID.CVSD),
+    max_latency=0x000A,
+    packet_type=(
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+    ),
+    retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
+)
+
+_ESCO_PARAMETERS_CVSD_S4 = EscoParameters(
+    transmit_coding_format=CodingFormat(CodecID.CVSD),
+    receive_coding_format=CodingFormat(CodecID.CVSD),
+    max_latency=0x000C,
+    packet_type=(
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+    ),
+    retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
+)
+
+_ESCO_PARAMETERS_MSBC_T1 = EscoParameters(
+    transmit_coding_format=CodingFormat(CodecID.MSBC),
+    receive_coding_format=CodingFormat(CodecID.MSBC),
+    max_latency=0x0008,
+    packet_type=(
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+    ),
+    input_bandwidth=32000,
+    output_bandwidth=32000,
+    retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
+)
+
+_ESCO_PARAMETERS_MSBC_T2 = EscoParameters(
+    transmit_coding_format=CodingFormat(CodecID.MSBC),
+    receive_coding_format=CodingFormat(CodecID.MSBC),
+    max_latency=0x000D,
+    packet_type=(
+        HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+        | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+    ),
+    input_bandwidth=32000,
+    output_bandwidth=32000,
+    retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
+)
+
+ESCO_PARAMETERS = {
+    DefaultCodecParameters.SCO_CVSD_D0: _ESCO_PARAMETERS_CVSD_D0,
+    DefaultCodecParameters.SCO_CVSD_D1: _ESCO_PARAMETERS_CVSD_D1,
+    DefaultCodecParameters.ESCO_CVSD_S1: _ESCO_PARAMETERS_CVSD_S1,
+    DefaultCodecParameters.ESCO_CVSD_S2: _ESCO_PARAMETERS_CVSD_S2,
+    DefaultCodecParameters.ESCO_CVSD_S3: _ESCO_PARAMETERS_CVSD_S3,
+    DefaultCodecParameters.ESCO_CVSD_S4: _ESCO_PARAMETERS_CVSD_S4,
+    DefaultCodecParameters.ESCO_MSBC_T1: _ESCO_PARAMETERS_MSBC_T1,
+    DefaultCodecParameters.ESCO_MSBC_T2: _ESCO_PARAMETERS_MSBC_T2,
+}
diff --git a/bumble/hid.py b/bumble/hid.py
index e4d6a77..fc5c807 100644
--- a/bumble/hid.py
+++ b/bumble/hid.py
@@ -18,18 +18,18 @@
 from __future__ import annotations
 from dataclasses import dataclass
 import logging
-import asyncio
 import enum
+import struct
 
+from abc import ABC, abstractmethod
 from pyee import EventEmitter
-from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
+from typing import Optional, Callable, TYPE_CHECKING
+from typing_extensions import override
 
-from . import core, l2cap  # type: ignore
-from .colors import color  # type: ignore
-from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError  # type: ignore
-
-if TYPE_CHECKING:
-    from bumble.device import Device, Connection
+from bumble import l2cap, device
+from bumble.colors import color
+from bumble.core import InvalidStateError, ProtocolError
+from .hci import Address
 
 
 # -----------------------------------------------------------------------------
@@ -61,6 +61,7 @@
         NOT_READY = 0x01
         ERR_INVALID_REPORT_ID = 0x02
         ERR_UNSUPPORTED_REQUEST = 0x03
+        ERR_INVALID_PARAMETER = 0x04
         ERR_UNKNOWN = 0x0E
         ERR_FATAL = 0x0F
 
@@ -102,13 +103,14 @@
     def __bytes__(self) -> bytes:
         packet_bytes = bytearray()
         packet_bytes.append(self.report_id)
-        packet_bytes.extend(
-            [(self.buffer_size & 0xFF), ((self.buffer_size >> 8) & 0xFF)]
-        )
-        if self.report_type == Message.ReportType.OTHER_REPORT:
+        if self.buffer_size == 0:
             return self.header(self.report_type) + packet_bytes
         else:
-            return self.header(0x08 | self.report_type) + packet_bytes
+            return (
+                self.header(0x08 | self.report_type)
+                + packet_bytes
+                + struct.pack("<H", self.buffer_size)
+            )
 
 
 @dataclass
@@ -122,6 +124,16 @@
 
 
 @dataclass
+class SendControlData(Message):
+    report_type: int
+    data: bytes
+    message_type = Message.MessageType.DATA
+
+    def __bytes__(self) -> bytes:
+        return self.header(self.report_type) + self.data
+
+
+@dataclass
 class GetProtocolMessage(Message):
     message_type = Message.MessageType.GET_PROTOCOL
 
@@ -162,31 +174,47 @@
         return self.header(Message.ControlCommand.VIRTUAL_CABLE_UNPLUG)
 
 
+# Device sends input report, host sends output report.
 @dataclass
 class SendData(Message):
     data: bytes
+    report_type: int
     message_type = Message.MessageType.DATA
 
     def __bytes__(self) -> bytes:
-        return self.header(Message.ReportType.OUTPUT_REPORT) + self.data
+        return self.header(self.report_type) + self.data
+
+
+@dataclass
+class SendHandshakeMessage(Message):
+    result_code: int
+    message_type = Message.MessageType.HANDSHAKE
+
+    def __bytes__(self) -> bytes:
+        return self.header(self.result_code)
 
 
 # -----------------------------------------------------------------------------
-class Host(EventEmitter):
-    l2cap_ctrl_channel: Optional[l2cap.ClassicChannel]
-    l2cap_intr_channel: Optional[l2cap.ClassicChannel]
+class HID(ABC, EventEmitter):
+    l2cap_ctrl_channel: Optional[l2cap.ClassicChannel] = None
+    l2cap_intr_channel: Optional[l2cap.ClassicChannel] = None
+    connection: Optional[device.Connection] = None
 
-    def __init__(self, device: Device, connection: Connection) -> None:
+    class Role(enum.IntEnum):
+        HOST = 0x00
+        DEVICE = 0x01
+
+    def __init__(self, device: device.Device, role: Role) -> None:
         super().__init__()
+        self.remote_device_bd_address: Optional[Address] = None
         self.device = device
-        self.connection = connection
-
-        self.l2cap_ctrl_channel = None
-        self.l2cap_intr_channel = None
+        self.role = role
 
         # Register ourselves with the L2CAP channel manager
-        device.register_l2cap_server(HID_CONTROL_PSM, self.on_connection)
-        device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_connection)
+        device.register_l2cap_server(HID_CONTROL_PSM, self.on_l2cap_connection)
+        device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_l2cap_connection)
+
+        device.on('connection', self.on_device_connection)
 
     async def connect_control_channel(self) -> None:
         # Create a new L2CAP connection - control channel
@@ -230,9 +258,18 @@
         self.l2cap_ctrl_channel = None
         await channel.disconnect()
 
-    def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
+    def on_device_connection(self, connection: device.Connection) -> None:
+        self.connection = connection
+        self.remote_device_bd_address = connection.peer_address
+        connection.on('disconnection', self.on_device_disconnection)
+
+    def on_device_disconnection(self, reason: int) -> None:
+        self.connection = None
+
+    def on_l2cap_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
         logger.debug(f'+++ New L2CAP connection: {l2cap_channel}')
         l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
+        l2cap_channel.on('close', lambda: self.on_l2cap_channel_close(l2cap_channel))
 
     def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
         if l2cap_channel.psm == HID_CONTROL_PSM:
@@ -243,37 +280,220 @@
             self.l2cap_intr_channel.sink = self.on_intr_pdu
         logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
 
+    def on_l2cap_channel_close(self, l2cap_channel: l2cap.ClassicChannel) -> None:
+        if l2cap_channel.psm == HID_CONTROL_PSM:
+            self.l2cap_ctrl_channel = None
+        else:
+            self.l2cap_intr_channel = None
+        logger.debug(f'$$$ L2CAP channel close: {l2cap_channel}')
+
+    @abstractmethod
+    def on_ctrl_pdu(self, pdu: bytes) -> None:
+        pass
+
+    def on_intr_pdu(self, pdu: bytes) -> None:
+        logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
+        self.emit("interrupt_data", pdu)
+
+    def send_pdu_on_ctrl(self, msg: bytes) -> None:
+        assert self.l2cap_ctrl_channel
+        self.l2cap_ctrl_channel.send_pdu(msg)
+
+    def send_pdu_on_intr(self, msg: bytes) -> None:
+        assert self.l2cap_intr_channel
+        self.l2cap_intr_channel.send_pdu(msg)
+
+    def send_data(self, data: bytes) -> None:
+        if self.role == HID.Role.HOST:
+            report_type = Message.ReportType.OUTPUT_REPORT
+        else:
+            report_type = Message.ReportType.INPUT_REPORT
+        msg = SendData(data, report_type)
+        hid_message = bytes(msg)
+        if self.l2cap_intr_channel is not None:
+            logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
+            self.send_pdu_on_intr(hid_message)
+
+    def virtual_cable_unplug(self) -> None:
+        msg = VirtualCableUnplug()
+        hid_message = bytes(msg)
+        logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
+        self.send_pdu_on_ctrl(hid_message)
+
+
+# -----------------------------------------------------------------------------
+
+
+class Device(HID):
+    class GetSetReturn(enum.IntEnum):
+        FAILURE = 0x00
+        REPORT_ID_NOT_FOUND = 0x01
+        ERR_UNSUPPORTED_REQUEST = 0x02
+        ERR_UNKNOWN = 0x03
+        ERR_INVALID_PARAMETER = 0x04
+        SUCCESS = 0xFF
+
+    class GetSetStatus:
+        def __init__(self) -> None:
+            self.data = bytearray()
+            self.status = 0
+
+    def __init__(self, device: device.Device) -> None:
+        super().__init__(device, HID.Role.DEVICE)
+        get_report_cb: Optional[Callable[[int, int, int], None]] = None
+        set_report_cb: Optional[Callable[[int, int, int, bytes], None]] = None
+        get_protocol_cb: Optional[Callable[[], None]] = None
+        set_protocol_cb: Optional[Callable[[int], None]] = None
+
+    @override
     def on_ctrl_pdu(self, pdu: bytes) -> None:
         logger.debug(f'<<< HID CONTROL PDU: {pdu.hex()}')
-        # Here we will receive all kinds of packets, parse and then call respective callbacks
-        message_type = pdu[0] >> 4
         param = pdu[0] & 0x0F
+        message_type = pdu[0] >> 4
 
-        if message_type == Message.MessageType.HANDSHAKE:
-            logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}')
-            self.emit('handshake', Message.Handshake(param))
+        if message_type == Message.MessageType.GET_REPORT:
+            logger.debug('<<< HID GET REPORT')
+            self.handle_get_report(pdu)
+        elif message_type == Message.MessageType.SET_REPORT:
+            logger.debug('<<< HID SET REPORT')
+            self.handle_set_report(pdu)
+        elif message_type == Message.MessageType.GET_PROTOCOL:
+            logger.debug('<<< HID GET PROTOCOL')
+            self.handle_get_protocol(pdu)
+        elif message_type == Message.MessageType.SET_PROTOCOL:
+            logger.debug('<<< HID SET PROTOCOL')
+            self.handle_set_protocol(pdu)
         elif message_type == Message.MessageType.DATA:
             logger.debug('<<< HID CONTROL DATA')
-            self.emit('data', pdu)
+            self.emit('control_data', pdu)
         elif message_type == Message.MessageType.CONTROL:
             if param == Message.ControlCommand.SUSPEND:
                 logger.debug('<<< HID SUSPEND')
-                self.emit('suspend', pdu)
+                self.emit('suspend')
             elif param == Message.ControlCommand.EXIT_SUSPEND:
                 logger.debug('<<< HID EXIT SUSPEND')
-                self.emit('exit_suspend', pdu)
+                self.emit('exit_suspend')
             elif param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
                 logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
                 self.emit('virtual_cable_unplug')
             else:
                 logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
         else:
-            logger.debug('<<< HID CONTROL DATA')
-            self.emit('data', pdu)
+            logger.debug('<<< HID MESSAGE TYPE UNSUPPORTED')
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
 
-    def on_intr_pdu(self, pdu: bytes) -> None:
-        logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
-        self.emit("data", pdu)
+    def send_handshake_message(self, result_code: int) -> None:
+        msg = SendHandshakeMessage(result_code)
+        hid_message = bytes(msg)
+        logger.debug(f'>>> HID HANDSHAKE MESSAGE, PDU: {hid_message.hex()}')
+        self.send_pdu_on_ctrl(hid_message)
+
+    def send_control_data(self, report_type: int, data: bytes):
+        msg = SendControlData(report_type=report_type, data=data)
+        hid_message = bytes(msg)
+        logger.debug(f'>>> HID CONTROL DATA: {hid_message.hex()}')
+        self.send_pdu_on_ctrl(hid_message)
+
+    def handle_get_report(self, pdu: bytes):
+        if self.get_report_cb is None:
+            logger.debug("GetReport callback not registered !!")
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+            return
+        report_type = pdu[0] & 0x03
+        buffer_flag = (pdu[0] & 0x08) >> 3
+        report_id = pdu[1]
+        logger.debug(f"buffer_flag: {buffer_flag}")
+        if buffer_flag == 1:
+            buffer_size = (pdu[3] << 8) | pdu[2]
+        else:
+            buffer_size = 0
+
+        ret = self.get_report_cb(report_id, report_type, buffer_size)
+        assert ret is not None
+        if ret.status == self.GetSetReturn.FAILURE:
+            self.send_handshake_message(Message.Handshake.ERR_UNKNOWN)
+        elif ret.status == self.GetSetReturn.SUCCESS:
+            data = bytearray()
+            data.append(report_id)
+            data.extend(ret.data)
+            if len(data) < self.l2cap_ctrl_channel.peer_mtu:  # type: ignore[union-attr]
+                self.send_control_data(report_type=report_type, data=data)
+            else:
+                self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
+        elif ret.status == self.GetSetReturn.REPORT_ID_NOT_FOUND:
+            self.send_handshake_message(Message.Handshake.ERR_INVALID_REPORT_ID)
+        elif ret.status == self.GetSetReturn.ERR_INVALID_PARAMETER:
+            self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
+        elif ret.status == self.GetSetReturn.ERR_UNSUPPORTED_REQUEST:
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+
+    def register_get_report_cb(self, cb: Callable[[int, int, int], None]) -> None:
+        self.get_report_cb = cb
+        logger.debug("GetReport callback registered successfully")
+
+    def handle_set_report(self, pdu: bytes):
+        if self.set_report_cb is None:
+            logger.debug("SetReport callback not registered !!")
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+            return
+        report_type = pdu[0] & 0x03
+        report_id = pdu[1]
+        report_data = pdu[2:]
+        report_size = len(report_data) + 1
+        ret = self.set_report_cb(report_id, report_type, report_size, report_data)
+        assert ret is not None
+        if ret.status == self.GetSetReturn.SUCCESS:
+            self.send_handshake_message(Message.Handshake.SUCCESSFUL)
+        elif ret.status == self.GetSetReturn.ERR_INVALID_PARAMETER:
+            self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
+        elif ret.status == self.GetSetReturn.REPORT_ID_NOT_FOUND:
+            self.send_handshake_message(Message.Handshake.ERR_INVALID_REPORT_ID)
+        else:
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+
+    def register_set_report_cb(
+        self, cb: Callable[[int, int, int, bytes], None]
+    ) -> None:
+        self.set_report_cb = cb
+        logger.debug("SetReport callback registered successfully")
+
+    def handle_get_protocol(self, pdu: bytes):
+        if self.get_protocol_cb is None:
+            logger.debug("GetProtocol callback not registered !!")
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+            return
+        ret = self.get_protocol_cb()
+        assert ret is not None
+        if ret.status == self.GetSetReturn.SUCCESS:
+            self.send_control_data(Message.ReportType.OTHER_REPORT, ret.data)
+        else:
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+
+    def register_get_protocol_cb(self, cb: Callable[[], None]) -> None:
+        self.get_protocol_cb = cb
+        logger.debug("GetProtocol callback registered successfully")
+
+    def handle_set_protocol(self, pdu: bytes):
+        if self.set_protocol_cb is None:
+            logger.debug("SetProtocol callback not registered !!")
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+            return
+        ret = self.set_protocol_cb(pdu[0] & 0x01)
+        assert ret is not None
+        if ret.status == self.GetSetReturn.SUCCESS:
+            self.send_handshake_message(Message.Handshake.SUCCESSFUL)
+        else:
+            self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+
+    def register_set_protocol_cb(self, cb: Callable[[int], None]) -> None:
+        self.set_protocol_cb = cb
+        logger.debug("SetProtocol callback registered successfully")
+
+
+# -----------------------------------------------------------------------------
+class Host(HID):
+    def __init__(self, device: device.Device) -> None:
+        super().__init__(device, HID.Role.HOST)
 
     def get_report(self, report_type: int, report_id: int, buffer_size: int) -> None:
         msg = GetReportMessage(
@@ -283,50 +503,52 @@
         logger.debug(f'>>> HID CONTROL GET REPORT, PDU: {hid_message.hex()}')
         self.send_pdu_on_ctrl(hid_message)
 
-    def set_report(self, report_type: int, data: bytes):
+    def set_report(self, report_type: int, data: bytes) -> None:
         msg = SetReportMessage(report_type=report_type, data=data)
         hid_message = bytes(msg)
         logger.debug(f'>>> HID CONTROL SET REPORT, PDU:{hid_message.hex()}')
         self.send_pdu_on_ctrl(hid_message)
 
-    def get_protocol(self):
+    def get_protocol(self) -> None:
         msg = GetProtocolMessage()
         hid_message = bytes(msg)
         logger.debug(f'>>> HID CONTROL GET PROTOCOL, PDU: {hid_message.hex()}')
         self.send_pdu_on_ctrl(hid_message)
 
-    def set_protocol(self, protocol_mode: int):
+    def set_protocol(self, protocol_mode: int) -> None:
         msg = SetProtocolMessage(protocol_mode=protocol_mode)
         hid_message = bytes(msg)
         logger.debug(f'>>> HID CONTROL SET PROTOCOL, PDU: {hid_message.hex()}')
         self.send_pdu_on_ctrl(hid_message)
 
-    def send_pdu_on_ctrl(self, msg: bytes) -> None:
-        self.l2cap_ctrl_channel.send_pdu(msg)  # type: ignore
-
-    def send_pdu_on_intr(self, msg: bytes) -> None:
-        self.l2cap_intr_channel.send_pdu(msg)  # type: ignore
-
-    def send_data(self, data):
-        msg = SendData(data)
-        hid_message = bytes(msg)
-        logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
-        self.send_pdu_on_intr(hid_message)
-
-    def suspend(self):
+    def suspend(self) -> None:
         msg = Suspend()
         hid_message = bytes(msg)
         logger.debug(f'>>> HID CONTROL SUSPEND, PDU:{hid_message.hex()}')
-        self.send_pdu_on_ctrl(msg)
+        self.send_pdu_on_ctrl(hid_message)
 
-    def exit_suspend(self):
+    def exit_suspend(self) -> None:
         msg = ExitSuspend()
         hid_message = bytes(msg)
         logger.debug(f'>>> HID CONTROL EXIT SUSPEND, PDU:{hid_message.hex()}')
-        self.send_pdu_on_ctrl(msg)
+        self.send_pdu_on_ctrl(hid_message)
 
-    def virtual_cable_unplug(self):
-        msg = VirtualCableUnplug()
-        hid_message = bytes(msg)
-        logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
-        self.send_pdu_on_ctrl(msg)
+    @override
+    def on_ctrl_pdu(self, pdu: bytes) -> None:
+        logger.debug(f'<<< HID CONTROL PDU: {pdu.hex()}')
+        param = pdu[0] & 0x0F
+        message_type = pdu[0] >> 4
+        if message_type == Message.MessageType.HANDSHAKE:
+            logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}')
+            self.emit('handshake', Message.Handshake(param))
+        elif message_type == Message.MessageType.DATA:
+            logger.debug('<<< HID CONTROL DATA')
+            self.emit('control_data', pdu)
+        elif message_type == Message.MessageType.CONTROL:
+            if param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
+                logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
+                self.emit('virtual_cable_unplug')
+            else:
+                logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
+        else:
+            logger.debug('<<< HID MESSAGE TYPE UNSUPPORTED')
diff --git a/bumble/host.py b/bumble/host.py
index 02caa46..fd0a247 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -18,65 +18,35 @@
 from __future__ import annotations
 import asyncio
 import collections
+import dataclasses
 import logging
 import struct
 
-from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
+from typing import (
+    Any,
+    Awaitable,
+    Callable,
+    Deque,
+    Dict,
+    Optional,
+    Set,
+    cast,
+    TYPE_CHECKING,
+)
 
 from bumble.colors import color
 from bumble.l2cap import L2CAP_PDU
 from bumble.snoop import Snooper
 from bumble import drivers
-
-from .hci import (
-    Address,
-    HCI_ACL_DATA_PACKET,
-    HCI_COMMAND_PACKET,
-    HCI_COMMAND_COMPLETE_EVENT,
-    HCI_EVENT_PACKET,
-    HCI_LE_READ_BUFFER_SIZE_COMMAND,
-    HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
-    HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
-    HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
-    HCI_READ_BUFFER_SIZE_COMMAND,
-    HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND,
-    HCI_RESET_COMMAND,
-    HCI_SUCCESS,
-    HCI_SUPPORTED_COMMANDS_FLAGS,
-    HCI_VERSION_BLUETOOTH_CORE_4_0,
-    HCI_AclDataPacket,
-    HCI_AclDataPacketAssembler,
-    HCI_Command,
-    HCI_Command_Complete_Event,
-    HCI_Constant,
-    HCI_Error,
-    HCI_Event,
-    HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
-    HCI_LE_Long_Term_Key_Request_Reply_Command,
-    HCI_LE_Read_Buffer_Size_Command,
-    HCI_LE_Read_Local_Supported_Features_Command,
-    HCI_LE_Read_Suggested_Default_Data_Length_Command,
-    HCI_LE_Remote_Connection_Parameter_Request_Reply_Command,
-    HCI_LE_Set_Event_Mask_Command,
-    HCI_LE_Write_Suggested_Default_Data_Length_Command,
-    HCI_Link_Key_Request_Negative_Reply_Command,
-    HCI_Link_Key_Request_Reply_Command,
-    HCI_Packet,
-    HCI_Read_Buffer_Size_Command,
-    HCI_Read_Local_Supported_Commands_Command,
-    HCI_Read_Local_Version_Information_Command,
-    HCI_Reset_Command,
-    HCI_Set_Event_Mask_Command,
-)
-from .core import (
+from bumble import hci
+from bumble.core import (
     BT_BR_EDR_TRANSPORT,
     BT_LE_TRANSPORT,
     ConnectionPHY,
     ConnectionParameters,
-    InvalidStateError,
 )
-from .utils import AbortableEventEmitter
-from .transport.common import TransportLostError
+from bumble.utils import AbortableEventEmitter
+from bumble.transport.common import TransportLostError
 
 if TYPE_CHECKING:
     from .transport.common import TransportSink, TransportSource
@@ -89,28 +59,70 @@
 
 
 # -----------------------------------------------------------------------------
-# Constants
-# -----------------------------------------------------------------------------
-# fmt: off
+class AclPacketQueue:
+    max_packet_size: int
 
-HOST_DEFAULT_HC_LE_ACL_DATA_PACKET_LENGTH = 27
-HOST_HC_TOTAL_NUM_LE_ACL_DATA_PACKETS     = 1
-HOST_DEFAULT_HC_ACL_DATA_PACKET_LENGTH    = 27
-HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS        = 1
+    def __init__(
+        self,
+        max_packet_size: int,
+        max_in_flight: int,
+        send: Callable[[hci.HCI_Packet], None],
+    ) -> None:
+        self.max_packet_size = max_packet_size
+        self.max_in_flight = max_in_flight
+        self.in_flight = 0
+        self.send = send
+        self.packets: Deque[hci.HCI_AclDataPacket] = collections.deque()
 
-# fmt: on
+    def enqueue(self, packet: hci.HCI_AclDataPacket) -> None:
+        self.packets.appendleft(packet)
+        self.check_queue()
+
+        if self.packets:
+            logger.debug(
+                f'{self.in_flight} ACL packets in flight, '
+                f'{len(self.packets)} in queue'
+            )
+
+    def check_queue(self) -> None:
+        while self.packets and self.in_flight < self.max_in_flight:
+            packet = self.packets.pop()
+            self.send(packet)
+            self.in_flight += 1
+
+    def on_packets_completed(self, packet_count: int) -> None:
+        if packet_count > self.in_flight:
+            logger.warning(
+                color(
+                    '!!! {packet_count} completed but only '
+                    f'{self.in_flight} in flight'
+                )
+            )
+            packet_count = self.in_flight
+
+        self.in_flight -= packet_count
+        self.check_queue()
 
 
 # -----------------------------------------------------------------------------
 class Connection:
-    def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
+    def __init__(
+        self, host: Host, handle: int, peer_address: hci.Address, transport: int
+    ):
         self.host = host
         self.handle = handle
         self.peer_address = peer_address
-        self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
+        self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
         self.transport = transport
+        acl_packet_queue: Optional[AclPacketQueue] = (
+            host.le_acl_packet_queue
+            if transport == BT_LE_TRANSPORT
+            else host.acl_packet_queue
+        )
+        assert acl_packet_queue
+        self.acl_packet_queue = acl_packet_queue
 
-    def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
+    def on_hci_acl_data_packet(self, packet: hci.HCI_AclDataPacket) -> None:
         self.assembler.feed_packet(packet)
 
     def on_acl_pdu(self, pdu: bytes) -> None:
@@ -119,14 +131,32 @@
 
 
 # -----------------------------------------------------------------------------
+@dataclasses.dataclass
+class ScoLink:
+    peer_address: hci.Address
+    handle: int
+
+
+# -----------------------------------------------------------------------------
+@dataclasses.dataclass
+class CisLink:
+    peer_address: hci.Address
+    handle: int
+
+
+# -----------------------------------------------------------------------------
 class Host(AbortableEventEmitter):
     connections: Dict[int, Connection]
-    acl_packet_queue: collections.deque[HCI_AclDataPacket]
-    hci_sink: TransportSink
+    cis_links: Dict[int, CisLink]
+    sco_links: Dict[int, ScoLink]
+    acl_packet_queue: Optional[AclPacketQueue] = None
+    le_acl_packet_queue: Optional[AclPacketQueue] = None
+    hci_sink: Optional[TransportSink] = None
+    hci_metadata: Dict[str, Any]
     long_term_key_provider: Optional[
         Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
     ]
-    link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
+    link_key_provider: Optional[Callable[[hci.Address], Awaitable[Optional[bytes]]]]
 
     def __init__(
         self,
@@ -135,21 +165,19 @@
     ) -> None:
         super().__init__()
 
-        self.hci_metadata = None
+        self.hci_metadata = {}
         self.ready = False  # True when we can accept incoming packets
-        self.reset_done = False
         self.connections = {}  # Connections, by connection handle
+        self.cis_links = {}  # CIS links, by connection handle
+        self.sco_links = {}  # SCO links, by connection handle
         self.pending_command = None
         self.pending_response = None
-        self.hc_le_acl_data_packet_length = HOST_DEFAULT_HC_LE_ACL_DATA_PACKET_LENGTH
-        self.hc_total_num_le_acl_data_packets = HOST_HC_TOTAL_NUM_LE_ACL_DATA_PACKETS
-        self.hc_acl_data_packet_length = HOST_DEFAULT_HC_ACL_DATA_PACKET_LENGTH
-        self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
-        self.acl_packet_queue = collections.deque()
-        self.acl_packets_in_flight = 0
+        self.number_of_supported_advertising_sets = 0
+        self.maximum_advertising_data_length = 31
         self.local_version = None
-        self.local_supported_commands = bytes(64)
+        self.local_supported_commands = 0
         self.local_le_features = 0
+        self.local_lmp_features = hci.LmpFeatureMask(0)  # Classic LMP features
         self.suggested_max_tx_octets = 251  # Max allowed
         self.suggested_max_tx_time = 2120  # Max allowed
         self.command_semaphore = asyncio.Semaphore(1)
@@ -160,16 +188,13 @@
 
         # Connect to the source and sink if specified
         if controller_source:
-            controller_source.set_packet_sink(self)
-            self.hci_metadata = getattr(
-                controller_source, 'metadata', self.hci_metadata
-            )
+            self.set_packet_source(controller_source)
         if controller_sink:
             self.set_packet_sink(controller_sink)
 
     def find_connection_by_bd_addr(
         self,
-        bd_addr: Address,
+        bd_addr: hci.Address,
         transport: Optional[int] = None,
         check_address_type: bool = False,
     ) -> Optional[Connection]:
@@ -198,105 +223,237 @@
             self.ready = False
             await self.flush()
 
-        await self.send_command(HCI_Reset_Command(), check_result=True)
-        self.ready = True
-
         # Instantiate and init a driver for the host if needed.
         # NOTE: we don't keep a reference to the driver here, because we don't
         # currently have a need for the driver later on. But if the driver interface
         # evolves, it may be required, then, to store a reference to the driver in
         # an object property.
+        reset_needed = True
         if driver_factory is not None:
             if driver := await driver_factory(self):
                 await driver.init_controller()
+                reset_needed = False
+
+        # Send a reset command unless a driver has already done so.
+        if reset_needed:
+            await self.send_command(hci.HCI_Reset_Command(), check_result=True)
+            self.ready = True
 
         response = await self.send_command(
-            HCI_Read_Local_Supported_Commands_Command(), check_result=True
+            hci.HCI_Read_Local_Supported_Commands_Command(), check_result=True
         )
-        self.local_supported_commands = response.return_parameters.supported_commands
+        self.local_supported_commands = int.from_bytes(
+            response.return_parameters.supported_commands, 'little'
+        )
 
-        if self.supports_command(HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
+        if self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
             response = await self.send_command(
-                HCI_LE_Read_Local_Supported_Features_Command(), check_result=True
+                hci.HCI_LE_Read_Local_Supported_Features_Command(), check_result=True
             )
             self.local_le_features = struct.unpack(
                 '<Q', response.return_parameters.le_features
             )[0]
 
-        if self.supports_command(HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
+        if self.supports_command(hci.HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
             response = await self.send_command(
-                HCI_Read_Local_Version_Information_Command(), check_result=True
+                hci.HCI_Read_Local_Version_Information_Command(), check_result=True
             )
             self.local_version = response.return_parameters
 
+        if self.supports_command(hci.HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND):
+            max_page_number = 0
+            page_number = 0
+            lmp_features = 0
+            while page_number <= max_page_number:
+                response = await self.send_command(
+                    hci.HCI_Read_Local_Extended_Features_Command(
+                        page_number=page_number
+                    ),
+                    check_result=True,
+                )
+                lmp_features |= int.from_bytes(
+                    response.return_parameters.extended_lmp_features, 'little'
+                ) << (64 * page_number)
+                max_page_number = response.return_parameters.maximum_page_number
+                page_number += 1
+            self.local_lmp_features = hci.LmpFeatureMask(lmp_features)
+
+        elif self.supports_command(hci.HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
+            response = await self.send_command(
+                hci.HCI_Read_Local_Supported_Features_Command(), check_result=True
+            )
+            self.local_lmp_features = hci.LmpFeatureMask(
+                int.from_bytes(response.return_parameters.lmp_features, 'little')
+            )
+
         await self.send_command(
-            HCI_Set_Event_Mask_Command(event_mask=bytes.fromhex('FFFFFFFFFFFFFF3F'))
+            hci.HCI_Set_Event_Mask_Command(
+                event_mask=hci.HCI_Set_Event_Mask_Command.mask(
+                    [
+                        hci.HCI_INQUIRY_COMPLETE_EVENT,
+                        hci.HCI_INQUIRY_RESULT_EVENT,
+                        hci.HCI_CONNECTION_COMPLETE_EVENT,
+                        hci.HCI_CONNECTION_REQUEST_EVENT,
+                        hci.HCI_DISCONNECTION_COMPLETE_EVENT,
+                        hci.HCI_AUTHENTICATION_COMPLETE_EVENT,
+                        hci.HCI_REMOTE_NAME_REQUEST_COMPLETE_EVENT,
+                        hci.HCI_ENCRYPTION_CHANGE_EVENT,
+                        hci.HCI_CHANGE_CONNECTION_LINK_KEY_COMPLETE_EVENT,
+                        hci.HCI_LINK_KEY_TYPE_CHANGED_EVENT,
+                        hci.HCI_READ_REMOTE_SUPPORTED_FEATURES_COMPLETE_EVENT,
+                        hci.HCI_READ_REMOTE_VERSION_INFORMATION_COMPLETE_EVENT,
+                        hci.HCI_QOS_SETUP_COMPLETE_EVENT,
+                        hci.HCI_HARDWARE_ERROR_EVENT,
+                        hci.HCI_FLUSH_OCCURRED_EVENT,
+                        hci.HCI_ROLE_CHANGE_EVENT,
+                        hci.HCI_MODE_CHANGE_EVENT,
+                        hci.HCI_RETURN_LINK_KEYS_EVENT,
+                        hci.HCI_PIN_CODE_REQUEST_EVENT,
+                        hci.HCI_LINK_KEY_REQUEST_EVENT,
+                        hci.HCI_LINK_KEY_NOTIFICATION_EVENT,
+                        hci.HCI_LOOPBACK_COMMAND_EVENT,
+                        hci.HCI_DATA_BUFFER_OVERFLOW_EVENT,
+                        hci.HCI_MAX_SLOTS_CHANGE_EVENT,
+                        hci.HCI_READ_CLOCK_OFFSET_COMPLETE_EVENT,
+                        hci.HCI_CONNECTION_PACKET_TYPE_CHANGED_EVENT,
+                        hci.HCI_QOS_VIOLATION_EVENT,
+                        hci.HCI_PAGE_SCAN_REPETITION_MODE_CHANGE_EVENT,
+                        hci.HCI_FLOW_SPECIFICATION_COMPLETE_EVENT,
+                        hci.HCI_INQUIRY_RESULT_WITH_RSSI_EVENT,
+                        hci.HCI_READ_REMOTE_EXTENDED_FEATURES_COMPLETE_EVENT,
+                        hci.HCI_SYNCHRONOUS_CONNECTION_COMPLETE_EVENT,
+                        hci.HCI_SYNCHRONOUS_CONNECTION_CHANGED_EVENT,
+                        hci.HCI_SNIFF_SUBRATING_EVENT,
+                        hci.HCI_EXTENDED_INQUIRY_RESULT_EVENT,
+                        hci.HCI_ENCRYPTION_KEY_REFRESH_COMPLETE_EVENT,
+                        hci.HCI_IO_CAPABILITY_REQUEST_EVENT,
+                        hci.HCI_IO_CAPABILITY_RESPONSE_EVENT,
+                        hci.HCI_USER_CONFIRMATION_REQUEST_EVENT,
+                        hci.HCI_USER_PASSKEY_REQUEST_EVENT,
+                        hci.HCI_REMOTE_OOB_DATA_REQUEST_EVENT,
+                        hci.HCI_SIMPLE_PAIRING_COMPLETE_EVENT,
+                        hci.HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT,
+                        hci.HCI_ENHANCED_FLUSH_COMPLETE_EVENT,
+                        hci.HCI_USER_PASSKEY_NOTIFICATION_EVENT,
+                        hci.HCI_KEYPRESS_NOTIFICATION_EVENT,
+                        hci.HCI_REMOTE_HOST_SUPPORTED_FEATURES_NOTIFICATION_EVENT,
+                        hci.HCI_LE_META_EVENT,
+                    ]
+                )
+            )
         )
 
         if (
             self.local_version is not None
-            and self.local_version.hci_version <= HCI_VERSION_BLUETOOTH_CORE_4_0
+            and self.local_version.hci_version <= hci.HCI_VERSION_BLUETOOTH_CORE_4_0
         ):
             # Some older controllers don't like event masks with bits they don't
             # understand
             le_event_mask = bytes.fromhex('1F00000000000000')
         else:
-            le_event_mask = bytes.fromhex('FFFFF00000000000')
+            le_event_mask = hci.HCI_LE_Set_Event_Mask_Command.mask(
+                [
+                    hci.HCI_LE_CONNECTION_COMPLETE_EVENT,
+                    hci.HCI_LE_ADVERTISING_REPORT_EVENT,
+                    hci.HCI_LE_CONNECTION_UPDATE_COMPLETE_EVENT,
+                    hci.HCI_LE_READ_REMOTE_FEATURES_COMPLETE_EVENT,
+                    hci.HCI_LE_LONG_TERM_KEY_REQUEST_EVENT,
+                    hci.HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_EVENT,
+                    hci.HCI_LE_DATA_LENGTH_CHANGE_EVENT,
+                    hci.HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT,
+                    hci.HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT,
+                    hci.HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT,
+                    hci.HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT,
+                    hci.HCI_LE_PHY_UPDATE_COMPLETE_EVENT,
+                    hci.HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT,
+                    hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT,
+                    hci.HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT,
+                    hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT,
+                    hci.HCI_LE_SCAN_TIMEOUT_EVENT,
+                    hci.HCI_LE_ADVERTISING_SET_TERMINATED_EVENT,
+                    hci.HCI_LE_SCAN_REQUEST_RECEIVED_EVENT,
+                    hci.HCI_LE_CONNECTIONLESS_IQ_REPORT_EVENT,
+                    hci.HCI_LE_CONNECTION_IQ_REPORT_EVENT,
+                    hci.HCI_LE_CTE_REQUEST_FAILED_EVENT,
+                    hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_EVENT,
+                    hci.HCI_LE_CIS_ESTABLISHED_EVENT,
+                    hci.HCI_LE_CIS_REQUEST_EVENT,
+                    hci.HCI_LE_CREATE_BIG_COMPLETE_EVENT,
+                    hci.HCI_LE_TERMINATE_BIG_COMPLETE_EVENT,
+                    hci.HCI_LE_BIG_SYNC_ESTABLISHED_EVENT,
+                    hci.HCI_LE_BIG_SYNC_LOST_EVENT,
+                    hci.HCI_LE_REQUEST_PEER_SCA_COMPLETE_EVENT,
+                    hci.HCI_LE_PATH_LOSS_THRESHOLD_EVENT,
+                    hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
+                    hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
+                    hci.HCI_LE_SUBRATE_CHANGE_EVENT,
+                ]
+            )
 
         await self.send_command(
-            HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
+            hci.HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
         )
 
-        if self.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
+        if self.supports_command(hci.HCI_READ_BUFFER_SIZE_COMMAND):
             response = await self.send_command(
-                HCI_Read_Buffer_Size_Command(), check_result=True
+                hci.HCI_Read_Buffer_Size_Command(), check_result=True
             )
-            self.hc_acl_data_packet_length = (
+            hc_acl_data_packet_length = (
                 response.return_parameters.hc_acl_data_packet_length
             )
-            self.hc_total_num_acl_data_packets = (
+            hc_total_num_acl_data_packets = (
                 response.return_parameters.hc_total_num_acl_data_packets
             )
 
             logger.debug(
                 'HCI ACL flow control: '
-                f'hc_acl_data_packet_length={self.hc_acl_data_packet_length},'
-                f'hc_total_num_acl_data_packets={self.hc_total_num_acl_data_packets}'
+                f'hc_acl_data_packet_length={hc_acl_data_packet_length},'
+                f'hc_total_num_acl_data_packets={hc_total_num_acl_data_packets}'
             )
 
-        if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
-            response = await self.send_command(
-                HCI_LE_Read_Buffer_Size_Command(), check_result=True
+            self.acl_packet_queue = AclPacketQueue(
+                max_packet_size=hc_acl_data_packet_length,
+                max_in_flight=hc_total_num_acl_data_packets,
+                send=self.send_hci_packet,
             )
-            self.hc_le_acl_data_packet_length = (
+
+        hc_le_acl_data_packet_length = 0
+        hc_total_num_le_acl_data_packets = 0
+        if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
+            response = await self.send_command(
+                hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
+            )
+            hc_le_acl_data_packet_length = (
                 response.return_parameters.hc_le_acl_data_packet_length
             )
-            self.hc_total_num_le_acl_data_packets = (
+            hc_total_num_le_acl_data_packets = (
                 response.return_parameters.hc_total_num_le_acl_data_packets
             )
 
             logger.debug(
                 'HCI LE ACL flow control: '
-                f'hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length},'
-                'hc_total_num_le_acl_data_packets='
-                f'{self.hc_total_num_le_acl_data_packets}'
+                f'hc_le_acl_data_packet_length={hc_le_acl_data_packet_length},'
+                f'hc_total_num_le_acl_data_packets={hc_total_num_le_acl_data_packets}'
             )
 
-            if (
-                response.return_parameters.hc_le_acl_data_packet_length == 0
-                or response.return_parameters.hc_total_num_le_acl_data_packets == 0
-            ):
-                # LE and Classic share the same values
-                self.hc_le_acl_data_packet_length = self.hc_acl_data_packet_length
-                self.hc_total_num_le_acl_data_packets = (
-                    self.hc_total_num_acl_data_packets
-                )
+        if hc_le_acl_data_packet_length == 0 or hc_total_num_le_acl_data_packets == 0:
+            # LE and Classic share the same queue
+            self.le_acl_packet_queue = self.acl_packet_queue
+        else:
+            # Create a separate queue for LE
+            self.le_acl_packet_queue = AclPacketQueue(
+                max_packet_size=hc_le_acl_data_packet_length,
+                max_in_flight=hc_total_num_le_acl_data_packets,
+                send=self.send_hci_packet,
+            )
 
         if self.supports_command(
-            HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
-        ) and self.supports_command(HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
+            hci.HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
+        ) and self.supports_command(
+            hci.HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
+        ):
             response = await self.send_command(
-                HCI_LE_Read_Suggested_Default_Data_Length_Command()
+                hci.HCI_LE_Read_Suggested_Default_Data_Length_Command()
             )
             suggested_max_tx_octets = response.return_parameters.suggested_max_tx_octets
             suggested_max_tx_time = response.return_parameters.suggested_max_tx_time
@@ -305,35 +462,59 @@
                 or suggested_max_tx_time != self.suggested_max_tx_time
             ):
                 await self.send_command(
-                    HCI_LE_Write_Suggested_Default_Data_Length_Command(
+                    hci.HCI_LE_Write_Suggested_Default_Data_Length_Command(
                         suggested_max_tx_octets=self.suggested_max_tx_octets,
                         suggested_max_tx_time=self.suggested_max_tx_time,
                     )
                 )
 
-        self.reset_done = True
+        if self.supports_command(
+            hci.HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND
+        ):
+            response = await self.send_command(
+                hci.HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command(),
+                check_result=True,
+            )
+            self.number_of_supported_advertising_sets = (
+                response.return_parameters.num_supported_advertising_sets
+            )
+
+        if self.supports_command(
+            hci.HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND
+        ):
+            response = await self.send_command(
+                hci.HCI_LE_Read_Maximum_Advertising_Data_Length_Command(),
+                check_result=True,
+            )
+            self.maximum_advertising_data_length = (
+                response.return_parameters.max_advertising_data_length
+            )
 
     @property
-    def controller(self) -> TransportSink:
+    def controller(self) -> Optional[TransportSink]:
         return self.hci_sink
 
     @controller.setter
-    def controller(self, controller):
+    def controller(self, controller) -> None:
         self.set_packet_sink(controller)
         if controller:
-            controller.set_packet_sink(self)
+            self.set_packet_source(controller)
 
-    def set_packet_sink(self, sink: TransportSink) -> None:
+    def set_packet_sink(self, sink: Optional[TransportSink]) -> None:
         self.hci_sink = sink
 
-    def send_hci_packet(self, packet: HCI_Packet) -> None:
+    def set_packet_source(self, source: TransportSource) -> None:
+        source.set_packet_sink(self)
+        self.hci_metadata = getattr(source, 'metadata', self.hci_metadata)
+
+    def send_hci_packet(self, packet: hci.HCI_Packet) -> None:
+        logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {packet}')
         if self.snooper:
             self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
-        self.hci_sink.on_packet(bytes(packet))
+        if self.hci_sink:
+            self.hci_sink.on_packet(bytes(packet))
 
     async def send_command(self, command, check_result=False):
-        logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
-
         # Wait until we can send (only one pending command at a time)
         async with self.command_semaphore:
             assert self.pending_command is None
@@ -357,11 +538,12 @@
                     else:
                         status = response.return_parameters.status
 
-                    if status != HCI_SUCCESS:
+                    if status != hci.HCI_SUCCESS:
                         logger.warning(
-                            f'{command.name} failed ({HCI_Constant.error_name(status)})'
+                            f'{command.name} failed '
+                            f'({hci.HCI_Constant.error_name(status)})'
                         )
-                        raise HCI_Error(status)
+                        raise hci.HCI_Error(status)
 
                 return response
             except Exception as error:
@@ -374,13 +556,24 @@
                 self.pending_response = None
 
     # Use this method to send a command from a task
-    def send_command_sync(self, command: HCI_Command) -> None:
-        async def send_command(command: HCI_Command) -> None:
+    def send_command_sync(self, command: hci.HCI_Command) -> None:
+        async def send_command(command: hci.HCI_Command) -> None:
             await self.send_command(command)
 
         asyncio.create_task(send_command(command))
 
     def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
+        if not (connection := self.connections.get(connection_handle)):
+            logger.warning(f'connection 0x{connection_handle:04X} not found')
+            return
+        packet_queue = connection.acl_packet_queue
+        if packet_queue is None:
+            logger.warning(
+                f'no ACL packet queue for connection 0x{connection_handle:04X}'
+            )
+            return
+
+        # Create a PDU
         l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
 
         # Send the data to the controller via ACL packets
@@ -388,71 +581,39 @@
         offset = 0
         pb_flag = 0
         while bytes_remaining:
-            # TODO: support different LE/Classic lengths
-            data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
-            acl_packet = HCI_AclDataPacket(
+            data_total_length = min(bytes_remaining, packet_queue.max_packet_size)
+            acl_packet = hci.HCI_AclDataPacket(
                 connection_handle=connection_handle,
                 pb_flag=pb_flag,
                 bc_flag=0,
                 data_total_length=data_total_length,
                 data=l2cap_pdu[offset : offset + data_total_length],
             )
-            logger.debug(
-                f'{color("### HOST -> CONTROLLER", "blue")}: (CID={cid}) {acl_packet}'
-            )
-            self.queue_acl_packet(acl_packet)
+            logger.debug(f'>>> ACL packet enqueue: (CID={cid}) {acl_packet}')
+            packet_queue.enqueue(acl_packet)
             pb_flag = 1
             offset += data_total_length
             bytes_remaining -= data_total_length
 
-    def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
-        self.acl_packet_queue.appendleft(acl_packet)
-        self.check_acl_packet_queue()
-
-        if len(self.acl_packet_queue):
-            logger.debug(
-                f'{self.acl_packets_in_flight} ACL packets in flight, '
-                f'{len(self.acl_packet_queue)} in queue'
-            )
-
-    def check_acl_packet_queue(self) -> None:
-        # Send all we can (TODO: support different LE/Classic limits)
-        while (
-            len(self.acl_packet_queue) > 0
-            and self.acl_packets_in_flight < self.hc_total_num_le_acl_data_packets
-        ):
-            packet = self.acl_packet_queue.pop()
-            self.send_hci_packet(packet)
-            self.acl_packets_in_flight += 1
-
-    def supports_command(self, command):
-        # Find the support flag position for this command
-        for octet, flags in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
-            for flag_position, value in enumerate(flags):
-                if value == command:
-                    # Check if the flag is set
-                    if octet < len(self.local_supported_commands) and flag_position < 8:
-                        return (
-                            self.local_supported_commands[octet] & (1 << flag_position)
-                        ) != 0
-
-        return False
+    def supports_command(self, op_code: int) -> bool:
+        return (
+            self.local_supported_commands
+            & hci.HCI_SUPPORTED_COMMANDS_MASKS.get(op_code, 0)
+        ) != 0
 
     @property
-    def supported_commands(self):
-        commands = []
-        for octet, flags in enumerate(self.local_supported_commands):
-            if octet < len(HCI_SUPPORTED_COMMANDS_FLAGS):
-                for flag in range(8):
-                    if flags & (1 << flag) != 0:
-                        command = HCI_SUPPORTED_COMMANDS_FLAGS[octet][flag]
-                        if command is not None:
-                            commands.append(command)
+    def supported_commands(self) -> Set[int]:
+        return set(
+            op_code
+            for op_code, mask in hci.HCI_SUPPORTED_COMMANDS_MASKS.items()
+            if self.local_supported_commands & mask
+        )
 
-        return commands
+    def supports_le_features(self, feature: hci.LeFeatureMask) -> bool:
+        return (self.local_le_features & feature) == feature
 
-    def supports_le_feature(self, feature):
-        return (self.local_le_features & (1 << feature)) != 0
+    def supports_lmp_features(self, feature: hci.LmpFeatureMask) -> bool:
+        return self.local_lmp_features & (feature) == feature
 
     @property
     def supported_le_features(self):
@@ -462,10 +623,10 @@
 
     # Packet Sink protocol (packets coming from the controller via HCI)
     def on_packet(self, packet: bytes) -> None:
-        hci_packet = HCI_Packet.from_bytes(packet)
+        hci_packet = hci.HCI_Packet.from_bytes(packet)
         if self.ready or (
-            isinstance(hci_packet, HCI_Command_Complete_Event)
-            and hci_packet.command_opcode == HCI_RESET_COMMAND
+            isinstance(hci_packet, hci.HCI_Command_Complete_Event)
+            and hci_packet.command_opcode == hci.HCI_RESET_COMMAND
         ):
             self.on_hci_packet(hci_packet)
         else:
@@ -478,35 +639,47 @@
 
         self.emit('flush')
 
-    def on_hci_packet(self, packet: HCI_Packet) -> None:
+    def on_hci_packet(self, packet: hci.HCI_Packet) -> None:
         logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
 
         if self.snooper:
             self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
 
         # If the packet is a command, invoke the handler for this packet
-        if isinstance(packet, HCI_Command):
-            self.on_hci_command_packet(packet)
-        elif isinstance(packet, HCI_Event):
-            self.on_hci_event_packet(packet)
-        elif isinstance(packet, HCI_AclDataPacket):
-            self.on_hci_acl_data_packet(packet)
+        if packet.hci_packet_type == hci.HCI_COMMAND_PACKET:
+            self.on_hci_command_packet(cast(hci.HCI_Command, packet))
+        elif packet.hci_packet_type == hci.HCI_EVENT_PACKET:
+            self.on_hci_event_packet(cast(hci.HCI_Event, packet))
+        elif packet.hci_packet_type == hci.HCI_ACL_DATA_PACKET:
+            self.on_hci_acl_data_packet(cast(hci.HCI_AclDataPacket, packet))
+        elif packet.hci_packet_type == hci.HCI_SYNCHRONOUS_DATA_PACKET:
+            self.on_hci_sco_data_packet(cast(hci.HCI_SynchronousDataPacket, packet))
+        elif packet.hci_packet_type == hci.HCI_ISO_DATA_PACKET:
+            self.on_hci_iso_data_packet(cast(hci.HCI_IsoDataPacket, packet))
         else:
             logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
 
-    def on_hci_command_packet(self, command: HCI_Command) -> None:
+    def on_hci_command_packet(self, command: hci.HCI_Command) -> None:
         logger.warning(f'!!! unexpected command packet: {command}')
 
-    def on_hci_event_packet(self, event: HCI_Event) -> None:
+    def on_hci_event_packet(self, event: hci.HCI_Event) -> None:
         handler_name = f'on_{event.name.lower()}'
         handler = getattr(self, handler_name, self.on_hci_event)
         handler(event)
 
-    def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
+    def on_hci_acl_data_packet(self, packet: hci.HCI_AclDataPacket) -> None:
         # Look for the connection to which this data belongs
         if connection := self.connections.get(packet.connection_handle):
             connection.on_hci_acl_data_packet(packet)
 
+    def on_hci_sco_data_packet(self, packet: hci.HCI_SynchronousDataPacket) -> None:
+        # Experimental
+        self.emit('sco_packet', packet.connection_handle, packet)
+
+    def on_hci_iso_data_packet(self, packet: hci.HCI_IsoDataPacket) -> None:
+        # Experimental
+        self.emit('iso_packet', packet.connection_handle, packet)
+
     def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
         self.emit('l2cap_pdu', connection.handle, cid, pdu)
 
@@ -535,7 +708,7 @@
             # This is used just for the Num_HCI_Command_Packets field, not related to
             # an actual command
             logger.debug('no-command event')
-            return None
+            return
 
         return self.on_command_processed(event)
 
@@ -543,18 +716,17 @@
         return self.on_command_processed(event)
 
     def on_hci_number_of_completed_packets_event(self, event):
-        total_packets = sum(event.num_completed_packets)
-        if total_packets <= self.acl_packets_in_flight:
-            self.acl_packets_in_flight -= total_packets
-            self.check_acl_packet_queue()
-        else:
-            logger.warning(
-                color(
-                    '!!! {total_packets} completed but only '
-                    f'{self.acl_packets_in_flight} in flight'
+        for connection_handle, num_completed_packets in zip(
+            event.connection_handles, event.num_completed_packets
+        ):
+            if not (connection := self.connections.get(connection_handle)):
+                logger.warning(
+                    'received packet completion event for unknown handle '
+                    f'0x{connection_handle:04X}'
                 )
-            )
-            self.acl_packets_in_flight = 0
+                continue
+
+            connection.acl_packet_queue.on_packets_completed(num_completed_packets)
 
     # Classic only
     def on_hci_connection_request_event(self, event):
@@ -568,11 +740,11 @@
 
     def on_hci_le_connection_complete_event(self, event):
         # Check if this is a cancellation
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             # Create/update the connection
             logger.debug(
                 f'### LE CONNECTION: [0x{event.connection_handle:04X}] '
-                f'{event.peer_address} as {HCI_Constant.role_name(event.role)}'
+                f'{event.peer_address} as {hci.HCI_Constant.role_name(event.role)}'
             )
 
             connection = self.connections.get(event.connection_handle)
@@ -612,7 +784,7 @@
         self.on_hci_le_connection_complete_event(event)
 
     def on_hci_connection_complete_event(self, event):
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             # Create/update the connection
             logger.debug(
                 f'### BR/EDR CONNECTION: [0x{event.connection_handle:04X}] '
@@ -648,25 +820,38 @@
 
     def on_hci_disconnection_complete_event(self, event):
         # Find the connection
-        if (connection := self.connections.get(event.connection_handle)) is None:
+        handle = event.connection_handle
+        if (
+            connection := (
+                self.connections.get(handle)
+                or self.cis_links.get(handle)
+                or self.sco_links.get(handle)
+            )
+        ) is None:
             logger.warning('!!! DISCONNECTION COMPLETE: unknown handle')
             return
 
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             logger.debug(
-                f'### DISCONNECTION: [0x{event.connection_handle:04X}] '
+                f'### DISCONNECTION: [0x{handle:04X}] '
                 f'{connection.peer_address} '
                 f'reason={event.reason}'
             )
-            del self.connections[event.connection_handle]
 
             # Notify the listeners
-            self.emit('disconnection', event.connection_handle, event.reason)
+            self.emit('disconnection', handle, event.reason)
+
+            # Remove the handle reference
+            _ = (
+                self.connections.pop(handle, 0)
+                or self.cis_links.pop(handle, 0)
+                or self.sco_links.pop(handle, 0)
+            )
         else:
             logger.debug(f'### DISCONNECTION FAILED: {event.status}')
 
             # Notify the listeners
-            self.emit('disconnection_failure', event.connection_handle, event.status)
+            self.emit('disconnection_failure', handle, event.status)
 
     def on_hci_le_connection_update_complete_event(self, event):
         if (connection := self.connections.get(event.connection_handle)) is None:
@@ -674,7 +859,7 @@
             return
 
         # Notify the client
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             connection_parameters = ConnectionParameters(
                 event.connection_interval,
                 event.peripheral_latency,
@@ -694,7 +879,7 @@
             return
 
         # Notify the client
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             connection_phy = ConnectionPHY(event.tx_phy, event.rx_phy)
             self.emit('connection_phy_update', connection.handle, connection_phy)
         else:
@@ -707,6 +892,37 @@
     def on_hci_le_extended_advertising_report_event(self, event):
         self.on_hci_le_advertising_report_event(event)
 
+    def on_hci_le_advertising_set_terminated_event(self, event):
+        self.emit(
+            'advertising_set_termination',
+            event.status,
+            event.advertising_handle,
+            event.connection_handle,
+            event.num_completed_extended_advertising_events,
+        )
+
+    def on_hci_le_cis_request_event(self, event):
+        self.emit(
+            'cis_request',
+            event.acl_connection_handle,
+            event.cis_connection_handle,
+            event.cig_id,
+            event.cis_id,
+        )
+
+    def on_hci_le_cis_established_event(self, event):
+        # The remaining parameters are unused for now.
+        if event.status == hci.HCI_SUCCESS:
+            self.cis_links[event.connection_handle] = CisLink(
+                handle=event.connection_handle,
+                peer_address=hci.Address.ANY,
+            )
+            self.emit('cis_establishment', event.connection_handle)
+        else:
+            self.emit(
+                'cis_establishment_failure', event.connection_handle, event.status
+            )
+
     def on_hci_le_remote_connection_parameter_request_event(self, event):
         if event.connection_handle not in self.connections:
             logger.warning('!!! REMOTE CONNECTION PARAMETER REQUEST: unknown handle')
@@ -715,7 +931,7 @@
         # For now, just accept everything
         # TODO: delegate the decision
         self.send_command_sync(
-            HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
+            hci.HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
                 connection_handle=event.connection_handle,
                 interval_min=event.interval_min,
                 interval_max=event.interval_max,
@@ -746,12 +962,12 @@
                     ),
                 )
             if long_term_key:
-                response = HCI_LE_Long_Term_Key_Request_Reply_Command(
+                response = hci.HCI_LE_Long_Term_Key_Request_Reply_Command(
                     connection_handle=event.connection_handle,
                     long_term_key=long_term_key,
                 )
             else:
-                response = HCI_LE_Long_Term_Key_Request_Negative_Reply_Command(
+                response = hci.HCI_LE_Long_Term_Key_Request_Negative_Reply_Command(
                     connection_handle=event.connection_handle
                 )
 
@@ -760,22 +976,45 @@
         asyncio.create_task(send_long_term_key())
 
     def on_hci_synchronous_connection_complete_event(self, event):
-        pass
+        if event.status == hci.HCI_SUCCESS:
+            # Create/update the connection
+            logger.debug(
+                f'### SCO CONNECTION: [0x{event.connection_handle:04X}] '
+                f'{event.bd_addr}'
+            )
+
+            self.sco_links[event.connection_handle] = ScoLink(
+                peer_address=event.bd_addr,
+                handle=event.connection_handle,
+            )
+
+            # Notify the client
+            self.emit(
+                'sco_connection',
+                event.bd_addr,
+                event.connection_handle,
+                event.link_type,
+            )
+        else:
+            logger.debug(f'### SCO CONNECTION FAILED: {event.status}')
+
+            # Notify the client
+            self.emit('sco_connection_failure', event.bd_addr, event.status)
 
     def on_hci_synchronous_connection_changed_event(self, event):
         pass
 
     def on_hci_role_change_event(self, event):
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             logger.debug(
                 f'role change for {event.bd_addr}: '
-                f'{HCI_Constant.role_name(event.new_role)}'
+                f'{hci.HCI_Constant.role_name(event.new_role)}'
             )
             self.emit('role_change', event.bd_addr, event.new_role)
         else:
             logger.debug(
                 f'role change for {event.bd_addr} failed: '
-                f'{HCI_Constant.error_name(event.status)}'
+                f'{hci.HCI_Constant.error_name(event.status)}'
             )
             self.emit('role_change_failure', event.bd_addr, event.status)
 
@@ -791,7 +1030,7 @@
 
     def on_hci_authentication_complete_event(self, event):
         # Notify the client
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             self.emit('connection_authentication', event.connection_handle)
         else:
             self.emit(
@@ -802,7 +1041,7 @@
 
     def on_hci_encryption_change_event(self, event):
         # Notify the client
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             self.emit(
                 'connection_encryption_change',
                 event.connection_handle,
@@ -815,7 +1054,7 @@
 
     def on_hci_encryption_key_refresh_complete_event(self, event):
         # Notify the client
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             self.emit('connection_encryption_key_refresh', event.connection_handle)
         else:
             self.emit(
@@ -836,16 +1075,16 @@
     def on_hci_link_key_notification_event(self, event):
         logger.debug(
             f'link key for {event.bd_addr}: {event.link_key.hex()}, '
-            f'type={HCI_Constant.link_key_type_name(event.key_type)}'
+            f'type={hci.HCI_Constant.link_key_type_name(event.key_type)}'
         )
         self.emit('link_key', event.bd_addr, event.link_key, event.key_type)
 
     def on_hci_simple_pairing_complete_event(self, event):
         logger.debug(
             f'simple pairing complete for {event.bd_addr}: '
-            f'status={HCI_Constant.status_name(event.status)}'
+            f'status={hci.HCI_Constant.status_name(event.status)}'
         )
-        if event.status == HCI_SUCCESS:
+        if event.status == hci.HCI_SUCCESS:
             self.emit('classic_pairing', event.bd_addr)
         else:
             self.emit('classic_pairing_failure', event.bd_addr, event.status)
@@ -865,11 +1104,11 @@
                     self.link_key_provider(event.bd_addr),
                 )
             if link_key:
-                response = HCI_Link_Key_Request_Reply_Command(
+                response = hci.HCI_Link_Key_Request_Reply_Command(
                     bd_addr=event.bd_addr, link_key=link_key
                 )
             else:
-                response = HCI_Link_Key_Request_Negative_Reply_Command(
+                response = hci.HCI_Link_Key_Request_Negative_Reply_Command(
                     bd_addr=event.bd_addr
                 )
 
@@ -926,7 +1165,7 @@
         )
 
     def on_hci_remote_name_request_complete_event(self, event):
-        if event.status != HCI_SUCCESS:
+        if event.status != hci.HCI_SUCCESS:
             self.emit('remote_name_failure', event.bd_addr, event.status)
         else:
             utf8_name = event.remote_name
@@ -942,3 +1181,15 @@
             event.bd_addr,
             event.host_supported_features,
         )
+
+    def on_hci_le_read_remote_features_complete_event(self, event):
+        if event.status != hci.HCI_SUCCESS:
+            self.emit(
+                'le_remote_features_failure', event.connection_handle, event.status
+            )
+        else:
+            self.emit(
+                'le_remote_features',
+                event.connection_handle,
+                int.from_bytes(event.le_features, 'little'),
+            )
diff --git a/bumble/l2cap.py b/bumble/l2cap.py
index 7a2f0ed..cec14b8 100644
--- a/bumble/l2cap.py
+++ b/bumble/l2cap.py
@@ -149,9 +149,10 @@
 
 L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS             = 65535
 L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU                 = 23
+L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU                 = 65535
 L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS                 = 23
 L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS                 = 65533
-L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU             = 2046
+L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU             = 2048
 L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS             = 2048
 L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS = 256
 
@@ -172,7 +173,7 @@
 @dataclasses.dataclass
 class ClassicChannelSpec:
     psm: Optional[int] = None
-    mtu: int = L2CAP_MIN_BR_EDR_MTU
+    mtu: int = L2CAP_DEFAULT_MTU
 
 
 @dataclasses.dataclass
@@ -188,8 +189,11 @@
             or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
         ):
             raise ValueError('max credits out of range')
-        if self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
-            raise ValueError('MTU too small')
+        if (
+            self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU
+            or self.mtu > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU
+        ):
+            raise ValueError('MTU out of range')
         if (
             self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
             or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
@@ -204,7 +208,7 @@
 
     @staticmethod
     def from_bytes(data: bytes) -> L2CAP_PDU:
-        # Sanity check
+        # Check parameters
         if len(data) < 4:
             raise ValueError('not enough data for L2CAP header')
 
@@ -391,6 +395,9 @@
     See Bluetooth spec @ Vol 3, Part A - 4.2 CONNECTION REQUEST
     '''
 
+    psm: int
+    source_cid: int
+
     @staticmethod
     def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]:
         psm_length = 2
@@ -432,6 +439,11 @@
     See Bluetooth spec @ Vol 3, Part A - 4.3 CONNECTION RESPONSE
     '''
 
+    source_cid: int
+    destination_cid: int
+    status: int
+    result: int
+
     CONNECTION_SUCCESSFUL = 0x0000
     CONNECTION_PENDING = 0x0001
     CONNECTION_REFUSED_PSM_NOT_SUPPORTED = 0x0002
@@ -737,6 +749,8 @@
     sink: Optional[Callable[[bytes], Any]]
     state: State
     connection: Connection
+    mtu: int
+    peer_mtu: int
 
     def __init__(
         self,
@@ -753,6 +767,7 @@
         self.signaling_cid = signaling_cid
         self.state = self.State.CLOSED
         self.mtu = mtu
+        self.peer_mtu = L2CAP_MIN_BR_EDR_MTU
         self.psm = psm
         self.source_cid = source_cid
         self.destination_cid = 0
@@ -849,7 +864,7 @@
             [
                 (
                     L2CAP_MAXIMUM_TRANSMISSION_UNIT_CONFIGURATION_OPTION_TYPE,
-                    struct.pack('<H', L2CAP_DEFAULT_MTU),
+                    struct.pack('<H', self.mtu),
                 )
             ]
         )
@@ -914,8 +929,8 @@
         options = L2CAP_Control_Frame.decode_configuration_options(request.options)
         for option in options:
             if option[0] == L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE:
-                self.mtu = struct.unpack('<H', option[1])[0]
-                logger.debug(f'MTU = {self.mtu}')
+                self.peer_mtu = struct.unpack('<H', option[1])[0]
+                logger.debug(f'peer MTU = {self.peer_mtu}')
 
         self.send_control_frame(
             L2CAP_Configure_Response(
@@ -1014,7 +1029,7 @@
         return (
             f'Channel({self.source_cid}->{self.destination_cid}, '
             f'PSM={self.psm}, '
-            f'MTU={self.mtu}, '
+            f'MTU={self.mtu}/{self.peer_mtu}, '
             f'state={self.state.name})'
         )
 
@@ -1636,12 +1651,13 @@
 
     def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
         pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
+        pdu_bytes = bytes(pdu)
         logger.debug(
             f'{color(">>> Sending L2CAP PDU", "blue")} '
             f'on connection [0x{connection.handle:04X}] (CID={cid}) '
-            f'{connection.peer_address}: {pdu_str}'
+            f'{connection.peer_address}: {len(pdu_bytes)} bytes, {pdu_str}'
         )
-        self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
+        self.host.send_l2cap_pdu(connection.handle, cid, pdu_bytes)
 
     def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
         if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
@@ -1918,7 +1934,7 @@
                     supervision_timeout=request.timeout,
                     min_ce_length=0,
                     max_ce_length=0,
-                )  # type: ignore[call-arg]
+                )
             )
         else:
             self.send_control_frame(
diff --git a/bumble/link.py b/bumble/link.py
index 85ad96e..5ef56b7 100644
--- a/bumble/link.py
+++ b/bumble/link.py
@@ -26,9 +26,13 @@
     HCI_SUCCESS,
     HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
     HCI_CONNECTION_TIMEOUT_ERROR,
+    HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
     HCI_PAGE_TIMEOUT_ERROR,
     HCI_Connection_Complete_Event,
 )
+from bumble import controller
+
+from typing import Optional, Set
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -57,6 +61,8 @@
     Link bus for controllers to communicate with each other
     '''
 
+    controllers: Set[controller.Controller]
+
     def __init__(self):
         self.controllers = set()
         self.pending_connection = None
@@ -79,7 +85,9 @@
                 return controller
         return None
 
-    def find_classic_controller(self, address):
+    def find_classic_controller(
+        self, address: Address
+    ) -> Optional[controller.Controller]:
         for controller in self.controllers:
             if controller.public_address == address:
                 return controller
@@ -188,6 +196,60 @@
         if peripheral_controller := self.find_controller(peripheral_address):
             peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
 
+    def create_cis(
+        self,
+        central_controller: controller.Controller,
+        peripheral_address: Address,
+        cig_id: int,
+        cis_id: int,
+    ) -> None:
+        logger.debug(
+            f'$$$ CIS Request {central_controller.random_address} -> {peripheral_address}'
+        )
+        if peripheral_controller := self.find_controller(peripheral_address):
+            asyncio.get_running_loop().call_soon(
+                peripheral_controller.on_link_cis_request,
+                central_controller.random_address,
+                cig_id,
+                cis_id,
+            )
+
+    def accept_cis(
+        self,
+        peripheral_controller: controller.Controller,
+        central_address: Address,
+        cig_id: int,
+        cis_id: int,
+    ) -> None:
+        logger.debug(
+            f'$$$ CIS Accept {peripheral_controller.random_address} -> {central_address}'
+        )
+        if central_controller := self.find_controller(central_address):
+            asyncio.get_running_loop().call_soon(
+                central_controller.on_link_cis_established, cig_id, cis_id
+            )
+            asyncio.get_running_loop().call_soon(
+                peripheral_controller.on_link_cis_established, cig_id, cis_id
+            )
+
+    def disconnect_cis(
+        self,
+        initiator_controller: controller.Controller,
+        peer_address: Address,
+        cig_id: int,
+        cis_id: int,
+    ) -> None:
+        logger.debug(
+            f'$$$ CIS Disconnect {initiator_controller.random_address} -> {peer_address}'
+        )
+        if peer_controller := self.find_controller(peer_address):
+            asyncio.get_running_loop().call_soon(
+                initiator_controller.on_link_cis_disconnected, cig_id, cis_id
+            )
+            asyncio.get_running_loop().call_soon(
+                peer_controller.on_link_cis_disconnected, cig_id, cis_id
+            )
+
     ############################################################
     # Classic handlers
     ############################################################
@@ -271,6 +333,52 @@
             initiator_controller.public_address, int(not (initiator_new_role))
         )
 
+    def classic_sco_connect(
+        self,
+        initiator_controller: controller.Controller,
+        responder_address: Address,
+        link_type: int,
+    ):
+        logger.debug(
+            f'[Classic] {initiator_controller.public_address} connects SCO to {responder_address}'
+        )
+        responder_controller = self.find_classic_controller(responder_address)
+        # Initiator controller should handle it.
+        assert responder_controller
+
+        responder_controller.on_classic_connection_request(
+            initiator_controller.public_address,
+            link_type,
+        )
+
+    def classic_accept_sco_connection(
+        self,
+        responder_controller: controller.Controller,
+        initiator_address: Address,
+        link_type: int,
+    ):
+        logger.debug(
+            f'[Classic] {responder_controller.public_address} accepts to connect SCO {initiator_address}'
+        )
+        initiator_controller = self.find_classic_controller(initiator_address)
+        if initiator_controller is None:
+            responder_controller.on_classic_sco_connection_complete(
+                responder_controller.public_address,
+                HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
+                link_type,
+            )
+            return
+
+        async def task():
+            initiator_controller.on_classic_sco_connection_complete(
+                responder_controller.public_address, HCI_SUCCESS, link_type
+            )
+
+        asyncio.create_task(task())
+        responder_controller.on_classic_sco_connection_complete(
+            initiator_controller.public_address, HCI_SUCCESS, link_type
+        )
+
 
 # -----------------------------------------------------------------------------
 class RemoteLink:
diff --git a/bumble/pairing.py b/bumble/pairing.py
index 877b739..5614e84 100644
--- a/bumble/pairing.py
+++ b/bumble/pairing.py
@@ -15,7 +15,9 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
 import enum
+from dataclasses import dataclass
 from typing import Optional, Tuple
 
 from .hci import (
@@ -35,7 +37,60 @@
     SMP_ID_KEY_DISTRIBUTION_FLAG,
     SMP_SIGN_KEY_DISTRIBUTION_FLAG,
     SMP_LINK_KEY_DISTRIBUTION_FLAG,
+    OobContext,
+    OobLegacyContext,
+    OobSharedData,
 )
+from .core import AdvertisingData, LeRole
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class OobData:
+    """OOB data that can be sent from one device to another."""
+
+    address: Optional[Address] = None
+    role: Optional[LeRole] = None
+    shared_data: Optional[OobSharedData] = None
+    legacy_context: Optional[OobLegacyContext] = None
+
+    @classmethod
+    def from_ad(cls, ad: AdvertisingData) -> OobData:
+        instance = cls()
+        shared_data_c: Optional[bytes] = None
+        shared_data_r: Optional[bytes] = None
+        for ad_type, ad_data in ad.ad_structures:
+            if ad_type == AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS:
+                instance.address = Address(ad_data)
+            elif ad_type == AdvertisingData.LE_ROLE:
+                instance.role = LeRole(ad_data[0])
+            elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE:
+                shared_data_c = ad_data
+            elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE:
+                shared_data_r = ad_data
+            elif ad_type == AdvertisingData.SECURITY_MANAGER_TK_VALUE:
+                instance.legacy_context = OobLegacyContext(tk=ad_data)
+        if shared_data_c and shared_data_r:
+            instance.shared_data = OobSharedData(c=shared_data_c, r=shared_data_r)
+
+        return instance
+
+    def to_ad(self) -> AdvertisingData:
+        ad_structures = []
+        if self.address is not None:
+            ad_structures.append(
+                (AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
+            )
+        if self.role is not None:
+            ad_structures.append((AdvertisingData.LE_ROLE, bytes([self.role])))
+        if self.shared_data is not None:
+            ad_structures.extend(self.shared_data.to_ad().ad_structures)
+        if self.legacy_context is not None:
+            ad_structures.append(
+                (AdvertisingData.SECURITY_MANAGER_TK_VALUE, self.legacy_context.tk)
+            )
+
+        return AdvertisingData(ad_structures)
 
 
 # -----------------------------------------------------------------------------
@@ -173,6 +228,14 @@
         PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
         RANDOM = Address.RANDOM_DEVICE_ADDRESS
 
+    @dataclass
+    class OobConfig:
+        """Config for OOB pairing."""
+
+        our_context: Optional[OobContext]
+        peer_data: Optional[OobSharedData]
+        legacy_context: Optional[OobLegacyContext]
+
     def __init__(
         self,
         sc: bool = True,
@@ -180,17 +243,20 @@
         bonding: bool = True,
         delegate: Optional[PairingDelegate] = None,
         identity_address_type: Optional[AddressType] = None,
+        oob: Optional[OobConfig] = None,
     ) -> None:
         self.sc = sc
         self.mitm = mitm
         self.bonding = bonding
         self.delegate = delegate or PairingDelegate()
         self.identity_address_type = identity_address_type
+        self.oob = oob
 
     def __str__(self) -> str:
         return (
             f'PairingConfig(sc={self.sc}, '
             f'mitm={self.mitm}, bonding={self.bonding}, '
             f'identity_address_type={self.identity_address_type}, '
-            f'delegate[{self.delegate.io_capability}])'
+            f'delegate[{self.delegate.io_capability}]), '
+            f'oob[{self.oob}])'
         )
diff --git a/bumble/pandora/config.py b/bumble/pandora/config.py
index fa448b8..e68abae 100644
--- a/bumble/pandora/config.py
+++ b/bumble/pandora/config.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
 from bumble.pairing import PairingConfig, PairingDelegate
 from dataclasses import dataclass
 from typing import Any, Dict
diff --git a/bumble/pandora/device.py b/bumble/pandora/device.py
index 9173900..4b0f7f2 100644
--- a/bumble/pandora/device.py
+++ b/bumble/pandora/device.py
@@ -14,6 +14,7 @@
 
 """Generic & dependency free Bumble (reference) device."""
 
+from __future__ import annotations
 from bumble import transport
 from bumble.core import (
     BT_GENERIC_AUDIO_SERVICE,
diff --git a/bumble/pandora/host.py b/bumble/pandora/host.py
index 9e6e4b5..e54d2d5 100644
--- a/bumble/pandora/host.py
+++ b/bumble/pandora/host.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
 import asyncio
 import bumble.device
 import grpc
@@ -33,8 +34,11 @@
     DEVICE_DEFAULT_SCAN_INTERVAL,
     DEVICE_DEFAULT_SCAN_WINDOW,
     Advertisement,
+    AdvertisingParameters,
+    AdvertisingEventProperties,
     AdvertisingType,
     Device,
+    Phy,
 )
 from bumble.gatt import Service
 from bumble.hci import (
@@ -46,9 +50,12 @@
 from google.protobuf import any_pb2  # pytype: disable=pyi-error
 from google.protobuf import empty_pb2  # pytype: disable=pyi-error
 from pandora.host_grpc_aio import HostServicer
+from pandora import host_pb2
 from pandora.host_pb2 import (
     NOT_CONNECTABLE,
     NOT_DISCOVERABLE,
+    DISCOVERABLE_LIMITED,
+    DISCOVERABLE_GENERAL,
     PRIMARY_1M,
     PRIMARY_CODED,
     SECONDARY_1M,
@@ -64,6 +71,7 @@
     ConnectResponse,
     DataTypes,
     DisconnectRequest,
+    DiscoverabilityMode,
     InquiryResponse,
     PrimaryPhy,
     ReadLocalAddressResponse,
@@ -93,6 +101,25 @@
     3: SECONDARY_CODED,
 }
 
+PRIMARY_PHY_TO_BUMBLE_PHY_MAP: Dict[PrimaryPhy, Phy] = {
+    PRIMARY_1M: Phy.LE_1M,
+    PRIMARY_CODED: Phy.LE_CODED,
+}
+
+SECONDARY_PHY_TO_BUMBLE_PHY_MAP: Dict[SecondaryPhy, Phy] = {
+    SECONDARY_NONE: Phy.LE_1M,
+    SECONDARY_1M: Phy.LE_1M,
+    SECONDARY_2M: Phy.LE_2M,
+    SECONDARY_CODED: Phy.LE_CODED,
+}
+
+OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, bumble.hci.OwnAddressType] = {
+    host_pb2.PUBLIC: bumble.hci.OwnAddressType.PUBLIC,
+    host_pb2.RANDOM: bumble.hci.OwnAddressType.RANDOM,
+    host_pb2.RESOLVABLE_OR_PUBLIC: bumble.hci.OwnAddressType.RESOLVABLE_OR_PUBLIC,
+    host_pb2.RESOLVABLE_OR_RANDOM: bumble.hci.OwnAddressType.RESOLVABLE_OR_RANDOM,
+}
+
 
 class HostService(HostServicer):
     waited_connections: Set[int]
@@ -280,14 +307,118 @@
     async def Advertise(
         self, request: AdvertiseRequest, context: grpc.ServicerContext
     ) -> AsyncGenerator[AdvertiseResponse, None]:
-        if not request.legacy:
-            raise NotImplementedError(
-                "TODO: add support for extended advertising in Bumble"
+        try:
+            if request.legacy:
+                async for rsp in self.legacy_advertise(request, context):
+                    yield rsp
+            else:
+                async for rsp in self.extended_advertise(request, context):
+                    yield rsp
+        finally:
+            pass
+
+    async def extended_advertise(
+        self, request: AdvertiseRequest, context: grpc.ServicerContext
+    ) -> AsyncGenerator[AdvertiseResponse, None]:
+        advertising_data = bytes(self.unpack_data_types(request.data))
+        scan_response_data = bytes(self.unpack_data_types(request.scan_response_data))
+        scannable = len(scan_response_data) != 0
+
+        advertising_event_properties = AdvertisingEventProperties(
+            is_connectable=request.connectable,
+            is_scannable=scannable,
+            is_directed=request.target is not None,
+            is_high_duty_cycle_directed_connectable=False,
+            is_legacy=False,
+            is_anonymous=False,
+            include_tx_power=False,
+        )
+
+        peer_address = Address.ANY
+        if request.target:
+            # Need to reverse bytes order since Bumble Address is using MSB.
+            target_bytes = bytes(reversed(request.target))
+            if request.target_variant() == "public":
+                peer_address = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
+            else:
+                peer_address = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
+
+        advertising_parameters = AdvertisingParameters(
+            advertising_event_properties=advertising_event_properties,
+            own_address_type=OWN_ADDRESS_MAP[request.own_address_type],
+            peer_address=peer_address,
+            primary_advertising_phy=PRIMARY_PHY_TO_BUMBLE_PHY_MAP[request.primary_phy],
+            secondary_advertising_phy=SECONDARY_PHY_TO_BUMBLE_PHY_MAP[
+                request.secondary_phy
+            ],
+        )
+        if advertising_interval := request.interval:
+            advertising_parameters.primary_advertising_interval_min = int(
+                advertising_interval
             )
-        if request.interval:
-            raise NotImplementedError("TODO: add support for `request.interval`")
-        if request.interval_range:
-            raise NotImplementedError("TODO: add support for `request.interval_range`")
+            advertising_parameters.primary_advertising_interval_max = int(
+                advertising_interval
+            )
+        if interval_range := request.interval_range:
+            advertising_parameters.primary_advertising_interval_max += int(
+                interval_range
+            )
+
+        advertising_set = await self.device.create_advertising_set(
+            advertising_parameters=advertising_parameters,
+            advertising_data=advertising_data,
+            scan_response_data=scan_response_data,
+        )
+
+        pending_connection: asyncio.Future[
+            bumble.device.Connection
+        ] = asyncio.get_running_loop().create_future()
+
+        if request.connectable:
+
+            def on_connection(connection: bumble.device.Connection) -> None:
+                if (
+                    connection.transport == BT_LE_TRANSPORT
+                    and connection.role == BT_PERIPHERAL_ROLE
+                ):
+                    pending_connection.set_result(connection)
+
+            self.device.on('connection', on_connection)
+
+        try:
+            # Advertise until RPC is canceled
+            while True:
+                if not advertising_set.enabled:
+                    self.log.debug('Advertise (extended)')
+                    await advertising_set.start()
+
+                if not request.connectable:
+                    await asyncio.sleep(1)
+                    continue
+
+                connection = await pending_connection
+                pending_connection = asyncio.get_running_loop().create_future()
+
+                cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
+                yield AdvertiseResponse(connection=Connection(cookie=cookie))
+
+                await asyncio.sleep(1)
+        finally:
+            try:
+                self.log.debug('Stop Advertise (extended)')
+                await advertising_set.stop()
+                await advertising_set.remove()
+            except Exception:
+                pass
+
+    async def legacy_advertise(
+        self, request: AdvertiseRequest, context: grpc.ServicerContext
+    ) -> AsyncGenerator[AdvertiseResponse, None]:
+        if advertising_interval := request.interval:
+            self.device.config.advertising_interval_min = int(advertising_interval)
+            self.device.config.advertising_interval_max = int(advertising_interval)
+        if interval_range := request.interval_range:
+            self.device.config.advertising_interval_max += int(interval_range)
         if request.primary_phy:
             raise NotImplementedError("TODO: add support for `request.primary_phy`")
         if request.secondary_phy:
@@ -355,14 +486,10 @@
             target_bytes = bytes(reversed(request.target))
             if request.target_variant() == "public":
                 target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
-                advertising_type = (
-                    AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
-                )  # FIXME: HIGH_DUTY ?
+                advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
             else:
                 target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
-                advertising_type = (
-                    AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
-                )  # FIXME: HIGH_DUTY ?
+                advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
 
         if request.connectable:
 
@@ -420,11 +547,16 @@
         self, request: ScanRequest, context: grpc.ServicerContext
     ) -> AsyncGenerator[ScanningResponse, None]:
         # TODO: modify `start_scanning` to accept floats instead of int for ms values
-        if request.phys:
-            raise NotImplementedError("TODO: add support for `request.phys`")
-
         self.log.debug('Scan')
 
+        scanning_phys = []
+        if PRIMARY_1M in request.phys:
+            scanning_phys.append(int(Phy.LE_1M))
+        if PRIMARY_CODED in request.phys:
+            scanning_phys.append(int(Phy.LE_CODED))
+        if not scanning_phys:
+            scanning_phys = [int(Phy.LE_1M), int(Phy.LE_CODED)]
+
         scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
         handler = self.device.on('advertisement', scan_queue.put_nowait)
         await self.device.start_scanning(
@@ -437,6 +569,7 @@
             scan_window=int(request.window)
             if request.window
             else DEVICE_DEFAULT_SCAN_WINDOW,
+            scanning_phys=scanning_phys,
         )
 
         try:
@@ -733,6 +866,16 @@
                 )
             )
 
+        flag_map = {
+            NOT_DISCOVERABLE: 0x00,
+            DISCOVERABLE_LIMITED: AdvertisingData.LE_LIMITED_DISCOVERABLE_MODE_FLAG,
+            DISCOVERABLE_GENERAL: AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG,
+        }
+
+        if dt.le_discoverability_mode:
+            flags = flag_map[dt.le_discoverability_mode]
+            ad_structures.append((AdvertisingData.FLAGS, flags.to_bytes(1, 'big')))
+
         return AdvertisingData(ad_structures)
 
     def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
diff --git a/bumble/pandora/security.py b/bumble/pandora/security.py
index 85365e6..b36fb18 100644
--- a/bumble/pandora/security.py
+++ b/bumble/pandora/security.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
 import asyncio
 import contextlib
 import grpc
@@ -109,7 +110,7 @@
 
         event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
         self.service.event_queue.put_nowait(event)
-        answer = await anext(self.service.event_answer)  # pytype: disable=name-error
+        answer = await anext(self.service.event_answer)  # type: ignore
         assert answer.event == event
         assert answer.answer_variant() == 'confirm' and answer.confirm is not None
         return answer.confirm
@@ -124,7 +125,7 @@
 
         event = self.add_origin(PairingEvent(numeric_comparison=number))
         self.service.event_queue.put_nowait(event)
-        answer = await anext(self.service.event_answer)  # pytype: disable=name-error
+        answer = await anext(self.service.event_answer)  # type: ignore
         assert answer.event == event
         assert answer.answer_variant() == 'confirm' and answer.confirm is not None
         return answer.confirm
@@ -139,7 +140,7 @@
 
         event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
         self.service.event_queue.put_nowait(event)
-        answer = await anext(self.service.event_answer)  # pytype: disable=name-error
+        answer = await anext(self.service.event_answer)  # type: ignore
         assert answer.event == event
         if answer.answer_variant() is None:
             return None
@@ -156,7 +157,7 @@
 
         event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
         self.service.event_queue.put_nowait(event)
-        answer = await anext(self.service.event_answer)  # pytype: disable=name-error
+        answer = await anext(self.service.event_answer)  # type: ignore
         assert answer.event == event
         if answer.answer_variant() is None:
             return None
diff --git a/bumble/pandora/utils.py b/bumble/pandora/utils.py
index c07a5bc..fba4b72 100644
--- a/bumble/pandora/utils.py
+++ b/bumble/pandora/utils.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
 import contextlib
 import functools
 import grpc
diff --git a/bumble/profiles/asha_service.py b/bumble/profiles/asha_service.py
index 412b28a..acbc47e 100644
--- a/bumble/profiles/asha_service.py
+++ b/bumble/profiles/asha_service.py
@@ -18,7 +18,7 @@
 # -----------------------------------------------------------------------------
 import struct
 import logging
-from typing import List
+from typing import List, Optional
 
 from bumble import l2cap
 from ..core import AdvertisingData
@@ -67,7 +67,7 @@
             self.emit('volume', connection, value[0])
 
         # Handler for audio control commands
-        def on_audio_control_point_write(connection: Connection, value):
+        def on_audio_control_point_write(connection: Optional[Connection], value):
             logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}')
             opcode = value[0]
             if opcode == AshaService.OPCODE_START:
diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py
new file mode 100644
index 0000000..dd57f01
--- /dev/null
+++ b/bumble/profiles/bap.py
@@ -0,0 +1,1247 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+
+from collections.abc import Sequence
+import dataclasses
+import enum
+import struct
+import functools
+import logging
+from typing import Optional, List, Union, Type, Dict, Any, Tuple, cast
+
+from bumble import colors
+from bumble import device
+from bumble import hci
+from bumble import gatt
+from bumble import gatt_client
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+
+class AudioLocation(enum.IntFlag):
+    '''Bluetooth Assigned Numbers, Section 6.12.1 - Audio Location'''
+
+    # fmt: off
+    NOT_ALLOWED             = 0x00000000
+    FRONT_LEFT              = 0x00000001
+    FRONT_RIGHT             = 0x00000002
+    FRONT_CENTER            = 0x00000004
+    LOW_FREQUENCY_EFFECTS_1 = 0x00000008
+    BACK_LEFT               = 0x00000010
+    BACK_RIGHT              = 0x00000020
+    FRONT_LEFT_OF_CENTER    = 0x00000040
+    FRONT_RIGHT_OF_CENTER   = 0x00000080
+    BACK_CENTER             = 0x00000100
+    LOW_FREQUENCY_EFFECTS_2 = 0x00000200
+    SIDE_LEFT               = 0x00000400
+    SIDE_RIGHT              = 0x00000800
+    TOP_FRONT_LEFT          = 0x00001000
+    TOP_FRONT_RIGHT         = 0x00002000
+    TOP_FRONT_CENTER        = 0x00004000
+    TOP_CENTER              = 0x00008000
+    TOP_BACK_LEFT           = 0x00010000
+    TOP_BACK_RIGHT          = 0x00020000
+    TOP_SIDE_LEFT           = 0x00040000
+    TOP_SIDE_RIGHT          = 0x00080000
+    TOP_BACK_CENTER         = 0x00100000
+    BOTTOM_FRONT_CENTER     = 0x00200000
+    BOTTOM_FRONT_LEFT       = 0x00400000
+    BOTTOM_FRONT_RIGHT      = 0x00800000
+    FRONT_LEFT_WIDE         = 0x01000000
+    FRONT_RIGHT_WIDE        = 0x02000000
+    LEFT_SURROUND           = 0x04000000
+    RIGHT_SURROUND          = 0x08000000
+
+
+class AudioInputType(enum.IntEnum):
+    '''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type'''
+
+    # fmt: off
+    UNSPECIFIED = 0x00
+    BLUETOOTH   = 0x01
+    MICROPHONE  = 0x02
+    ANALOG      = 0x03
+    DIGITAL     = 0x04
+    RADIO       = 0x05
+    STREAMING   = 0x06
+    AMBIENT     = 0x07
+
+
+class ContextType(enum.IntFlag):
+    '''Bluetooth Assigned Numbers, Section 6.12.3 - Context Type'''
+
+    # fmt: off
+    PROHIBITED       = 0x0000
+    CONVERSATIONAL   = 0x0002
+    MEDIA            = 0x0004
+    GAME             = 0x0008
+    INSTRUCTIONAL    = 0x0010
+    VOICE_ASSISTANTS = 0x0020
+    LIVE             = 0x0040
+    SOUND_EFFECTS    = 0x0080
+    NOTIFICATIONS    = 0x0100
+    RINGTONE         = 0x0200
+    ALERTS           = 0x0400
+    EMERGENCY_ALARM  = 0x0800
+
+
+class SamplingFrequency(enum.IntEnum):
+    '''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
+
+    # fmt: off
+    FREQ_8000    = 0x01
+    FREQ_11025   = 0x02
+    FREQ_16000   = 0x03
+    FREQ_22050   = 0x04
+    FREQ_24000   = 0x05
+    FREQ_32000   = 0x06
+    FREQ_44100   = 0x07
+    FREQ_48000   = 0x08
+    FREQ_88200   = 0x09
+    FREQ_96000   = 0x0A
+    FREQ_176400  = 0x0B
+    FREQ_192000  = 0x0C
+    FREQ_384000  = 0x0D
+    # fmt: on
+
+    @classmethod
+    def from_hz(cls, frequency: int) -> SamplingFrequency:
+        return {
+            8000: SamplingFrequency.FREQ_8000,
+            11025: SamplingFrequency.FREQ_11025,
+            16000: SamplingFrequency.FREQ_16000,
+            22050: SamplingFrequency.FREQ_22050,
+            24000: SamplingFrequency.FREQ_24000,
+            32000: SamplingFrequency.FREQ_32000,
+            44100: SamplingFrequency.FREQ_44100,
+            48000: SamplingFrequency.FREQ_48000,
+            88200: SamplingFrequency.FREQ_88200,
+            96000: SamplingFrequency.FREQ_96000,
+            176400: SamplingFrequency.FREQ_176400,
+            192000: SamplingFrequency.FREQ_192000,
+            384000: SamplingFrequency.FREQ_384000,
+        }[frequency]
+
+    @property
+    def hz(self) -> int:
+        return {
+            SamplingFrequency.FREQ_8000: 8000,
+            SamplingFrequency.FREQ_11025: 11025,
+            SamplingFrequency.FREQ_16000: 16000,
+            SamplingFrequency.FREQ_22050: 22050,
+            SamplingFrequency.FREQ_24000: 24000,
+            SamplingFrequency.FREQ_32000: 32000,
+            SamplingFrequency.FREQ_44100: 44100,
+            SamplingFrequency.FREQ_48000: 48000,
+            SamplingFrequency.FREQ_88200: 88200,
+            SamplingFrequency.FREQ_96000: 96000,
+            SamplingFrequency.FREQ_176400: 176400,
+            SamplingFrequency.FREQ_192000: 192000,
+            SamplingFrequency.FREQ_384000: 384000,
+        }[self]
+
+
+class SupportedSamplingFrequency(enum.IntFlag):
+    '''Bluetooth Assigned Numbers, Section 6.12.4.1 - Sample Frequency'''
+
+    # fmt: off
+    FREQ_8000    = 1 << (SamplingFrequency.FREQ_8000 - 1)
+    FREQ_11025   = 1 << (SamplingFrequency.FREQ_11025 - 1)
+    FREQ_16000   = 1 << (SamplingFrequency.FREQ_16000 - 1)
+    FREQ_22050   = 1 << (SamplingFrequency.FREQ_22050 - 1)
+    FREQ_24000   = 1 << (SamplingFrequency.FREQ_24000 - 1)
+    FREQ_32000   = 1 << (SamplingFrequency.FREQ_32000 - 1)
+    FREQ_44100   = 1 << (SamplingFrequency.FREQ_44100 - 1)
+    FREQ_48000   = 1 << (SamplingFrequency.FREQ_48000 - 1)
+    FREQ_88200   = 1 << (SamplingFrequency.FREQ_88200 - 1)
+    FREQ_96000   = 1 << (SamplingFrequency.FREQ_96000 - 1)
+    FREQ_176400  = 1 << (SamplingFrequency.FREQ_176400 - 1)
+    FREQ_192000  = 1 << (SamplingFrequency.FREQ_192000 - 1)
+    FREQ_384000  = 1 << (SamplingFrequency.FREQ_384000 - 1)
+    # fmt: on
+
+    @classmethod
+    def from_hz(cls, frequencies: Sequence[int]) -> SupportedSamplingFrequency:
+        MAPPING = {
+            8000: SupportedSamplingFrequency.FREQ_8000,
+            11025: SupportedSamplingFrequency.FREQ_11025,
+            16000: SupportedSamplingFrequency.FREQ_16000,
+            22050: SupportedSamplingFrequency.FREQ_22050,
+            24000: SupportedSamplingFrequency.FREQ_24000,
+            32000: SupportedSamplingFrequency.FREQ_32000,
+            44100: SupportedSamplingFrequency.FREQ_44100,
+            48000: SupportedSamplingFrequency.FREQ_48000,
+            88200: SupportedSamplingFrequency.FREQ_88200,
+            96000: SupportedSamplingFrequency.FREQ_96000,
+            176400: SupportedSamplingFrequency.FREQ_176400,
+            192000: SupportedSamplingFrequency.FREQ_192000,
+            384000: SupportedSamplingFrequency.FREQ_384000,
+        }
+
+        return functools.reduce(
+            lambda x, y: x | MAPPING[y],
+            frequencies,
+            cls(0),
+        )
+
+
+class FrameDuration(enum.IntEnum):
+    '''Bluetooth Assigned Numbers, Section 6.12.5.2 - Frame Duration'''
+
+    # fmt: off
+    DURATION_7500_US  = 0x00
+    DURATION_10000_US = 0x01
+
+
+class SupportedFrameDuration(enum.IntFlag):
+    '''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration'''
+
+    # fmt: off
+    DURATION_7500_US_SUPPORTED  = 0b0001
+    DURATION_10000_US_SUPPORTED = 0b0010
+    DURATION_7500_US_PREFERRED  = 0b0001
+    DURATION_10000_US_PREFERRED = 0b0010
+
+
+# -----------------------------------------------------------------------------
+# ASE Operations
+# -----------------------------------------------------------------------------
+
+
+class ASE_Operation:
+    '''
+    See Audio Stream Control Service - 5 ASE Control operations.
+    '''
+
+    classes: Dict[int, Type[ASE_Operation]] = {}
+    op_code: int
+    name: str
+    fields: Optional[Sequence[Any]] = None
+    ase_id: List[int]
+
+    class Opcode(enum.IntEnum):
+        # fmt: off
+        CONFIG_CODEC         = 0x01
+        CONFIG_QOS           = 0x02
+        ENABLE               = 0x03
+        RECEIVER_START_READY = 0x04
+        DISABLE              = 0x05
+        RECEIVER_STOP_READY  = 0x06
+        UPDATE_METADATA      = 0x07
+        RELEASE              = 0x08
+
+    @staticmethod
+    def from_bytes(pdu: bytes) -> ASE_Operation:
+        op_code = pdu[0]
+
+        cls = ASE_Operation.classes.get(op_code)
+        if cls is None:
+            instance = ASE_Operation(pdu)
+            instance.name = ASE_Operation.Opcode(op_code).name
+            instance.op_code = op_code
+            return instance
+        self = cls.__new__(cls)
+        ASE_Operation.__init__(self, pdu)
+        if self.fields is not None:
+            self.init_from_bytes(pdu, 1)
+        return self
+
+    @staticmethod
+    def subclass(fields):
+        def inner(cls: Type[ASE_Operation]):
+            try:
+                operation = ASE_Operation.Opcode[cls.__name__[4:].upper()]
+                cls.name = operation.name
+                cls.op_code = operation
+            except:
+                raise KeyError(f'PDU name {cls.name} not found in Ase_Operation.Opcode')
+            cls.fields = fields
+
+            # Register a factory for this class
+            ASE_Operation.classes[cls.op_code] = cls
+
+            return cls
+
+        return inner
+
+    def __init__(self, pdu: Optional[bytes] = None, **kwargs) -> None:
+        if self.fields is not None and kwargs:
+            hci.HCI_Object.init_from_fields(self, self.fields, kwargs)
+        if pdu is None:
+            pdu = bytes([self.op_code]) + hci.HCI_Object.dict_to_bytes(
+                kwargs, self.fields
+            )
+        self.pdu = pdu
+
+    def init_from_bytes(self, pdu: bytes, offset: int):
+        return hci.HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
+
+    def __bytes__(self) -> bytes:
+        return self.pdu
+
+    def __str__(self) -> str:
+        result = f'{colors.color(self.name, "yellow")} '
+        if fields := getattr(self, 'fields', None):
+            result += ':\n' + hci.HCI_Object.format_fields(self.__dict__, fields, '  ')
+        else:
+            if len(self.pdu) > 1:
+                result += f': {self.pdu.hex()}'
+        return result
+
+
+@ASE_Operation.subclass(
+    [
+        [
+            ('ase_id', 1),
+            ('target_latency', 1),
+            ('target_phy', 1),
+            ('codec_id', hci.CodingFormat.parse_from_bytes),
+            ('codec_specific_configuration', 'v'),
+        ],
+    ]
+)
+class ASE_Config_Codec(ASE_Operation):
+    '''
+    See Audio Stream Control Service 5.1 - Config Codec Operation
+    '''
+
+    target_latency: List[int]
+    target_phy: List[int]
+    codec_id: List[hci.CodingFormat]
+    codec_specific_configuration: List[bytes]
+
+
+@ASE_Operation.subclass(
+    [
+        [
+            ('ase_id', 1),
+            ('cig_id', 1),
+            ('cis_id', 1),
+            ('sdu_interval', 3),
+            ('framing', 1),
+            ('phy', 1),
+            ('max_sdu', 2),
+            ('retransmission_number', 1),
+            ('max_transport_latency', 2),
+            ('presentation_delay', 3),
+        ],
+    ]
+)
+class ASE_Config_QOS(ASE_Operation):
+    '''
+    See Audio Stream Control Service 5.2 - Config Qos Operation
+    '''
+
+    cig_id: List[int]
+    cis_id: List[int]
+    sdu_interval: List[int]
+    framing: List[int]
+    phy: List[int]
+    max_sdu: List[int]
+    retransmission_number: List[int]
+    max_transport_latency: List[int]
+    presentation_delay: List[int]
+
+
+@ASE_Operation.subclass([[('ase_id', 1), ('metadata', 'v')]])
+class ASE_Enable(ASE_Operation):
+    '''
+    See Audio Stream Control Service 5.3 - Enable Operation
+    '''
+
+    metadata: bytes
+
+
+@ASE_Operation.subclass([[('ase_id', 1)]])
+class ASE_Receiver_Start_Ready(ASE_Operation):
+    '''
+    See Audio Stream Control Service 5.4 - Receiver Start Ready Operation
+    '''
+
+
+@ASE_Operation.subclass([[('ase_id', 1)]])
+class ASE_Disable(ASE_Operation):
+    '''
+    See Audio Stream Control Service 5.5 - Disable Operation
+    '''
+
+
+@ASE_Operation.subclass([[('ase_id', 1)]])
+class ASE_Receiver_Stop_Ready(ASE_Operation):
+    '''
+    See Audio Stream Control Service 5.6 - Receiver Stop Ready Operation
+    '''
+
+
+@ASE_Operation.subclass([[('ase_id', 1), ('metadata', 'v')]])
+class ASE_Update_Metadata(ASE_Operation):
+    '''
+    See Audio Stream Control Service 5.7 - Update Metadata Operation
+    '''
+
+    metadata: List[bytes]
+
+
+@ASE_Operation.subclass([[('ase_id', 1)]])
+class ASE_Release(ASE_Operation):
+    '''
+    See Audio Stream Control Service 5.8 - Release Operation
+    '''
+
+
+class AseResponseCode(enum.IntEnum):
+    # fmt: off
+    SUCCESS                                     = 0x00
+    UNSUPPORTED_OPCODE                          = 0x01
+    INVALID_LENGTH                              = 0x02
+    INVALID_ASE_ID                              = 0x03
+    INVALID_ASE_STATE_MACHINE_TRANSITION        = 0x04
+    INVALID_ASE_DIRECTION                       = 0x05
+    UNSUPPORTED_AUDIO_CAPABILITIES              = 0x06
+    UNSUPPORTED_CONFIGURATION_PARAMETER_VALUE   = 0x07
+    REJECTED_CONFIGURATION_PARAMETER_VALUE      = 0x08
+    INVALID_CONFIGURATION_PARAMETER_VALUE       = 0x09
+    UNSUPPORTED_METADATA                        = 0x0A
+    REJECTED_METADATA                           = 0x0B
+    INVALID_METADATA                            = 0x0C
+    INSUFFICIENT_RESOURCES                      = 0x0D
+    UNSPECIFIED_ERROR                           = 0x0E
+
+
+class AseReasonCode(enum.IntEnum):
+    # fmt: off
+    NONE                            = 0x00
+    CODEC_ID                        = 0x01
+    CODEC_SPECIFIC_CONFIGURATION    = 0x02
+    SDU_INTERVAL                    = 0x03
+    FRAMING                         = 0x04
+    PHY                             = 0x05
+    MAXIMUM_SDU_SIZE                = 0x06
+    RETRANSMISSION_NUMBER           = 0x07
+    MAX_TRANSPORT_LATENCY           = 0x08
+    PRESENTATION_DELAY              = 0x09
+    INVALID_ASE_CIS_MAPPING         = 0x0A
+
+
+class AudioRole(enum.IntEnum):
+    SINK = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST
+    SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
+
+
+# -----------------------------------------------------------------------------
+# Utils
+# -----------------------------------------------------------------------------
+
+
+def bits_to_channel_counts(data: int) -> List[int]:
+    pos = 0
+    counts = []
+    while data != 0:
+        # Bit 0 = count 1
+        # Bit 1 = count 2, and so on
+        pos += 1
+        if data & 1:
+            counts.append(pos)
+        data >>= 1
+    return counts
+
+
+def channel_counts_to_bits(counts: Sequence[int]) -> int:
+    return sum(set([1 << (count - 1) for count in counts]))
+
+
+# -----------------------------------------------------------------------------
+# Structures
+# -----------------------------------------------------------------------------
+
+
+@dataclasses.dataclass
+class CodecSpecificCapabilities:
+    '''See:
+    * Bluetooth Assigned Numbers, 6.12.4 - Codec Specific Capabilities LTV Structures
+    * Basic Audio Profile, 4.3.1 - Codec_Specific_Capabilities LTV requirements
+    '''
+
+    class Type(enum.IntEnum):
+        # fmt: off
+        SAMPLING_FREQUENCY   = 0x01
+        FRAME_DURATION       = 0x02
+        AUDIO_CHANNEL_COUNT  = 0x03
+        OCTETS_PER_FRAME     = 0x04
+        CODEC_FRAMES_PER_SDU = 0x05
+
+    supported_sampling_frequencies: SupportedSamplingFrequency
+    supported_frame_durations: SupportedFrameDuration
+    supported_audio_channel_counts: Sequence[int]
+    min_octets_per_codec_frame: int
+    max_octets_per_codec_frame: int
+    supported_max_codec_frames_per_sdu: int
+
+    @classmethod
+    def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities:
+        offset = 0
+        # Allowed default values.
+        supported_audio_channel_counts = [1]
+        supported_max_codec_frames_per_sdu = 1
+        while offset < len(data):
+            length, type = struct.unpack_from('BB', data, offset)
+            offset += 2
+            value = int.from_bytes(data[offset : offset + length - 1], 'little')
+            offset += length - 1
+
+            if type == CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY:
+                supported_sampling_frequencies = SupportedSamplingFrequency(value)
+            elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
+                supported_frame_durations = SupportedFrameDuration(value)
+            elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
+                supported_audio_channel_counts = bits_to_channel_counts(value)
+            elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
+                min_octets_per_sample = value & 0xFFFF
+                max_octets_per_sample = value >> 16
+            elif type == CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU:
+                supported_max_codec_frames_per_sdu = value
+
+        # It is expected here that if some fields are missing, an error should be raised.
+        return CodecSpecificCapabilities(
+            supported_sampling_frequencies=supported_sampling_frequencies,
+            supported_frame_durations=supported_frame_durations,
+            supported_audio_channel_counts=supported_audio_channel_counts,
+            min_octets_per_codec_frame=min_octets_per_sample,
+            max_octets_per_codec_frame=max_octets_per_sample,
+            supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu,
+        )
+
+    def __bytes__(self) -> bytes:
+        return struct.pack(
+            '<BBHBBBBBBBBHHBBB',
+            3,
+            CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY,
+            self.supported_sampling_frequencies,
+            2,
+            CodecSpecificCapabilities.Type.FRAME_DURATION,
+            self.supported_frame_durations,
+            2,
+            CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT,
+            channel_counts_to_bits(self.supported_audio_channel_counts),
+            5,
+            CodecSpecificCapabilities.Type.OCTETS_PER_FRAME,
+            self.min_octets_per_codec_frame,
+            self.max_octets_per_codec_frame,
+            2,
+            CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU,
+            self.supported_max_codec_frames_per_sdu,
+        )
+
+
+@dataclasses.dataclass
+class CodecSpecificConfiguration:
+    '''See:
+    * Bluetooth Assigned Numbers, 6.12.5 - Codec Specific Configuration LTV Structures
+    * Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements
+    '''
+
+    class Type(enum.IntEnum):
+        # fmt: off
+        SAMPLING_FREQUENCY       = 0x01
+        FRAME_DURATION           = 0x02
+        AUDIO_CHANNEL_ALLOCATION = 0x03
+        OCTETS_PER_FRAME         = 0x04
+        CODEC_FRAMES_PER_SDU     = 0x05
+
+    sampling_frequency: SamplingFrequency
+    frame_duration: FrameDuration
+    audio_channel_allocation: AudioLocation
+    octets_per_codec_frame: int
+    codec_frames_per_sdu: int
+
+    @classmethod
+    def from_bytes(cls, data: bytes) -> CodecSpecificConfiguration:
+        offset = 0
+        # Allowed default values.
+        audio_channel_allocation = AudioLocation.NOT_ALLOWED
+        codec_frames_per_sdu = 1
+        while offset < len(data):
+            length, type = struct.unpack_from('BB', data, offset)
+            offset += 2
+            value = int.from_bytes(data[offset : offset + length - 1], 'little')
+            offset += length - 1
+
+            if type == CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY:
+                sampling_frequency = SamplingFrequency(value)
+            elif type == CodecSpecificConfiguration.Type.FRAME_DURATION:
+                frame_duration = FrameDuration(value)
+            elif type == CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION:
+                audio_channel_allocation = AudioLocation(value)
+            elif type == CodecSpecificConfiguration.Type.OCTETS_PER_FRAME:
+                octets_per_codec_frame = value
+            elif type == CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU:
+                codec_frames_per_sdu = value
+
+        # It is expected here that if some fields are missing, an error should be raised.
+        return CodecSpecificConfiguration(
+            sampling_frequency=sampling_frequency,
+            frame_duration=frame_duration,
+            audio_channel_allocation=audio_channel_allocation,
+            octets_per_codec_frame=octets_per_codec_frame,
+            codec_frames_per_sdu=codec_frames_per_sdu,
+        )
+
+    def __bytes__(self) -> bytes:
+        return struct.pack(
+            '<BBBBBBBBIBBHBBB',
+            2,
+            CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY,
+            self.sampling_frequency,
+            2,
+            CodecSpecificConfiguration.Type.FRAME_DURATION,
+            self.frame_duration,
+            5,
+            CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION,
+            self.audio_channel_allocation,
+            3,
+            CodecSpecificConfiguration.Type.OCTETS_PER_FRAME,
+            self.octets_per_codec_frame,
+            2,
+            CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU,
+            self.codec_frames_per_sdu,
+        )
+
+
+@dataclasses.dataclass
+class PacRecord:
+    coding_format: hci.CodingFormat
+    codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
+    # TODO: Parse Metadata
+    metadata: bytes = b''
+
+    @classmethod
+    def from_bytes(cls, data: bytes) -> PacRecord:
+        offset, coding_format = hci.CodingFormat.parse_from_bytes(data, 0)
+        codec_specific_capabilities_size = data[offset]
+
+        offset += 1
+        codec_specific_capabilities_bytes = data[
+            offset : offset + codec_specific_capabilities_size
+        ]
+        offset += codec_specific_capabilities_size
+        metadata_size = data[offset]
+        metadata = data[offset : offset + metadata_size]
+
+        codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
+        if coding_format.codec_id == hci.CodecID.VENDOR_SPECIFIC:
+            codec_specific_capabilities = codec_specific_capabilities_bytes
+        else:
+            codec_specific_capabilities = CodecSpecificCapabilities.from_bytes(
+                codec_specific_capabilities_bytes
+            )
+
+        return PacRecord(
+            coding_format=coding_format,
+            codec_specific_capabilities=codec_specific_capabilities,
+            metadata=metadata,
+        )
+
+    def __bytes__(self) -> bytes:
+        capabilities_bytes = bytes(self.codec_specific_capabilities)
+        return (
+            bytes(self.coding_format)
+            + bytes([len(capabilities_bytes)])
+            + capabilities_bytes
+            + bytes([len(self.metadata)])
+            + self.metadata
+        )
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+class PublishedAudioCapabilitiesService(gatt.TemplateService):
+    UUID = gatt.GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE
+
+    sink_pac: Optional[gatt.Characteristic]
+    sink_audio_locations: Optional[gatt.Characteristic]
+    source_pac: Optional[gatt.Characteristic]
+    source_audio_locations: Optional[gatt.Characteristic]
+    available_audio_contexts: gatt.Characteristic
+    supported_audio_contexts: gatt.Characteristic
+
+    def __init__(
+        self,
+        supported_source_context: ContextType,
+        supported_sink_context: ContextType,
+        available_source_context: ContextType,
+        available_sink_context: ContextType,
+        sink_pac: Sequence[PacRecord] = [],
+        sink_audio_locations: Optional[AudioLocation] = None,
+        source_pac: Sequence[PacRecord] = [],
+        source_audio_locations: Optional[AudioLocation] = None,
+    ) -> None:
+        characteristics = []
+
+        self.supported_audio_contexts = gatt.Characteristic(
+            uuid=gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ,
+            permissions=gatt.Characteristic.Permissions.READABLE,
+            value=struct.pack('<HH', supported_sink_context, supported_source_context),
+        )
+        characteristics.append(self.supported_audio_contexts)
+
+        self.available_audio_contexts = gatt.Characteristic(
+            uuid=gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READABLE,
+            value=struct.pack('<HH', available_sink_context, available_source_context),
+        )
+        characteristics.append(self.available_audio_contexts)
+
+        if sink_pac:
+            self.sink_pac = gatt.Characteristic(
+                uuid=gatt.GATT_SINK_PAC_CHARACTERISTIC,
+                properties=gatt.Characteristic.Properties.READ,
+                permissions=gatt.Characteristic.Permissions.READABLE,
+                value=bytes([len(sink_pac)]) + b''.join(map(bytes, sink_pac)),
+            )
+            characteristics.append(self.sink_pac)
+
+        if sink_audio_locations is not None:
+            self.sink_audio_locations = gatt.Characteristic(
+                uuid=gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC,
+                properties=gatt.Characteristic.Properties.READ,
+                permissions=gatt.Characteristic.Permissions.READABLE,
+                value=struct.pack('<I', sink_audio_locations),
+            )
+            characteristics.append(self.sink_audio_locations)
+
+        if source_pac:
+            self.source_pac = gatt.Characteristic(
+                uuid=gatt.GATT_SOURCE_PAC_CHARACTERISTIC,
+                properties=gatt.Characteristic.Properties.READ,
+                permissions=gatt.Characteristic.Permissions.READABLE,
+                value=bytes([len(source_pac)]) + b''.join(map(bytes, source_pac)),
+            )
+            characteristics.append(self.source_pac)
+
+        if source_audio_locations is not None:
+            self.source_audio_locations = gatt.Characteristic(
+                uuid=gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC,
+                properties=gatt.Characteristic.Properties.READ,
+                permissions=gatt.Characteristic.Permissions.READABLE,
+                value=struct.pack('<I', source_audio_locations),
+            )
+            characteristics.append(self.source_audio_locations)
+
+        super().__init__(characteristics)
+
+
+class AseStateMachine(gatt.Characteristic):
+    class State(enum.IntEnum):
+        # fmt: off
+        IDLE             = 0x00
+        CODEC_CONFIGURED = 0x01
+        QOS_CONFIGURED   = 0x02
+        ENABLING         = 0x03
+        STREAMING        = 0x04
+        DISABLING        = 0x05
+        RELEASING        = 0x06
+
+    cis_link: Optional[device.CisLink] = None
+
+    # Additional parameters in CODEC_CONFIGURED State
+    preferred_framing = 0  # Unframed PDU supported
+    preferred_phy = 0
+    preferred_retransmission_number = 13
+    preferred_max_transport_latency = 100
+    supported_presentation_delay_min = 0
+    supported_presentation_delay_max = 0
+    preferred_presentation_delay_min = 0
+    preferred_presentation_delay_max = 0
+    codec_id = hci.CodingFormat(hci.CodecID.LC3)
+    codec_specific_configuration: Union[CodecSpecificConfiguration, bytes] = b''
+
+    # Additional parameters in QOS_CONFIGURED State
+    cig_id = 0
+    cis_id = 0
+    sdu_interval = 0
+    framing = 0
+    phy = 0
+    max_sdu = 0
+    retransmission_number = 0
+    max_transport_latency = 0
+    presentation_delay = 0
+
+    # Additional parameters in ENABLING, STREAMING, DISABLING State
+    # TODO: Parse this
+    metadata = b''
+
+    def __init__(
+        self,
+        role: AudioRole,
+        ase_id: int,
+        service: AudioStreamControlService,
+    ) -> None:
+        self.service = service
+        self.ase_id = ase_id
+        self._state = AseStateMachine.State.IDLE
+        self.role = role
+
+        uuid = (
+            gatt.GATT_SINK_ASE_CHARACTERISTIC
+            if role == AudioRole.SINK
+            else gatt.GATT_SOURCE_ASE_CHARACTERISTIC
+        )
+        super().__init__(
+            uuid=uuid,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READABLE,
+            value=gatt.CharacteristicValue(read=self.on_read),
+        )
+
+        self.service.device.on('cis_request', self.on_cis_request)
+        self.service.device.on('cis_establishment', self.on_cis_establishment)
+
+    def on_cis_request(
+        self,
+        acl_connection: device.Connection,
+        cis_handle: int,
+        cig_id: int,
+        cis_id: int,
+    ) -> None:
+        if cis_id == self.cis_id and self.state == self.State.ENABLING:
+            acl_connection.abort_on(
+                'flush', self.service.device.accept_cis_request(cis_handle)
+            )
+
+    def on_cis_establishment(self, cis_link: device.CisLink) -> None:
+        if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING:
+            self.state = self.State.STREAMING
+            self.cis_link = cis_link
+
+            async def post_cis_established():
+                await self.service.device.send_command(
+                    hci.HCI_LE_Setup_ISO_Data_Path_Command(
+                        connection_handle=cis_link.handle,
+                        data_path_direction=self.role,
+                        data_path_id=0x00,  # Fixed HCI
+                        codec_id=hci.CodingFormat(hci.CodecID.TRANSPARENT),
+                        controller_delay=0,
+                        codec_configuration=b'',
+                    )
+                )
+                await self.service.device.notify_subscribers(self, self.value)
+
+            cis_link.acl_connection.abort_on('flush', post_cis_established())
+
+    def on_config_codec(
+        self,
+        target_latency: int,
+        target_phy: int,
+        codec_id: hci.CodingFormat,
+        codec_specific_configuration: bytes,
+    ) -> Tuple[AseResponseCode, AseReasonCode]:
+        if self.state not in (
+            self.State.IDLE,
+            self.State.CODEC_CONFIGURED,
+            self.State.QOS_CONFIGURED,
+        ):
+            return (
+                AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+                AseReasonCode.NONE,
+            )
+
+        self.max_transport_latency = target_latency
+        self.phy = target_phy
+        self.codec_id = codec_id
+        if codec_id.codec_id == hci.CodecID.VENDOR_SPECIFIC:
+            self.codec_specific_configuration = codec_specific_configuration
+        else:
+            self.codec_specific_configuration = CodecSpecificConfiguration.from_bytes(
+                codec_specific_configuration
+            )
+
+        self.state = self.State.CODEC_CONFIGURED
+
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    def on_config_qos(
+        self,
+        cig_id: int,
+        cis_id: int,
+        sdu_interval: int,
+        framing: int,
+        phy: int,
+        max_sdu: int,
+        retransmission_number: int,
+        max_transport_latency: int,
+        presentation_delay: int,
+    ) -> Tuple[AseResponseCode, AseReasonCode]:
+        if self.state not in (
+            AseStateMachine.State.CODEC_CONFIGURED,
+            AseStateMachine.State.QOS_CONFIGURED,
+        ):
+            return (
+                AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+                AseReasonCode.NONE,
+            )
+
+        self.cig_id = cig_id
+        self.cis_id = cis_id
+        self.sdu_interval = sdu_interval
+        self.framing = framing
+        self.phy = phy
+        self.max_sdu = max_sdu
+        self.retransmission_number = retransmission_number
+        self.max_transport_latency = max_transport_latency
+        self.presentation_delay = presentation_delay
+
+        self.state = self.State.QOS_CONFIGURED
+
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    def on_enable(self, metadata: bytes) -> Tuple[AseResponseCode, AseReasonCode]:
+        if self.state != AseStateMachine.State.QOS_CONFIGURED:
+            return (
+                AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+                AseReasonCode.NONE,
+            )
+
+        self.metadata = metadata
+        self.state = self.State.ENABLING
+
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    def on_receiver_start_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
+        if self.state != AseStateMachine.State.ENABLING:
+            return (
+                AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+                AseReasonCode.NONE,
+            )
+        self.state = self.State.STREAMING
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    def on_disable(self) -> Tuple[AseResponseCode, AseReasonCode]:
+        if self.state not in (
+            AseStateMachine.State.ENABLING,
+            AseStateMachine.State.STREAMING,
+        ):
+            return (
+                AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+                AseReasonCode.NONE,
+            )
+        self.state = self.State.DISABLING
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
+        if self.state != AseStateMachine.State.DISABLING:
+            return (
+                AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+                AseReasonCode.NONE,
+            )
+        self.state = self.State.QOS_CONFIGURED
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    def on_update_metadata(
+        self, metadata: bytes
+    ) -> Tuple[AseResponseCode, AseReasonCode]:
+        if self.state not in (
+            AseStateMachine.State.ENABLING,
+            AseStateMachine.State.STREAMING,
+        ):
+            return (
+                AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+                AseReasonCode.NONE,
+            )
+        self.metadata = metadata
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    def on_release(self) -> Tuple[AseResponseCode, AseReasonCode]:
+        if self.state == AseStateMachine.State.IDLE:
+            return (
+                AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+                AseReasonCode.NONE,
+            )
+        self.state = self.State.RELEASING
+
+        async def remove_cis_async():
+            await self.service.device.send_command(
+                hci.HCI_LE_Remove_ISO_Data_Path_Command(
+                    connection_handle=self.cis_link.handle,
+                    data_path_direction=self.role,
+                )
+            )
+            self.state = self.State.IDLE
+            await self.service.device.notify_subscribers(self, self.value)
+
+        self.service.device.abort_on('flush', remove_cis_async())
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    @property
+    def state(self) -> State:
+        return self._state
+
+    @state.setter
+    def state(self, new_state: State) -> None:
+        logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}')
+        self._state = new_state
+
+    @property
+    def value(self):
+        '''Returns ASE_ID, ASE_STATE, and ASE Additional Parameters.'''
+
+        if self.state == self.State.CODEC_CONFIGURED:
+            codec_specific_configuration_bytes = bytes(
+                self.codec_specific_configuration
+            )
+            additional_parameters = (
+                struct.pack(
+                    '<BBBH',
+                    self.preferred_framing,
+                    self.preferred_phy,
+                    self.preferred_retransmission_number,
+                    self.preferred_max_transport_latency,
+                )
+                + self.supported_presentation_delay_min.to_bytes(3, 'little')
+                + self.supported_presentation_delay_max.to_bytes(3, 'little')
+                + self.preferred_presentation_delay_min.to_bytes(3, 'little')
+                + self.preferred_presentation_delay_max.to_bytes(3, 'little')
+                + bytes(self.codec_id)
+                + bytes([len(codec_specific_configuration_bytes)])
+                + codec_specific_configuration_bytes
+            )
+        elif self.state == self.State.QOS_CONFIGURED:
+            additional_parameters = (
+                bytes([self.cig_id, self.cis_id])
+                + self.sdu_interval.to_bytes(3, 'little')
+                + struct.pack(
+                    '<BBHBH',
+                    self.framing,
+                    self.phy,
+                    self.max_sdu,
+                    self.retransmission_number,
+                    self.max_transport_latency,
+                )
+                + self.presentation_delay.to_bytes(3, 'little')
+            )
+        elif self.state in (
+            self.State.ENABLING,
+            self.State.STREAMING,
+            self.State.DISABLING,
+        ):
+            additional_parameters = (
+                bytes([self.cig_id, self.cis_id, len(self.metadata)]) + self.metadata
+            )
+        else:
+            additional_parameters = b''
+
+        return bytes([self.ase_id, self.state]) + additional_parameters
+
+    @value.setter
+    def value(self, _new_value):
+        # Readonly. Do nothing in the setter.
+        pass
+
+    def on_read(self, _: Optional[device.Connection]) -> bytes:
+        return self.value
+
+    def __str__(self) -> str:
+        return (
+            f'AseStateMachine(id={self.ase_id}, role={self.role.name} '
+            f'state={self._state.name})'
+        )
+
+
+class AudioStreamControlService(gatt.TemplateService):
+    UUID = gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE
+
+    ase_state_machines: Dict[int, AseStateMachine]
+    ase_control_point: gatt.Characteristic
+
+    def __init__(
+        self,
+        device: device.Device,
+        source_ase_id: Sequence[int] = [],
+        sink_ase_id: Sequence[int] = [],
+    ) -> None:
+        self.device = device
+        self.ase_state_machines = {
+            **{
+                id: AseStateMachine(role=AudioRole.SINK, ase_id=id, service=self)
+                for id in sink_ase_id
+            },
+            **{
+                id: AseStateMachine(role=AudioRole.SOURCE, ase_id=id, service=self)
+                for id in source_ase_id
+            },
+        }  # ASE state machines, by ASE ID
+
+        self.ase_control_point = gatt.Characteristic(
+            uuid=gatt.GATT_ASE_CONTROL_POINT_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.WRITE
+            | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.WRITEABLE,
+            value=gatt.CharacteristicValue(write=self.on_write_ase_control_point),
+        )
+
+        super().__init__([self.ase_control_point, *self.ase_state_machines.values()])
+
+    def on_operation(self, opcode: ASE_Operation.Opcode, ase_id: int, args):
+        if ase := self.ase_state_machines.get(ase_id):
+            handler = getattr(ase, 'on_' + opcode.name.lower())
+            return (ase_id, *handler(*args))
+        else:
+            return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE)
+
+    def on_write_ase_control_point(self, connection, data):
+        operation = ASE_Operation.from_bytes(data)
+        responses = []
+        logger.debug(f'*** ASCS Write {operation} ***')
+
+        if operation.op_code == ASE_Operation.Opcode.CONFIG_CODEC:
+            for ase_id, *args in zip(
+                operation.ase_id,
+                operation.target_latency,
+                operation.target_phy,
+                operation.codec_id,
+                operation.codec_specific_configuration,
+            ):
+                responses.append(self.on_operation(operation.op_code, ase_id, args))
+        elif operation.op_code == ASE_Operation.Opcode.CONFIG_QOS:
+            for ase_id, *args in zip(
+                operation.ase_id,
+                operation.cig_id,
+                operation.cis_id,
+                operation.sdu_interval,
+                operation.framing,
+                operation.phy,
+                operation.max_sdu,
+                operation.retransmission_number,
+                operation.max_transport_latency,
+                operation.presentation_delay,
+            ):
+                responses.append(self.on_operation(operation.op_code, ase_id, args))
+        elif operation.op_code in (
+            ASE_Operation.Opcode.ENABLE,
+            ASE_Operation.Opcode.UPDATE_METADATA,
+        ):
+            for ase_id, *args in zip(
+                operation.ase_id,
+                operation.metadata,
+            ):
+                responses.append(self.on_operation(operation.op_code, ase_id, args))
+        elif operation.op_code in (
+            ASE_Operation.Opcode.RECEIVER_START_READY,
+            ASE_Operation.Opcode.DISABLE,
+            ASE_Operation.Opcode.RECEIVER_STOP_READY,
+            ASE_Operation.Opcode.RELEASE,
+        ):
+            for ase_id in operation.ase_id:
+                responses.append(self.on_operation(operation.op_code, ase_id, []))
+
+        control_point_notification = bytes(
+            [operation.op_code, len(responses)]
+        ) + b''.join(map(bytes, responses))
+        self.device.abort_on(
+            'flush',
+            self.device.notify_subscribers(
+                self.ase_control_point, control_point_notification
+            ),
+        )
+
+        for ase_id, *_ in responses:
+            if ase := self.ase_state_machines.get(ase_id):
+                self.device.abort_on(
+                    'flush',
+                    self.device.notify_subscribers(ase, ase.value),
+                )
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
+    SERVICE_CLASS = PublishedAudioCapabilitiesService
+
+    sink_pac: Optional[gatt_client.CharacteristicProxy] = None
+    sink_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
+    source_pac: Optional[gatt_client.CharacteristicProxy] = None
+    source_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
+    available_audio_contexts: gatt_client.CharacteristicProxy
+    supported_audio_contexts: gatt_client.CharacteristicProxy
+
+    def __init__(self, service_proxy: gatt_client.ServiceProxy):
+        self.service_proxy = service_proxy
+
+        self.available_audio_contexts = service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
+        )[0]
+        self.supported_audio_contexts = service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
+        )[0]
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SINK_PAC_CHARACTERISTIC
+        ):
+            self.sink_pac = characteristics[0]
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SOURCE_PAC_CHARACTERISTIC
+        ):
+            self.source_pac = characteristics[0]
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC
+        ):
+            self.sink_audio_locations = characteristics[0]
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC
+        ):
+            self.source_audio_locations = characteristics[0]
+
+
+class AudioStreamControlServiceProxy(gatt_client.ProfileServiceProxy):
+    SERVICE_CLASS = AudioStreamControlService
+
+    sink_ase: List[gatt_client.CharacteristicProxy]
+    source_ase: List[gatt_client.CharacteristicProxy]
+    ase_control_point: gatt_client.CharacteristicProxy
+
+    def __init__(self, service_proxy: gatt_client.ServiceProxy):
+        self.service_proxy = service_proxy
+
+        self.sink_ase = service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SINK_ASE_CHARACTERISTIC
+        )
+        self.source_ase = service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SOURCE_ASE_CHARACTERISTIC
+        )
+        self.ase_control_point = service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_ASE_CONTROL_POINT_CHARACTERISTIC
+        )[0]
diff --git a/bumble/profiles/cap.py b/bumble/profiles/cap.py
new file mode 100644
index 0000000..476f908
--- /dev/null
+++ b/bumble/profiles/cap.py
@@ -0,0 +1,52 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+
+from bumble import gatt
+from bumble import gatt_client
+from bumble.profiles import csip
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+class CommonAudioServiceService(gatt.TemplateService):
+    UUID = gatt.GATT_COMMON_AUDIO_SERVICE
+
+    def __init__(
+        self,
+        coordinated_set_identification_service: csip.CoordinatedSetIdentificationService,
+    ) -> None:
+        self.coordinated_set_identification_service = (
+            coordinated_set_identification_service
+        )
+        super().__init__(
+            characteristics=[],
+            included_services=[coordinated_set_identification_service],
+        )
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+class CommonAudioServiceServiceProxy(gatt_client.ProfileServiceProxy):
+    SERVICE_CLASS = CommonAudioServiceService
+
+    def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
+        self.service_proxy = service_proxy
diff --git a/bumble/profiles/csip.py b/bumble/profiles/csip.py
new file mode 100644
index 0000000..03fba9c
--- /dev/null
+++ b/bumble/profiles/csip.py
@@ -0,0 +1,257 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import enum
+import struct
+from typing import Optional, Tuple
+
+from bumble import core
+from bumble import crypto
+from bumble import device
+from bumble import gatt
+from bumble import gatt_client
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+SET_IDENTITY_RESOLVING_KEY_LENGTH = 16
+
+
+class SirkType(enum.IntEnum):
+    '''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.'''
+
+    ENCRYPTED = 0x00
+    PLAINTEXT = 0x01
+
+
+class MemberLock(enum.IntEnum):
+    '''Coordinated Set Identification Service - 5.3 Set Member Lock.'''
+
+    UNLOCKED = 0x01
+    LOCKED = 0x02
+
+
+# -----------------------------------------------------------------------------
+# Crypto Toolbox
+# -----------------------------------------------------------------------------
+def s1(m: bytes) -> bytes:
+    '''
+    Coordinated Set Identification Service - 4.3 s1 SALT generation function.
+    '''
+    return crypto.aes_cmac(m[::-1], bytes(16))[::-1]
+
+
+def k1(n: bytes, salt: bytes, p: bytes) -> bytes:
+    '''
+    Coordinated Set Identification Service - 4.4 k1 derivation function.
+    '''
+    t = crypto.aes_cmac(n[::-1], salt[::-1])
+    return crypto.aes_cmac(p[::-1], t)[::-1]
+
+
+def sef(k: bytes, r: bytes) -> bytes:
+    '''
+    Coordinated Set Identification Service - 4.5 SIRK encryption function sef.
+
+    SIRK decryption function sdf shares the same algorithm. The only difference is that argument r is:
+      * Plaintext in encryption
+      * Cipher in decryption
+    '''
+    return crypto.xor(k1(k, s1(b'SIRKenc'[::-1]), b'csis'[::-1]), r)
+
+
+def sih(k: bytes, r: bytes) -> bytes:
+    '''
+    Coordinated Set Identification Service - 4.7 Resolvable Set Identifier hash function sih.
+    '''
+    return crypto.e(k, r + bytes(13))[:3]
+
+
+def generate_rsi(sirk: bytes) -> bytes:
+    '''
+    Coordinated Set Identification Service - 4.8 Resolvable Set Identifier generation operation.
+    '''
+    prand = crypto.generate_prand()
+    return sih(sirk, prand) + prand
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+class CoordinatedSetIdentificationService(gatt.TemplateService):
+    UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
+
+    set_identity_resolving_key: bytes
+    set_identity_resolving_key_characteristic: gatt.Characteristic
+    coordinated_set_size_characteristic: Optional[gatt.Characteristic] = None
+    set_member_lock_characteristic: Optional[gatt.Characteristic] = None
+    set_member_rank_characteristic: Optional[gatt.Characteristic] = None
+
+    def __init__(
+        self,
+        set_identity_resolving_key: bytes,
+        set_identity_resolving_key_type: SirkType,
+        coordinated_set_size: Optional[int] = None,
+        set_member_lock: Optional[MemberLock] = None,
+        set_member_rank: Optional[int] = None,
+    ) -> None:
+        if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
+            raise ValueError(
+                f'Invalid SIRK length {len(set_identity_resolving_key)}, expected {SET_IDENTITY_RESOLVING_KEY_LENGTH}'
+            )
+
+        characteristics = []
+
+        self.set_identity_resolving_key = set_identity_resolving_key
+        self.set_identity_resolving_key_type = set_identity_resolving_key_type
+        self.set_identity_resolving_key_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=gatt.CharacteristicValue(read=self.on_sirk_read),
+        )
+        characteristics.append(self.set_identity_resolving_key_characteristic)
+
+        if coordinated_set_size is not None:
+            self.coordinated_set_size_characteristic = gatt.Characteristic(
+                uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC,
+                properties=gatt.Characteristic.Properties.READ
+                | gatt.Characteristic.Properties.NOTIFY,
+                permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+                value=struct.pack('B', coordinated_set_size),
+            )
+            characteristics.append(self.coordinated_set_size_characteristic)
+
+        if set_member_lock is not None:
+            self.set_member_lock_characteristic = gatt.Characteristic(
+                uuid=gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC,
+                properties=gatt.Characteristic.Properties.READ
+                | gatt.Characteristic.Properties.NOTIFY
+                | gatt.Characteristic.Properties.WRITE,
+                permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
+                | gatt.Characteristic.Permissions.WRITEABLE,
+                value=struct.pack('B', set_member_lock),
+            )
+            characteristics.append(self.set_member_lock_characteristic)
+
+        if set_member_rank is not None:
+            self.set_member_rank_characteristic = gatt.Characteristic(
+                uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC,
+                properties=gatt.Characteristic.Properties.READ
+                | gatt.Characteristic.Properties.NOTIFY,
+                permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+                value=struct.pack('B', set_member_rank),
+            )
+            characteristics.append(self.set_member_rank_characteristic)
+
+        super().__init__(characteristics)
+
+    async def on_sirk_read(self, connection: Optional[device.Connection]) -> bytes:
+        if self.set_identity_resolving_key_type == SirkType.PLAINTEXT:
+            sirk_bytes = self.set_identity_resolving_key
+        else:
+            assert connection
+
+            if connection.transport == core.BT_LE_TRANSPORT:
+                key = await connection.device.get_long_term_key(
+                    connection_handle=connection.handle, rand=b'', ediv=0
+                )
+            else:
+                key = await connection.device.get_link_key(connection.peer_address)
+
+            if not key:
+                raise RuntimeError('LTK or LinkKey is not present')
+
+            sirk_bytes = sef(key, self.set_identity_resolving_key)
+
+        return bytes([self.set_identity_resolving_key_type]) + sirk_bytes
+
+    def get_advertising_data(self) -> bytes:
+        return bytes(
+            core.AdvertisingData(
+                [
+                    (
+                        core.AdvertisingData.RESOLVABLE_SET_IDENTIFIER,
+                        generate_rsi(self.set_identity_resolving_key),
+                    ),
+                ]
+            )
+        )
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
+    SERVICE_CLASS = CoordinatedSetIdentificationService
+
+    set_identity_resolving_key: gatt_client.CharacteristicProxy
+    coordinated_set_size: Optional[gatt_client.CharacteristicProxy] = None
+    set_member_lock: Optional[gatt_client.CharacteristicProxy] = None
+    set_member_rank: Optional[gatt_client.CharacteristicProxy] = None
+
+    def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
+        self.service_proxy = service_proxy
+
+        self.set_identity_resolving_key = service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC
+        )[0]
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC
+        ):
+            self.coordinated_set_size = characteristics[0]
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC
+        ):
+            self.set_member_lock = characteristics[0]
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC
+        ):
+            self.set_member_rank = characteristics[0]
+
+    async def read_set_identity_resolving_key(self) -> Tuple[SirkType, bytes]:
+        '''Reads SIRK and decrypts if encrypted.'''
+        response = await self.set_identity_resolving_key.read_value()
+        if len(response) != SET_IDENTITY_RESOLVING_KEY_LENGTH + 1:
+            raise RuntimeError('Invalid SIRK value')
+
+        sirk_type = SirkType(response[0])
+        if sirk_type == SirkType.PLAINTEXT:
+            sirk = response[1:]
+        else:
+            connection = self.service_proxy.client.connection
+            device = connection.device
+            if connection.transport == core.BT_LE_TRANSPORT:
+                key = await device.get_long_term_key(
+                    connection_handle=connection.handle, rand=b'', ediv=0
+                )
+            else:
+                key = await device.get_link_key(connection.peer_address)
+
+            if not key:
+                raise RuntimeError('LTK or LinkKey is not present')
+
+            sirk = sef(key, response[1:])
+
+        return (sirk_type, sirk)
diff --git a/bumble/profiles/heart_rate_service.py b/bumble/profiles/heart_rate_service.py
index c7d3018..fe46cb2 100644
--- a/bumble/profiles/heart_rate_service.py
+++ b/bumble/profiles/heart_rate_service.py
@@ -42,12 +42,12 @@
     RESET_ENERGY_EXPENDED = 0x01
 
     class BodySensorLocation(IntEnum):
-        OTHER = (0,)
-        CHEST = (1,)
-        WRIST = (2,)
-        FINGER = (3,)
-        HAND = (4,)
-        EAR_LOBE = (5,)
+        OTHER = 0
+        CHEST = 1
+        WRIST = 2
+        FINGER = 3
+        HAND = 4
+        EAR_LOBE = 5
         FOOT = 6
 
     class HeartRateMeasurement:
diff --git a/bumble/profiles/vcp.py b/bumble/profiles/vcp.py
new file mode 100644
index 0000000..0788219
--- /dev/null
+++ b/bumble/profiles/vcp.py
@@ -0,0 +1,228 @@
+# Copyright 2021-2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import enum
+
+from bumble import att
+from bumble import device
+from bumble import gatt
+from bumble import gatt_client
+
+from typing import Optional
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+MIN_VOLUME = 0
+MAX_VOLUME = 255
+
+
+class ErrorCode(enum.IntEnum):
+    '''
+    See Volume Control Service 1.6. Application error codes.
+    '''
+
+    INVALID_CHANGE_COUNTER = 0x80
+    OPCODE_NOT_SUPPORTED = 0x81
+
+
+class VolumeFlags(enum.IntFlag):
+    '''
+    See Volume Control Service 3.3. Volume Flags.
+    '''
+
+    VOLUME_SETTING_PERSISTED = 0x01
+    # RFU
+
+
+class VolumeControlPointOpcode(enum.IntEnum):
+    '''
+    See Volume Control Service Table 3.3: Volume Control Point procedure requirements.
+    '''
+
+    # fmt: off
+    RELATIVE_VOLUME_DOWN        = 0x00
+    RELATIVE_VOLUME_UP          = 0x01
+    UNMUTE_RELATIVE_VOLUME_DOWN = 0x02
+    UNMUTE_RELATIVE_VOLUME_UP   = 0x03
+    SET_ABSOLUTE_VOLUME         = 0x04
+    UNMUTE                      = 0x05
+    MUTE                        = 0x06
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+class VolumeControlService(gatt.TemplateService):
+    UUID = gatt.GATT_VOLUME_CONTROL_SERVICE
+
+    volume_state: gatt.Characteristic
+    volume_control_point: gatt.Characteristic
+    volume_flags: gatt.Characteristic
+
+    volume_setting: int
+    muted: int
+    change_counter: int
+
+    def __init__(
+        self,
+        step_size: int = 16,
+        volume_setting: int = 0,
+        muted: int = 0,
+        change_counter: int = 0,
+        volume_flags: int = 0,
+    ) -> None:
+        self.step_size = step_size
+        self.volume_setting = volume_setting
+        self.muted = muted
+        self.change_counter = change_counter
+
+        self.volume_state = gatt.Characteristic(
+            uuid=gatt.GATT_VOLUME_STATE_CHARACTERISTIC,
+            properties=(
+                gatt.Characteristic.Properties.READ
+                | gatt.Characteristic.Properties.NOTIFY
+            ),
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=gatt.CharacteristicValue(read=self._on_read_volume_state),
+        )
+        self.volume_control_point = gatt.Characteristic(
+            uuid=gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.WRITE,
+            permissions=gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
+            value=gatt.CharacteristicValue(write=self._on_write_volume_control_point),
+        )
+        self.volume_flags = gatt.Characteristic(
+            uuid=gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=bytes([volume_flags]),
+        )
+
+        super().__init__(
+            [
+                self.volume_state,
+                self.volume_control_point,
+                self.volume_flags,
+            ]
+        )
+
+    @property
+    def volume_state_bytes(self) -> bytes:
+        return bytes([self.volume_setting, self.muted, self.change_counter])
+
+    @volume_state_bytes.setter
+    def volume_state_bytes(self, new_value: bytes) -> None:
+        self.volume_setting, self.muted, self.change_counter = new_value
+
+    def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes:
+        return self.volume_state_bytes
+
+    def _on_write_volume_control_point(
+        self, connection: Optional[device.Connection], value: bytes
+    ) -> None:
+        assert connection
+
+        opcode = VolumeControlPointOpcode(value[0])
+        change_counter = value[1]
+
+        if change_counter != self.change_counter:
+            raise att.ATT_Error(ErrorCode.INVALID_CHANGE_COUNTER)
+
+        handler = getattr(self, '_on_' + opcode.name.lower())
+        if handler(*value[2:]):
+            self.change_counter = (self.change_counter + 1) % 256
+            connection.abort_on(
+                'disconnection',
+                connection.device.notify_subscribers(
+                    attribute=self.volume_state,
+                    value=self.volume_state_bytes,
+                ),
+            )
+            self.emit(
+                'volume_state', self.volume_setting, self.muted, self.change_counter
+            )
+
+    def _on_relative_volume_down(self) -> bool:
+        old_volume = self.volume_setting
+        self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME)
+        return self.volume_setting != old_volume
+
+    def _on_relative_volume_up(self) -> bool:
+        old_volume = self.volume_setting
+        self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME)
+        return self.volume_setting != old_volume
+
+    def _on_unmute_relative_volume_down(self) -> bool:
+        old_volume, old_muted_state = self.volume_setting, self.muted
+        self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME)
+        self.muted = 0
+        return (self.volume_setting, self.muted) != (old_volume, old_muted_state)
+
+    def _on_unmute_relative_volume_up(self) -> bool:
+        old_volume, old_muted_state = self.volume_setting, self.muted
+        self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME)
+        self.muted = 0
+        return (self.volume_setting, self.muted) != (old_volume, old_muted_state)
+
+    def _on_set_absolute_volume(self, volume_setting: int) -> bool:
+        old_volume_setting = self.volume_setting
+        self.volume_setting = volume_setting
+        return old_volume_setting != self.volume_setting
+
+    def _on_unmute(self) -> bool:
+        old_muted_state = self.muted
+        self.muted = 0
+        return self.muted != old_muted_state
+
+    def _on_mute(self) -> bool:
+        old_muted_state = self.muted
+        self.muted = 1
+        return self.muted != old_muted_state
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
+    SERVICE_CLASS = VolumeControlService
+
+    volume_control_point: gatt_client.CharacteristicProxy
+
+    def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
+        self.service_proxy = service_proxy
+
+        self.volume_state = gatt.PackedCharacteristicAdapter(
+            service_proxy.get_characteristics_by_uuid(
+                gatt.GATT_VOLUME_STATE_CHARACTERISTIC
+            )[0],
+            'BBB',
+        )
+
+        self.volume_control_point = service_proxy.get_characteristics_by_uuid(
+            gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC
+        )[0]
+
+        self.volume_flags = gatt.PackedCharacteristicAdapter(
+            service_proxy.get_characteristics_by_uuid(
+                gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
+            )[0],
+            'B',
+        )
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index 53e98e0..6ca0f50 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -19,12 +19,16 @@
 
 import logging
 import asyncio
+import dataclasses
 import enum
 from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
+from typing_extensions import Self
 
 from pyee import EventEmitter
 
-from . import core, l2cap
+from bumble import core
+from bumble import l2cap
+from bumble import sdp
 from .colors import color
 from .core import (
     UUID,
@@ -34,15 +38,6 @@
     InvalidStateError,
     ProtocolError,
 )
-from .sdp import (
-    SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
-    SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
-    SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
-    SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-    SDP_PUBLIC_BROWSE_ROOT,
-    DataElement,
-    ServiceAttribute,
-)
 
 if TYPE_CHECKING:
     from bumble.device import Device, Connection
@@ -60,27 +55,18 @@
 
 RFCOMM_PSM = 0x0003
 
+class FrameType(enum.IntEnum):
+    SABM = 0x2F  # Control field [1,1,1,1,_,1,0,0] LSB-first
+    UA   = 0x63  # Control field [0,1,1,0,_,0,1,1] LSB-first
+    DM   = 0x0F  # Control field [1,1,1,1,_,0,0,0] LSB-first
+    DISC = 0x43  # Control field [0,1,0,_,0,0,1,1] LSB-first
+    UIH  = 0xEF  # Control field [1,1,1,_,1,1,1,1] LSB-first
+    UI   = 0x03  # Control field [0,0,0,_,0,0,1,1] LSB-first
 
-# Frame types
-RFCOMM_SABM_FRAME = 0x2F  # Control field [1,1,1,1,_,1,0,0] LSB-first
-RFCOMM_UA_FRAME   = 0x63  # Control field [0,1,1,0,_,0,1,1] LSB-first
-RFCOMM_DM_FRAME   = 0x0F  # Control field [1,1,1,1,_,0,0,0] LSB-first
-RFCOMM_DISC_FRAME = 0x43  # Control field [0,1,0,_,0,0,1,1] LSB-first
-RFCOMM_UIH_FRAME  = 0xEF  # Control field [1,1,1,_,1,1,1,1] LSB-first
-RFCOMM_UI_FRAME   = 0x03  # Control field [0,0,0,_,0,0,1,1] LSB-first
+class MccType(enum.IntEnum):
+    PN  = 0x20
+    MSC = 0x38
 
-RFCOMM_FRAME_TYPE_NAMES = {
-    RFCOMM_SABM_FRAME: 'SABM',
-    RFCOMM_UA_FRAME:   'UA',
-    RFCOMM_DM_FRAME:   'DM',
-    RFCOMM_DISC_FRAME: 'DISC',
-    RFCOMM_UIH_FRAME:  'UIH',
-    RFCOMM_UI_FRAME:   'UI'
-}
-
-# MCC Types
-RFCOMM_MCC_PN_TYPE  = 0x20
-RFCOMM_MCC_MSC_TYPE = 0x38
 
 # FCS CRC
 CRC_TABLE = bytes([
@@ -118,8 +104,9 @@
     0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
 ])
 
-RFCOMM_DEFAULT_INITIAL_RX_CREDITS = 7
-RFCOMM_DEFAULT_PREFERRED_MTU      = 1280
+RFCOMM_DEFAULT_L2CAP_MTU      = 2048
+RFCOMM_DEFAULT_WINDOW_SIZE    = 7
+RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
 
 RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
 RFCOMM_DYNAMIC_CHANNEL_NUMBER_END   = 30
@@ -130,29 +117,33 @@
 # -----------------------------------------------------------------------------
 def make_service_sdp_records(
     service_record_handle: int, channel: int, uuid: Optional[UUID] = None
-) -> List[ServiceAttribute]:
+) -> List[sdp.ServiceAttribute]:
     """
     Create SDP records for an RFComm service given a channel number and an
     optional UUID. A Service Class Attribute is included only if the UUID is not None.
     """
     records = [
-        ServiceAttribute(
-            SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
-            DataElement.unsigned_integer_32(service_record_handle),
+        sdp.ServiceAttribute(
+            sdp.SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+            sdp.DataElement.unsigned_integer_32(service_record_handle),
         ),
-        ServiceAttribute(
-            SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
-            DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+        sdp.ServiceAttribute(
+            sdp.SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+            sdp.DataElement.sequence(
+                [sdp.DataElement.uuid(sdp.SDP_PUBLIC_BROWSE_ROOT)]
+            ),
         ),
-        ServiceAttribute(
-            SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-            DataElement.sequence(
+        sdp.ServiceAttribute(
+            sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+            sdp.DataElement.sequence(
                 [
-                    DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
-                    DataElement.sequence(
+                    sdp.DataElement.sequence(
+                        [sdp.DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]
+                    ),
+                    sdp.DataElement.sequence(
                         [
-                            DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
-                            DataElement.unsigned_integer_8(channel),
+                            sdp.DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
+                            sdp.DataElement.unsigned_integer_8(channel),
                         ]
                     ),
                 ]
@@ -162,9 +153,9 @@
 
     if uuid:
         records.append(
-            ServiceAttribute(
-                SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
-                DataElement.sequence([DataElement.uuid(uuid)]),
+            sdp.ServiceAttribute(
+                sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+                sdp.DataElement.sequence([sdp.DataElement.uuid(uuid)]),
             )
         )
 
@@ -172,6 +163,72 @@
 
 
 # -----------------------------------------------------------------------------
+async def find_rfcomm_channels(connection: Connection) -> Dict[int, List[UUID]]:
+    """Searches all RFCOMM channels and their associated UUID from SDP service records.
+
+    Args:
+        connection: ACL connection to make SDP search.
+
+    Returns:
+        Dictionary mapping from channel number to service class UUID list.
+    """
+    results = {}
+    async with sdp.Client(connection) as sdp_client:
+        search_result = await sdp_client.search_attributes(
+            uuids=[core.BT_RFCOMM_PROTOCOL_ID],
+            attribute_ids=[
+                sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+                sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+            ],
+        )
+        for attribute_lists in search_result:
+            service_classes: List[UUID] = []
+            channel: Optional[int] = None
+            for attribute in attribute_lists:
+                # The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
+                if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
+                    protocol_descriptor_list = attribute.value.value
+                    channel = protocol_descriptor_list[1].value[1].value
+                elif attribute.id == sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
+                    service_class_id_list = attribute.value.value
+                    service_classes = [
+                        service_class.value for service_class in service_class_id_list
+                    ]
+            if not service_classes or not channel:
+                logger.warning(f"Bad result {attribute_lists}.")
+            else:
+                results[channel] = service_classes
+    return results
+
+
+# -----------------------------------------------------------------------------
+async def find_rfcomm_channel_with_uuid(
+    connection: Connection, uuid: str | UUID
+) -> Optional[int]:
+    """Searches an RFCOMM channel associated with given UUID from service records.
+
+    Args:
+        connection: ACL connection to make SDP search.
+        uuid: UUID of service record to search for.
+
+    Returns:
+        RFCOMM channel number if found, otherwise None.
+    """
+    if isinstance(uuid, str):
+        uuid = UUID(uuid)
+    return next(
+        (
+            channel
+            for channel, class_id_list in (
+                await find_rfcomm_channels(connection)
+            ).items()
+            if uuid in class_id_list
+        ),
+        None,
+    )
+
+
+# -----------------------------------------------------------------------------
 def compute_fcs(buffer: bytes) -> int:
     result = 0xFF
     for byte in buffer:
@@ -183,7 +240,7 @@
 class RFCOMM_Frame:
     def __init__(
         self,
-        frame_type: int,
+        frame_type: FrameType,
         c_r: int,
         dlci: int,
         p_f: int,
@@ -206,14 +263,11 @@
             self.length = bytes([(length << 1) | 1])
         self.address = (dlci << 2) | (c_r << 1) | 1
         self.control = frame_type | (p_f << 4)
-        if frame_type == RFCOMM_UIH_FRAME:
+        if frame_type == FrameType.UIH:
             self.fcs = compute_fcs(bytes([self.address, self.control]))
         else:
             self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
 
-    def type_name(self) -> str:
-        return RFCOMM_FRAME_TYPE_NAMES[self.type]
-
     @staticmethod
     def parse_mcc(data) -> Tuple[int, bool, bytes]:
         mcc_type = data[0] >> 2
@@ -237,24 +291,24 @@
 
     @staticmethod
     def sabm(c_r: int, dlci: int):
-        return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
+        return RFCOMM_Frame(FrameType.SABM, c_r, dlci, 1)
 
     @staticmethod
     def ua(c_r: int, dlci: int):
-        return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
+        return RFCOMM_Frame(FrameType.UA, c_r, dlci, 1)
 
     @staticmethod
     def dm(c_r: int, dlci: int):
-        return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
+        return RFCOMM_Frame(FrameType.DM, c_r, dlci, 1)
 
     @staticmethod
     def disc(c_r: int, dlci: int):
-        return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
+        return RFCOMM_Frame(FrameType.DISC, c_r, dlci, 1)
 
     @staticmethod
     def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
         return RFCOMM_Frame(
-            RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
+            FrameType.UIH, c_r, dlci, p_f, information, with_credits=(p_f == 1)
         )
 
     @staticmethod
@@ -262,7 +316,7 @@
         # Extract fields
         dlci = (data[0] >> 2) & 0x3F
         c_r = (data[0] >> 1) & 0x01
-        frame_type = data[1] & 0xEF
+        frame_type = FrameType(data[1] & 0xEF)
         p_f = (data[1] >> 4) & 0x01
         length = data[2]
         if length & 0x01:
@@ -291,7 +345,7 @@
 
     def __str__(self) -> str:
         return (
-            f'{color(self.type_name(), "yellow")}'
+            f'{color(self.type.name, "yellow")}'
             f'(c/r={self.c_r},'
             f'dlci={self.dlci},'
             f'p/f={self.p_f},'
@@ -301,6 +355,7 @@
 
 
 # -----------------------------------------------------------------------------
+@dataclasses.dataclass
 class RFCOMM_MCC_PN:
     dlci: int
     cl: int
@@ -310,23 +365,11 @@
     max_retransmissions: int
     window_size: int
 
-    def __init__(
-        self,
-        dlci: int,
-        cl: int,
-        priority: int,
-        ack_timer: int,
-        max_frame_size: int,
-        max_retransmissions: int,
-        window_size: int,
-    ) -> None:
-        self.dlci = dlci
-        self.cl = cl
-        self.priority = priority
-        self.ack_timer = ack_timer
-        self.max_frame_size = max_frame_size
-        self.max_retransmissions = max_retransmissions
-        self.window_size = window_size
+    def __post_init__(self) -> None:
+        if self.window_size < 1 or self.window_size > 7:
+            logger.warning(
+                f'Error Recovery Window size {self.window_size} is out of range [1, 7].'
+            )
 
     @staticmethod
     def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
@@ -337,7 +380,7 @@
             ack_timer=data[3],
             max_frame_size=data[4] | data[5] << 8,
             max_retransmissions=data[6],
-            window_size=data[7],
+            window_size=data[7] & 0x07,
         )
 
     def __bytes__(self) -> bytes:
@@ -350,23 +393,14 @@
                 self.max_frame_size & 0xFF,
                 (self.max_frame_size >> 8) & 0xFF,
                 self.max_retransmissions & 0xFF,
-                self.window_size & 0xFF,
+                # Only 3 bits are meaningful.
+                self.window_size & 0x07,
             ]
         )
 
-    def __str__(self) -> str:
-        return (
-            f'PN(dlci={self.dlci},'
-            f'cl={self.cl},'
-            f'priority={self.priority},'
-            f'ack_timer={self.ack_timer},'
-            f'max_frame_size={self.max_frame_size},'
-            f'max_retransmissions={self.max_retransmissions},'
-            f'window_size={self.window_size})'
-        )
-
 
 # -----------------------------------------------------------------------------
+@dataclasses.dataclass
 class RFCOMM_MCC_MSC:
     dlci: int
     fc: int
@@ -375,16 +409,6 @@
     ic: int
     dv: int
 
-    def __init__(
-        self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
-    ) -> None:
-        self.dlci = dlci
-        self.fc = fc
-        self.rtc = rtc
-        self.rtr = rtr
-        self.ic = ic
-        self.dv = dv
-
     @staticmethod
     def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
         return RFCOMM_MCC_MSC(
@@ -409,16 +433,6 @@
             ]
         )
 
-    def __str__(self) -> str:
-        return (
-            f'MSC(dlci={self.dlci},'
-            f'fc={self.fc},'
-            f'rtc={self.rtc},'
-            f'rtr={self.rtr},'
-            f'ic={self.ic},'
-            f'dv={self.dv})'
-        )
-
 
 # -----------------------------------------------------------------------------
 class DLC(EventEmitter):
@@ -438,25 +452,29 @@
         multiplexer: Multiplexer,
         dlci: int,
         max_frame_size: int,
-        initial_tx_credits: int,
+        window_size: int,
     ) -> None:
         super().__init__()
         self.multiplexer = multiplexer
         self.dlci = dlci
-        self.rx_credits = RFCOMM_DEFAULT_INITIAL_RX_CREDITS
-        self.rx_threshold = self.rx_credits // 2
-        self.tx_credits = initial_tx_credits
+        self.max_frame_size = max_frame_size
+        self.window_size = window_size
+        self.rx_credits = window_size
+        self.rx_threshold = window_size // 2
+        self.tx_credits = window_size
         self.tx_buffer = b''
         self.state = DLC.State.INIT
         self.role = multiplexer.role
         self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
         self.sink = None
         self.connection_result = None
+        self.drained = asyncio.Event()
+        self.drained.set()
 
         # Compute the MTU
         max_overhead = 4 + 1  # header with 2-byte length + fcs
         self.mtu = min(
-            max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
+            max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
         )
 
     def change_state(self, new_state: State) -> None:
@@ -467,7 +485,7 @@
         self.multiplexer.send_frame(frame)
 
     def on_frame(self, frame: RFCOMM_Frame) -> None:
-        handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
+        handler = getattr(self, f'on_{frame.type.name}_frame'.lower())
         handler(frame)
 
     def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
@@ -481,9 +499,7 @@
 
         # Exchange the modem status with the peer
         msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
-        mcc = RFCOMM_Frame.make_mcc(
-            mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=1, data=bytes(msc)
-        )
+        mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
         logger.debug(f'>>> MCC MSC Command: {msc}')
         self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
 
@@ -499,9 +515,7 @@
 
         # Exchange the modem status with the peer
         msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
-        mcc = RFCOMM_Frame.make_mcc(
-            mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=1, data=bytes(msc)
-        )
+        mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
         logger.debug(f'>>> MCC MSC Command: {msc}')
         self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
 
@@ -534,14 +548,15 @@
             f'[{self.dlci}] {len(data)} bytes, '
             f'rx_credits={self.rx_credits}: {data.hex()}'
         )
-        if len(data) and self.sink:
-            self.sink(data)  # pylint: disable=not-callable
+        if data:
+            if self.sink:
+                self.sink(data)  # pylint: disable=not-callable
 
-        # Update the credits
-        if self.rx_credits > 0:
-            self.rx_credits -= 1
-        else:
-            logger.warning(color('!!! received frame with no rx credits', 'red'))
+            # Update the credits
+            if self.rx_credits > 0:
+                self.rx_credits -= 1
+            else:
+                logger.warning(color('!!! received frame with no rx credits', 'red'))
 
         # Check if there's anything to send (including credits)
         self.process_tx()
@@ -554,9 +569,7 @@
             # Command
             logger.debug(f'<<< MCC MSC Command: {msc}')
             msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
-            mcc = RFCOMM_Frame.make_mcc(
-                mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=0, data=bytes(msc)
-            )
+            mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=0, data=bytes(msc))
             logger.debug(f'>>> MCC MSC Response: {msc}')
             self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
         else:
@@ -580,18 +593,18 @@
             cl=0xE0,
             priority=7,
             ack_timer=0,
-            max_frame_size=RFCOMM_DEFAULT_PREFERRED_MTU,
+            max_frame_size=self.max_frame_size,
             max_retransmissions=0,
-            window_size=RFCOMM_DEFAULT_INITIAL_RX_CREDITS,
+            window_size=self.window_size,
         )
-        mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
+        mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
         logger.debug(f'>>> PN Response: {pn}')
         self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
         self.change_state(DLC.State.CONNECTING)
 
     def rx_credits_needed(self) -> int:
         if self.rx_credits <= self.rx_threshold:
-            return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
+            return self.window_size - self.rx_credits
 
         return 0
 
@@ -631,6 +644,8 @@
             )
 
             rx_credits_needed = 0
+            if not self.tx_buffer:
+                self.drained.set()
 
     # Stream protocol
     def write(self, data: Union[bytes, str]) -> None:
@@ -643,11 +658,11 @@
                 raise ValueError('write only accept bytes or strings')
 
         self.tx_buffer += data
+        self.drained.clear()
         self.process_tx()
 
-    def drain(self) -> None:
-        # TODO
-        pass
+    async def drain(self) -> None:
+        await self.drained.wait()
 
     def __str__(self) -> str:
         return f'DLC(dlci={self.dlci},state={self.state.name})'
@@ -704,7 +719,7 @@
         if frame.dlci == 0:
             self.on_frame(frame)
         else:
-            if frame.type == RFCOMM_DM_FRAME:
+            if frame.type == FrameType.DM:
                 # DM responses are for a DLCI, but since we only create the dlc when we
                 # receive a PN response (because we need the parameters), we handle DM
                 # frames at the Multiplexer level
@@ -717,7 +732,7 @@
                 dlc.on_frame(frame)
 
     def on_frame(self, frame: RFCOMM_Frame) -> None:
-        handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
+        handler = getattr(self, f'on_{frame.type.name}_frame'.lower())
         handler(frame)
 
     def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
@@ -765,10 +780,10 @@
     def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
         (mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
 
-        if mcc_type == RFCOMM_MCC_PN_TYPE:
+        if mcc_type == MccType.PN:
             pn = RFCOMM_MCC_PN.from_bytes(value)
             self.on_mcc_pn(c_r, pn)
-        elif mcc_type == RFCOMM_MCC_MSC_TYPE:
+        elif mcc_type == MccType.MSC:
             mcs = RFCOMM_MCC_MSC.from_bytes(value)
             self.on_mcc_msc(c_r, mcs)
 
@@ -843,7 +858,12 @@
         )
         await self.disconnection_result
 
-    async def open_dlc(self, channel: int) -> DLC:
+    async def open_dlc(
+        self,
+        channel: int,
+        max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
+        window_size: int = RFCOMM_DEFAULT_WINDOW_SIZE,
+    ) -> DLC:
         if self.state != Multiplexer.State.CONNECTED:
             if self.state == Multiplexer.State.OPENING:
                 raise InvalidStateError('open already in progress')
@@ -855,11 +875,11 @@
             cl=0xF0,
             priority=7,
             ack_timer=0,
-            max_frame_size=RFCOMM_DEFAULT_PREFERRED_MTU,
+            max_frame_size=max_frame_size,
             max_retransmissions=0,
-            window_size=RFCOMM_DEFAULT_INITIAL_RX_CREDITS,
+            window_size=window_size,
         )
-        mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
+        mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=1, data=bytes(pn))
         logger.debug(f'>>> Sending MCC: {pn}')
         self.open_result = asyncio.get_running_loop().create_future()
         self.change_state(Multiplexer.State.OPENING)
@@ -889,9 +909,11 @@
     multiplexer: Optional[Multiplexer]
     l2cap_channel: Optional[l2cap.ClassicChannel]
 
-    def __init__(self, device: Device, connection: Connection) -> None:
-        self.device = device
+    def __init__(
+        self, connection: Connection, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
+    ) -> None:
         self.connection = connection
+        self.l2cap_mtu = l2cap_mtu
         self.l2cap_channel = None
         self.multiplexer = None
 
@@ -899,14 +921,14 @@
         # Create a new L2CAP connection
         try:
             self.l2cap_channel = await self.connection.create_l2cap_channel(
-                spec=l2cap.ClassicChannelSpec(RFCOMM_PSM)
+                spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM, mtu=self.l2cap_mtu)
             )
         except ProtocolError as error:
             logger.warning(f'L2CAP connection failed: {error}')
             raise
 
         assert self.l2cap_channel is not None
-        # Create a mutliplexer to manage DLCs with the server
+        # Create a multiplexer to manage DLCs with the server
         self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
 
         # Connect the multiplexer
@@ -922,22 +944,33 @@
         self.multiplexer = None
 
         # Close the L2CAP channel
-        # TODO
+        if self.l2cap_channel:
+            await self.l2cap_channel.disconnect()
+            self.l2cap_channel = None
+
+    async def __aenter__(self) -> Multiplexer:
+        return await self.start()
+
+    async def __aexit__(self, *args) -> None:
+        await self.shutdown()
 
 
 # -----------------------------------------------------------------------------
 class Server(EventEmitter):
     acceptors: Dict[int, Callable[[DLC], None]]
 
-    def __init__(self, device: Device) -> None:
+    def __init__(
+        self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
+    ) -> None:
         super().__init__()
         self.device = device
         self.multiplexer = None
         self.acceptors = {}
 
         # Register ourselves with the L2CAP channel manager
-        device.create_l2cap_server(
-            spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM), handler=self.on_connection
+        self.l2cap_server = device.create_l2cap_server(
+            spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM, mtu=l2cap_mtu),
+            handler=self.on_connection,
         )
 
     def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
@@ -987,3 +1020,9 @@
         acceptor = self.acceptors.get(dlc.dlci >> 1)
         if acceptor:
             acceptor(dlc)
+
+    def __enter__(self) -> Self:
+        return self
+
+    def __exit__(self, *args) -> None:
+        self.l2cap_server.close()
diff --git a/bumble/sdp.py b/bumble/sdp.py
index bc8303c..6423790 100644
--- a/bumble/sdp.py
+++ b/bumble/sdp.py
@@ -19,6 +19,7 @@
 import logging
 import struct
 from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
+from typing_extensions import Self
 
 from . import core, l2cap
 from .colors import color
@@ -97,7 +98,8 @@
 SDP_ICON_URL_ATTRIBUTE_ID                            = 0X000C
 SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
 
-# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
+
+# Profile-specific Attribute Identifiers (cf. Assigned Numbers for Service Discovery)
 # used by AVRCP, HFP and A2DP
 SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
 
@@ -115,7 +117,8 @@
     SDP_DOCUMENTATION_URL_ATTRIBUTE_ID:                   'SDP_DOCUMENTATION_URL_ATTRIBUTE_ID',
     SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID:               'SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID',
     SDP_ICON_URL_ATTRIBUTE_ID:                            'SDP_ICON_URL_ATTRIBUTE_ID',
-    SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID'
+    SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID',
+    SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID:                  'SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID',
 }
 
 SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
@@ -760,13 +763,13 @@
 class Client:
     channel: Optional[l2cap.ClassicChannel]
 
-    def __init__(self, device: Device) -> None:
-        self.device = device
+    def __init__(self, connection: Connection) -> None:
+        self.connection = connection
         self.pending_request = None
         self.channel = None
 
-    async def connect(self, connection: Connection) -> None:
-        self.channel = await connection.create_l2cap_channel(
+    async def connect(self) -> None:
+        self.channel = await self.connection.create_l2cap_channel(
             spec=l2cap.ClassicChannelSpec(SDP_PSM)
         )
 
@@ -918,6 +921,13 @@
 
         return ServiceAttribute.list_from_data_elements(attribute_list_sequence.value)
 
+    async def __aenter__(self) -> Self:
+        await self.connect()
+        return self
+
+    async def __aexit__(self, *args) -> None:
+        await self.disconnect()
+
 
 # -----------------------------------------------------------------------------
 class Server:
diff --git a/bumble/smp.py b/bumble/smp.py
index f8bba40..73fd439 100644
--- a/bumble/smp.py
+++ b/bumble/smp.py
@@ -27,6 +27,7 @@
 import asyncio
 import enum
 import secrets
+from dataclasses import dataclass
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -53,6 +54,7 @@
     BT_BR_EDR_TRANSPORT,
     BT_CENTRAL_ROLE,
     BT_LE_TRANSPORT,
+    AdvertisingData,
     ProtocolError,
     name_or_number,
 )
@@ -185,8 +187,8 @@
 SMP_CT2_AUTHREQ      = 0b00100000
 
 # Crypto salt
-SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('00000000000000000000000000000000746D7031')
-SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032')
+SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('000000000000000000000000746D7031')
+SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('000000000000000000000000746D7032')
 
 # fmt: on
 # pylint: enable=line-too-long
@@ -564,6 +566,54 @@
 
 
 # -----------------------------------------------------------------------------
+class OobContext:
+    """Cryptographic context for LE SC OOB pairing."""
+
+    ecc_key: crypto.EccKey
+    r: bytes
+
+    def __init__(
+        self, ecc_key: Optional[crypto.EccKey] = None, r: Optional[bytes] = None
+    ) -> None:
+        self.ecc_key = crypto.EccKey.generate() if ecc_key is None else ecc_key
+        self.r = crypto.r() if r is None else r
+
+    def share(self) -> OobSharedData:
+        pkx = self.ecc_key.x[::-1]
+        return OobSharedData(c=crypto.f4(pkx, pkx, self.r, bytes(1)), r=self.r)
+
+
+# -----------------------------------------------------------------------------
+class OobLegacyContext:
+    """Cryptographic context for LE Legacy OOB pairing."""
+
+    tk: bytes
+
+    def __init__(self, tk: Optional[bytes] = None) -> None:
+        self.tk = crypto.r() if tk is None else tk
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class OobSharedData:
+    """Shareable data for LE SC OOB pairing."""
+
+    c: bytes
+    r: bytes
+
+    def to_ad(self) -> AdvertisingData:
+        return AdvertisingData(
+            [
+                (AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE, self.c),
+                (AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE, self.r),
+            ]
+        )
+
+    def __str__(self) -> str:
+        return f'OOB(C={self.c.hex()}, R={self.r.hex()})'
+
+
+# -----------------------------------------------------------------------------
 class Session:
     # I/O Capability to pairing method decision matrix
     #
@@ -627,6 +677,13 @@
         },
     }
 
+    ea: bytes
+    eb: bytes
+    ltk: bytes
+    preq: bytes
+    pres: bytes
+    tk: bytes
+
     def __init__(
         self,
         manager: Manager,
@@ -636,17 +693,10 @@
     ) -> None:
         self.manager = manager
         self.connection = connection
-        self.preq: Optional[bytes] = None
-        self.pres: Optional[bytes] = None
-        self.ea = None
-        self.eb = None
-        self.tk = bytes(16)
-        self.r = bytes(16)
         self.stk = None
-        self.ltk = None
         self.ltk_ediv = 0
         self.ltk_rand = bytes(8)
-        self.link_key = None
+        self.link_key: Optional[bytes] = None
         self.initiator_key_distribution: int = 0
         self.responder_key_distribution: int = 0
         self.peer_random_value: Optional[bytes] = None
@@ -659,7 +709,7 @@
         self.peer_bd_addr: Optional[Address] = None
         self.peer_signature_key = None
         self.peer_expected_distributions: List[Type[SMP_Command]] = []
-        self.dh_key = None
+        self.dh_key = b''
         self.confirm_value = None
         self.passkey: Optional[int] = None
         self.passkey_ready = asyncio.Event()
@@ -712,8 +762,8 @@
         self.io_capability = pairing_config.delegate.io_capability
         self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
 
-        # OOB (not supported yet)
-        self.oob = False
+        # OOB
+        self.oob_data_flag = 0 if pairing_config.oob is None else 1
 
         # Set up addresses
         self_address = connection.self_address
@@ -729,9 +779,35 @@
             self.ia = bytes(peer_address)
             self.iat = 1 if peer_address.is_random else 0
 
+        # Select the ECC key, TK and r initial value
+        if pairing_config.oob:
+            self.peer_oob_data = pairing_config.oob.peer_data
+            if pairing_config.sc:
+                if pairing_config.oob.our_context is None:
+                    raise ValueError(
+                        "oob pairing config requires a context when sc is True"
+                    )
+                self.r = pairing_config.oob.our_context.r
+                self.ecc_key = pairing_config.oob.our_context.ecc_key
+                if pairing_config.oob.legacy_context is not None:
+                    self.tk = pairing_config.oob.legacy_context.tk
+            else:
+                if pairing_config.oob.legacy_context is None:
+                    raise ValueError(
+                        "oob pairing config requires a legacy context when sc is False"
+                    )
+                self.r = bytes(16)
+                self.ecc_key = manager.ecc_key
+                self.tk = pairing_config.oob.legacy_context.tk
+        else:
+            self.peer_oob_data = None
+            self.r = bytes(16)
+            self.ecc_key = manager.ecc_key
+            self.tk = bytes(16)
+
     @property
     def pkx(self) -> Tuple[bytes, bytes]:
-        return (bytes(reversed(self.manager.ecc_key.x)), self.peer_public_key_x)
+        return (self.ecc_key.x[::-1], self.peer_public_key_x)
 
     @property
     def pka(self) -> bytes:
@@ -768,7 +844,10 @@
         return None
 
     def decide_pairing_method(
-        self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
+        self,
+        auth_req: int,
+        initiator_io_capability: int,
+        responder_io_capability: int,
     ) -> None:
         if self.connection.transport == BT_BR_EDR_TRANSPORT:
             self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
@@ -909,7 +988,7 @@
 
         command = SMP_Pairing_Request_Command(
             io_capability=self.io_capability,
-            oob_data_flag=0,
+            oob_data_flag=self.oob_data_flag,
             auth_req=self.auth_req,
             maximum_encryption_key_size=16,
             initiator_key_distribution=self.initiator_key_distribution,
@@ -921,7 +1000,7 @@
     def send_pairing_response_command(self) -> None:
         response = SMP_Pairing_Response_Command(
             io_capability=self.io_capability,
-            oob_data_flag=0,
+            oob_data_flag=self.oob_data_flag,
             auth_req=self.auth_req,
             maximum_encryption_key_size=16,
             initiator_key_distribution=self.initiator_key_distribution,
@@ -982,8 +1061,8 @@
     def send_public_key_command(self) -> None:
         self.send_command(
             SMP_Pairing_Public_Key_Command(
-                public_key_x=bytes(reversed(self.manager.ecc_key.x)),
-                public_key_y=bytes(reversed(self.manager.ecc_key.y)),
+                public_key_x=self.ecc_key.x[::-1],
+                public_key_y=self.ecc_key.y[::-1],
             )
         )
 
@@ -1011,7 +1090,7 @@
         # We can now encrypt the connection with the short term key, so that we can
         # distribute the long term and/or other keys over an encrypted connection
         self.manager.device.host.send_command_sync(
-            HCI_LE_Enable_Encryption_Command(  # type: ignore[call-arg]
+            HCI_LE_Enable_Encryption_Command(
                 connection_handle=self.connection.handle,
                 random_number=bytes(8),
                 encrypted_diversifier=0,
@@ -1019,18 +1098,56 @@
             )
         )
 
-    async def derive_ltk(self) -> None:
-        link_key = await self.manager.device.get_link_key(self.connection.peer_address)
-        assert link_key is not None
+    @classmethod
+    def derive_ltk(cls, link_key: bytes, ct2: bool) -> bytes:
+        '''Derives Long Term Key from Link Key.
+
+        Args:
+            link_key: BR/EDR Link Key bytes in little-endian.
+            ct2: whether ct2 is supported on both devices.
+        Returns:
+            LE Long Tern Key bytes in little-endian.
+        '''
         ilk = (
             crypto.h7(salt=SMP_CTKD_H7_BRLE_SALT, w=link_key)
-            if self.ct2
+            if ct2
             else crypto.h6(link_key, b'tmp2')
         )
-        self.ltk = crypto.h6(ilk, b'brle')
+        return crypto.h6(ilk, b'brle')
+
+    @classmethod
+    def derive_link_key(cls, ltk: bytes, ct2: bool) -> bytes:
+        '''Derives Link Key from Long Term Key.
+
+        Args:
+            ltk: LE Long Term Key bytes in little-endian.
+            ct2: whether ct2 is supported on both devices.
+        Returns:
+            BR/EDR Link Key bytes in little-endian.
+        '''
+        ilk = (
+            crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=ltk)
+            if ct2
+            else crypto.h6(ltk, b'tmp1')
+        )
+        return crypto.h6(ilk, b'lebr')
+
+    async def get_link_key_and_derive_ltk(self) -> None:
+        '''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
+        self.link_key = await self.manager.device.get_link_key(
+            self.connection.peer_address
+        )
+        if self.link_key is None:
+            logging.warning(
+                'Try to derive LTK but host does not have the LK. Send a SMP_PAIRING_FAILED but the procedure will not be paused!'
+            )
+            self.send_pairing_failed(
+                SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
+            )
+        else:
+            self.ltk = self.derive_ltk(self.link_key, self.ct2)
 
     def distribute_keys(self) -> None:
-
         # Distribute the keys as required
         if self.is_initiator:
             # CTKD: Derive LTK from LinkKey
@@ -1039,7 +1156,7 @@
                 and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
             ):
                 self.ctkd_task = self.connection.abort_on(
-                    'disconnection', self.derive_ltk()
+                    'disconnection', self.get_link_key_and_derive_ltk()
                 )
             elif not self.sc:
                 # Distribute the LTK, EDIV and RAND
@@ -1069,12 +1186,7 @@
 
             # CTKD, calculate BR/EDR link key
             if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
-                ilk = (
-                    crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
-                    if self.ct2
-                    else crypto.h6(self.ltk, b'tmp1')
-                )
-                self.link_key = crypto.h6(ilk, b'lebr')
+                self.link_key = self.derive_link_key(self.ltk, self.ct2)
 
         else:
             # CTKD: Derive LTK from LinkKey
@@ -1083,7 +1195,7 @@
                 and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
             ):
                 self.ctkd_task = self.connection.abort_on(
-                    'disconnection', self.derive_ltk()
+                    'disconnection', self.get_link_key_and_derive_ltk()
                 )
             # Distribute the LTK, EDIV and RAND
             elif not self.sc:
@@ -1113,12 +1225,7 @@
 
             # CTKD, calculate BR/EDR link key
             if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
-                ilk = (
-                    crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
-                    if self.ct2
-                    else crypto.h6(self.ltk, b'tmp1')
-                )
-                self.link_key = crypto.h6(ilk, b'lebr')
+                self.link_key = self.derive_link_key(self.ltk, self.ct2)
 
     def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
         # Set our expectations for what to wait for in the key distribution phase
@@ -1296,7 +1403,7 @@
             try:
                 handler(command)
             except Exception as error:
-                logger.warning(f'{color("!!! Exception in handler:", "red")} {error}')
+                logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
                 response = SMP_Pairing_Failed_Command(
                     reason=SMP_UNSPECIFIED_REASON_ERROR
                 )
@@ -1333,15 +1440,28 @@
         self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
         self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0)
 
-        # Check for OOB
-        if command.oob_data_flag != 0:
-            self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
-            return
+        # Infer the pairing method
+        if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
+            not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
+        ):
+            # Use OOB
+            self.pairing_method = PairingMethod.OOB
+            if not self.sc and self.tk is None:
+                # For legacy OOB, TK is required.
+                logger.warning("legacy OOB without TK")
+                self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
+                return
+            if command.oob_data_flag == 0:
+                # The peer doesn't have OOB data, use r=0
+                self.r = bytes(16)
+        else:
+            # Decide which pairing method to use from the IO capability
+            self.decide_pairing_method(
+                command.auth_req,
+                command.io_capability,
+                self.io_capability,
+            )
 
-        # Decide which pairing method to use
-        self.decide_pairing_method(
-            command.auth_req, command.io_capability, self.io_capability
-        )
         logger.debug(f'pairing method: {self.pairing_method.name}')
 
         # Key distribution
@@ -1390,15 +1510,26 @@
         self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
         self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
 
-        # Check for OOB
-        if self.sc and command.oob_data_flag:
-            self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
-            return
+        # Infer the pairing method
+        if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
+            not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
+        ):
+            # Use OOB
+            self.pairing_method = PairingMethod.OOB
+            if not self.sc and self.tk is None:
+                # For legacy OOB, TK is required.
+                logger.warning("legacy OOB without TK")
+                self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
+                return
+            if command.oob_data_flag == 0:
+                # The peer doesn't have OOB data, use r=0
+                self.r = bytes(16)
+        else:
+            # Decide which pairing method to use from the IO capability
+            self.decide_pairing_method(
+                command.auth_req, self.io_capability, command.io_capability
+            )
 
-        # Decide which pairing method to use
-        self.decide_pairing_method(
-            command.auth_req, self.io_capability, command.io_capability
-        )
         logger.debug(f'pairing method: {self.pairing_method.name}')
 
         # Key distribution
@@ -1549,12 +1680,13 @@
                 if self.passkey_step < 20:
                     self.send_pairing_confirm_command()
                     return
-            else:
+            elif self.pairing_method != PairingMethod.OOB:
                 return
         else:
             if self.pairing_method in (
                 PairingMethod.JUST_WORKS,
                 PairingMethod.NUMERIC_COMPARISON,
+                PairingMethod.OOB,
             ):
                 self.send_pairing_random_command()
             elif self.pairing_method == PairingMethod.PASSKEY:
@@ -1591,6 +1723,7 @@
         if self.pairing_method in (
             PairingMethod.JUST_WORKS,
             PairingMethod.NUMERIC_COMPARISON,
+            PairingMethod.OOB,
         ):
             ra = bytes(16)
             rb = ra
@@ -1599,7 +1732,6 @@
             ra = self.passkey.to_bytes(16, byteorder='little')
             rb = ra
         else:
-            # OOB not implemented yet
             return
 
         assert self.preq and self.pres
@@ -1651,18 +1783,33 @@
         self.peer_public_key_y = command.public_key_y
 
         # Compute the DH key
-        self.dh_key = bytes(
-            reversed(
-                self.manager.ecc_key.dh(
-                    bytes(reversed(command.public_key_x)),
-                    bytes(reversed(command.public_key_y)),
-                )
-            )
-        )
+        self.dh_key = self.ecc_key.dh(
+            command.public_key_x[::-1],
+            command.public_key_y[::-1],
+        )[::-1]
         logger.debug(f'DH key: {self.dh_key.hex()}')
 
+        if self.pairing_method == PairingMethod.OOB:
+            # Check against shared OOB data
+            if self.peer_oob_data:
+                confirm_verifier = crypto.f4(
+                    self.peer_public_key_x,
+                    self.peer_public_key_x,
+                    self.peer_oob_data.r,
+                    bytes(1),
+                )
+                if not self.check_expected_value(
+                    self.peer_oob_data.c,
+                    confirm_verifier,
+                    SMP_CONFIRM_VALUE_FAILED_ERROR,
+                ):
+                    return
+
         if self.is_initiator:
-            self.send_pairing_confirm_command()
+            if self.pairing_method == PairingMethod.OOB:
+                self.send_pairing_random_command()
+            else:
+                self.send_pairing_confirm_command()
         else:
             if self.pairing_method == PairingMethod.PASSKEY:
                 self.display_or_input_passkey()
@@ -1673,6 +1820,7 @@
             if self.pairing_method in (
                 PairingMethod.JUST_WORKS,
                 PairingMethod.NUMERIC_COMPARISON,
+                PairingMethod.OOB,
             ):
                 # We can now send the confirmation value
                 self.send_pairing_confirm_command()
@@ -1701,7 +1849,6 @@
             else:
                 self.send_pairing_dhkey_check_command()
         else:
-            assert self.ltk
             self.start_encryption(self.ltk)
 
     def on_smp_pairing_failed_command(
@@ -1751,6 +1898,7 @@
     sessions: Dict[int, Session]
     pairing_config_factory: Callable[[Connection], PairingConfig]
     session_proxy: Type[Session]
+    _ecc_key: Optional[crypto.EccKey]
 
     def __init__(
         self,
@@ -1845,10 +1993,8 @@
     ) -> None:
         # Store the keys in the key store
         if self.device.keystore and identity_address is not None:
-            self.device.abort_on(
-                'flush', self.device.update_keys(str(identity_address), keys)
-            )
-
+            # Make sure on_pairing emits after key update.
+            await self.device.update_keys(str(identity_address), keys)
         # Notify the device
         self.device.on_pairing(session.connection, identity_address, keys, session.sc)
 
diff --git a/bumble/transport/__init__.py b/bumble/transport/__init__.py
index bc0766b..6a9a6b5 100644
--- a/bumble/transport/__init__.py
+++ b/bumble/transport/__init__.py
@@ -18,6 +18,7 @@
 from contextlib import asynccontextmanager
 import logging
 import os
+from typing import Optional
 
 from .common import Transport, AsyncPipeSink, SnoopingTransport
 from ..snoop import create_snooper
@@ -52,8 +53,16 @@
 async def open_transport(name: str) -> Transport:
     """
     Open a transport by name.
-    The name must be <type>:<parameters>
-    Where <parameters> depend on the type (and may be empty for some types).
+    The name must be <type>:<metadata><parameters>
+    Where <parameters> depend on the type (and may be empty for some types), and
+    <metadata> is either omitted, or a ,-separated list of <key>=<value> pairs,
+    enclosed in [].
+    If there are not metadata or parameter, the : after the <type> may be omitted.
+    Examples:
+      * usb:0
+      * usb:[driver=rtk]0
+      * android-netsim
+
     The supported types are:
       * serial
       * udp
@@ -71,87 +80,105 @@
       * android-netsim
     """
 
-    return _wrap_transport(await _open_transport(name))
+    scheme, *tail = name.split(':', 1)
+    spec = tail[0] if tail else None
+    metadata = None
+    if spec:
+        # Metadata may precede the spec
+        if spec.startswith('['):
+            metadata_str, *tail = spec[1:].split(']')
+            spec = tail[0] if tail else None
+            metadata = dict([entry.split('=') for entry in metadata_str.split(',')])
+
+    transport = await _open_transport(scheme, spec)
+    if metadata:
+        transport.source.metadata = {  # type: ignore[attr-defined]
+            **metadata,
+            **getattr(transport.source, 'metadata', {}),
+        }
+        # pylint: disable=line-too-long
+        logger.debug(f'HCI metadata: {transport.source.metadata}')  # type: ignore[attr-defined]
+
+    return _wrap_transport(transport)
 
 
 # -----------------------------------------------------------------------------
-async def _open_transport(name: str) -> Transport:
+async def _open_transport(scheme: str, spec: Optional[str]) -> Transport:
     # pylint: disable=import-outside-toplevel
     # pylint: disable=too-many-return-statements
 
-    scheme, *spec = name.split(':', 1)
     if scheme == 'serial' and spec:
         from .serial import open_serial_transport
 
-        return await open_serial_transport(spec[0])
+        return await open_serial_transport(spec)
 
     if scheme == 'udp' and spec:
         from .udp import open_udp_transport
 
-        return await open_udp_transport(spec[0])
+        return await open_udp_transport(spec)
 
     if scheme == 'tcp-client' and spec:
         from .tcp_client import open_tcp_client_transport
 
-        return await open_tcp_client_transport(spec[0])
+        return await open_tcp_client_transport(spec)
 
     if scheme == 'tcp-server' and spec:
         from .tcp_server import open_tcp_server_transport
 
-        return await open_tcp_server_transport(spec[0])
+        return await open_tcp_server_transport(spec)
 
     if scheme == 'ws-client' and spec:
         from .ws_client import open_ws_client_transport
 
-        return await open_ws_client_transport(spec[0])
+        return await open_ws_client_transport(spec)
 
     if scheme == 'ws-server' and spec:
         from .ws_server import open_ws_server_transport
 
-        return await open_ws_server_transport(spec[0])
+        return await open_ws_server_transport(spec)
 
     if scheme == 'pty':
         from .pty import open_pty_transport
 
-        return await open_pty_transport(spec[0] if spec else None)
+        return await open_pty_transport(spec)
 
     if scheme == 'file':
         from .file import open_file_transport
 
         assert spec is not None
-        return await open_file_transport(spec[0])
+        return await open_file_transport(spec)
 
     if scheme == 'vhci':
         from .vhci import open_vhci_transport
 
-        return await open_vhci_transport(spec[0] if spec else None)
+        return await open_vhci_transport(spec)
 
     if scheme == 'hci-socket':
         from .hci_socket import open_hci_socket_transport
 
-        return await open_hci_socket_transport(spec[0] if spec else None)
+        return await open_hci_socket_transport(spec)
 
     if scheme == 'usb':
         from .usb import open_usb_transport
 
-        assert spec is not None
-        return await open_usb_transport(spec[0])
+        assert spec
+        return await open_usb_transport(spec)
 
     if scheme == 'pyusb':
         from .pyusb import open_pyusb_transport
 
-        assert spec is not None
-        return await open_pyusb_transport(spec[0])
+        assert spec
+        return await open_pyusb_transport(spec)
 
     if scheme == 'android-emulator':
         from .android_emulator import open_android_emulator_transport
 
-        return await open_android_emulator_transport(spec[0] if spec else None)
+        return await open_android_emulator_transport(spec)
 
     if scheme == 'android-netsim':
         from .android_netsim import open_android_netsim_transport
 
-        return await open_android_netsim_transport(spec[0] if spec else None)
+        return await open_android_netsim_transport(spec)
 
     raise ValueError('unknown transport scheme')
 
@@ -170,12 +197,13 @@
 
     """
     if name.startswith('link-relay:'):
+        logger.warning('Link Relay has been deprecated.')
         from ..controller import Controller
         from ..link import RemoteLink  # lazy import
 
         link = RemoteLink(name[11:])
         await link.wait_until_connected()
-        controller = Controller('remote', link=link)
+        controller = Controller('remote', link=link)  # type:ignore[arg-type]
 
         class LinkTransport(Transport):
             async def close(self):
diff --git a/bumble/transport/android_emulator.py b/bumble/transport/android_emulator.py
index 8d19a9e..9cd7ec2 100644
--- a/bumble/transport/android_emulator.py
+++ b/bumble/transport/android_emulator.py
@@ -69,7 +69,7 @@
     mode = 'host'
     server_host = 'localhost'
     server_port = '8554'
-    if spec is not None:
+    if spec:
         params = spec.split(',')
         for param in params:
             if param.startswith('mode='):
diff --git a/bumble/transport/common.py b/bumble/transport/common.py
index 2786a75..ef35c9f 100644
--- a/bumble/transport/common.py
+++ b/bumble/transport/common.py
@@ -21,7 +21,7 @@
 import asyncio
 import logging
 import io
-from typing import ContextManager, Tuple, Optional, Protocol, Dict
+from typing import Any, ContextManager, Tuple, Optional, Protocol, Dict
 
 from bumble import hci
 from bumble.colors import color
@@ -42,6 +42,7 @@
     hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
     hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
     hci.HCI_EVENT_PACKET: (1, 1, 'B'),
+    hci.HCI_ISO_DATA_PACKET: (2, 2, 'H'),
 }
 
 
@@ -150,7 +151,7 @@
                         try:
                             self.sink.on_packet(bytes(self.packet))
                         except Exception as error:
-                            logger.warning(
+                            logger.exception(
                                 color(f'!!! Exception in on_packet: {error}', 'red')
                             )
                     self.reset()
@@ -167,11 +168,13 @@
 
     def __init__(self, source: io.BufferedReader) -> None:
         self.source = source
+        self.at_end = False
 
     def next_packet(self) -> Optional[bytes]:
         # Get the packet type
         packet_type = self.source.read(1)
         if len(packet_type) != 1:
+            self.at_end = True
             return None
 
         # Get the packet info based on its type
diff --git a/bumble/transport/hci_socket.py b/bumble/transport/hci_socket.py
index df9e885..4125043 100644
--- a/bumble/transport/hci_socket.py
+++ b/bumble/transport/hci_socket.py
@@ -59,10 +59,7 @@
         ) from error
 
     # Compute the adapter index
-    if spec is None:
-        adapter_index = 0
-    else:
-        adapter_index = int(spec)
+    adapter_index = int(spec) if spec else 0
 
     # Bind the socket
     # NOTE: since Python doesn't support binding with the required address format (yet),
diff --git a/bumble/transport/pyusb.py b/bumble/transport/pyusb.py
index 5e686d1..61ce17e 100644
--- a/bumble/transport/pyusb.py
+++ b/bumble/transport/pyusb.py
@@ -113,9 +113,10 @@
             self.loop.call_soon_threadsafe(self.stop_event.set)
 
     class UsbPacketSource(asyncio.Protocol, ParserSource):
-        def __init__(self, device, sco_enabled):
+        def __init__(self, device, metadata, sco_enabled):
             super().__init__()
             self.device = device
+            self.metadata = metadata
             self.loop = asyncio.get_running_loop()
             self.queue = asyncio.Queue()
             self.dequeue_task = None
@@ -216,6 +217,15 @@
     if ':' in spec:
         vendor_id, product_id = spec.split(':')
         device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
+    elif '-' in spec:
+
+        def device_path(device):
+            if device.port_numbers:
+                return f'{device.bus}-{".".join(map(str, device.port_numbers))}'
+            else:
+                return str(device.bus)
+
+        device = usb_find(custom_match=lambda device: device_path(device) == spec)
     else:
         device_index = int(spec)
         devices = list(
@@ -235,6 +245,9 @@
         raise ValueError('device not found')
     logger.debug(f'USB Device: {device}')
 
+    # Collect the metadata
+    device_metadata = {'vendor_id': device.idVendor, 'product_id': device.idProduct}
+
     # Detach the kernel driver if needed
     if device.is_kernel_driver_active(0):
         logger.debug("detaching kernel driver")
@@ -289,7 +302,7 @@
     #     except usb.USBError:
     #         logger.warning('failed to set alternate setting')
 
-    packet_source = UsbPacketSource(device, sco_enabled)
+    packet_source = UsbPacketSource(device, device_metadata, sco_enabled)
     packet_sink = UsbPacketSink(device)
     packet_source.start()
     packet_sink.start()
diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py
index ccc82c1..6479016 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -24,9 +24,10 @@
 
 import usb1
 
-from .common import Transport, ParserSource
-from .. import hci
-from ..colors import color
+from bumble.transport.common import Transport, ParserSource
+from bumble import hci
+from bumble.colors import color
+from bumble.utils import AsyncRunner
 
 
 # -----------------------------------------------------------------------------
@@ -107,13 +108,13 @@
         USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER,
     )
 
-    READ_SIZE = 1024
+    READ_SIZE = 4096
 
     class UsbPacketSink:
         def __init__(self, device, acl_out):
             self.device = device
             self.acl_out = acl_out
-            self.transfer = device.getTransfer()
+            self.acl_out_transfer = device.getTransfer()
             self.packets = collections.deque()  # Queue of packets waiting to be sent
             self.loop = asyncio.get_running_loop()
             self.cancel_done = self.loop.create_future()
@@ -137,21 +138,20 @@
                 # The queue was previously empty, re-prime the pump
                 self.process_queue()
 
-        def on_packet_sent(self, transfer):
+        def transfer_callback(self, transfer):
             status = transfer.getStatus()
-            # logger.debug(f'<<< USB out transfer callback: status={status}')
 
             # pylint: disable=no-member
             if status == usb1.TRANSFER_COMPLETED:
-                self.loop.call_soon_threadsafe(self.on_packet_sent_)
+                self.loop.call_soon_threadsafe(self.on_packet_sent)
             elif status == usb1.TRANSFER_CANCELLED:
                 self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
             else:
                 logger.warning(
-                    color(f'!!! out transfer not completed: status={status}', 'red')
+                    color(f'!!! OUT transfer not completed: status={status}', 'red')
                 )
 
-        def on_packet_sent_(self):
+        def on_packet_sent(self):
             if self.packets:
                 self.packets.popleft()
                 self.process_queue()
@@ -163,22 +163,20 @@
             packet = self.packets[0]
             packet_type = packet[0]
             if packet_type == hci.HCI_ACL_DATA_PACKET:
-                self.transfer.setBulk(
-                    self.acl_out, packet[1:], callback=self.on_packet_sent
+                self.acl_out_transfer.setBulk(
+                    self.acl_out, packet[1:], callback=self.transfer_callback
                 )
-                logger.debug('submit ACL')
-                self.transfer.submit()
+                self.acl_out_transfer.submit()
             elif packet_type == hci.HCI_COMMAND_PACKET:
-                self.transfer.setControl(
+                self.acl_out_transfer.setControl(
                     USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
                     0,
                     0,
                     0,
                     packet[1:],
-                    callback=self.on_packet_sent,
+                    callback=self.transfer_callback,
                 )
-                logger.debug('submit COMMAND')
-                self.transfer.submit()
+                self.acl_out_transfer.submit()
             else:
                 logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
 
@@ -193,11 +191,11 @@
             self.packets.clear()
 
             # If we have a transfer in flight, cancel it
-            if self.transfer.isSubmitted():
+            if self.acl_out_transfer.isSubmitted():
                 # Try to cancel the transfer, but that may fail because it may have
                 # already completed
                 try:
-                    self.transfer.cancel()
+                    self.acl_out_transfer.cancel()
 
                     logger.debug('waiting for OUT transfer cancellation to be done...')
                     await self.cancel_done
@@ -206,27 +204,22 @@
                     logger.debug('OUT transfer likely already completed')
 
     class UsbPacketSource(asyncio.Protocol, ParserSource):
-        def __init__(self, context, device, metadata, acl_in, events_in):
+        def __init__(self, device, metadata, acl_in, events_in):
             super().__init__()
-            self.context = context
             self.device = device
             self.metadata = metadata
             self.acl_in = acl_in
+            self.acl_in_transfer = None
             self.events_in = events_in
+            self.events_in_transfer = None
             self.loop = asyncio.get_running_loop()
             self.queue = asyncio.Queue()
             self.dequeue_task = None
-            self.closed = False
-            self.event_loop_done = self.loop.create_future()
             self.cancel_done = {
                 hci.HCI_EVENT_PACKET: self.loop.create_future(),
                 hci.HCI_ACL_DATA_PACKET: self.loop.create_future(),
             }
-            self.events_in_transfer = None
-            self.acl_in_transfer = None
-
-            # Create a thread to process events
-            self.event_thread = threading.Thread(target=self.run)
+            self.closed = False
 
         def start(self):
             # Set up transfer objects for input
@@ -234,7 +227,7 @@
             self.events_in_transfer.setInterrupt(
                 self.events_in,
                 READ_SIZE,
-                callback=self.on_packet_received,
+                callback=self.transfer_callback,
                 user_data=hci.HCI_EVENT_PACKET,
             )
             self.events_in_transfer.submit()
@@ -243,22 +236,23 @@
             self.acl_in_transfer.setBulk(
                 self.acl_in,
                 READ_SIZE,
-                callback=self.on_packet_received,
+                callback=self.transfer_callback,
                 user_data=hci.HCI_ACL_DATA_PACKET,
             )
             self.acl_in_transfer.submit()
 
             self.dequeue_task = self.loop.create_task(self.dequeue())
-            self.event_thread.start()
 
-        def on_packet_received(self, transfer):
+        @property
+        def usb_transfer_submitted(self):
+            return (
+                self.events_in_transfer.isSubmitted()
+                or self.acl_in_transfer.isSubmitted()
+            )
+
+        def transfer_callback(self, transfer):
             packet_type = transfer.getUserData()
             status = transfer.getStatus()
-            # logger.debug(
-            #     f'<<< USB IN transfer callback: status={status} '
-            #     f'packet_type={packet_type} '
-            #     f'length={transfer.getActualLength()}'
-            # )
 
             # pylint: disable=no-member
             if status == usb1.TRANSFER_COMPLETED:
@@ -267,18 +261,18 @@
                     + transfer.getBuffer()[: transfer.getActualLength()]
                 )
                 self.loop.call_soon_threadsafe(self.queue.put_nowait, packet)
+
+                # Re-submit the transfer so we can receive more data
+                transfer.submit()
             elif status == usb1.TRANSFER_CANCELLED:
                 self.loop.call_soon_threadsafe(
                     self.cancel_done[packet_type].set_result, None
                 )
-                return
             else:
                 logger.warning(
-                    color(f'!!! transfer not completed: status={status}', 'red')
+                    color(f'!!! IN transfer not completed: status={status}', 'red')
                 )
-
-            # Re-submit the transfer so we can receive more data
-            transfer.submit()
+                self.loop.call_soon_threadsafe(self.on_transport_lost)
 
         async def dequeue(self):
             while not self.closed:
@@ -288,21 +282,6 @@
                     return
                 self.parser.feed_data(packet)
 
-        def run(self):
-            logger.debug('starting USB event loop')
-            while (
-                self.events_in_transfer.isSubmitted()
-                or self.acl_in_transfer.isSubmitted()
-            ):
-                # pylint: disable=no-member
-                try:
-                    self.context.handleEvents()
-                except usb1.USBErrorInterrupted:
-                    pass
-
-            logger.debug('USB event loop done')
-            self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
-
         def close(self):
             self.closed = True
 
@@ -331,15 +310,14 @@
                             f'IN[{packet_type}] transfer likely already completed'
                         )
 
-            # Wait for the thread to terminate
-            await self.event_loop_done
-
     class UsbTransport(Transport):
         def __init__(self, context, device, interface, setting, source, sink):
             super().__init__(source, sink)
             self.context = context
             self.device = device
             self.interface = interface
+            self.loop = asyncio.get_running_loop()
+            self.event_loop_done = self.loop.create_future()
 
             # Get exclusive access
             device.claimInterface(interface)
@@ -352,6 +330,22 @@
             source.start()
             sink.start()
 
+            # Create a thread to process events
+            self.event_thread = threading.Thread(target=self.run)
+            self.event_thread.start()
+
+        def run(self):
+            logger.debug('starting USB event loop')
+            while self.source.usb_transfer_submitted:
+                # pylint: disable=no-member
+                try:
+                    self.context.handleEvents()
+                except usb1.USBErrorInterrupted:
+                    pass
+
+            logger.debug('USB event loop done')
+            self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
+
         async def close(self):
             self.source.close()
             self.sink.close()
@@ -361,6 +355,9 @@
             self.device.close()
             self.context.close()
 
+            # Wait for the thread to terminate
+            await self.event_loop_done
+
     # Find the device according to the spec moniker
     load_libusb()
     context = usb1.USBContext()
@@ -399,6 +396,16 @@
                         break
                     device_index -= 1
                 device.close()
+        elif '-' in spec:
+
+            def device_path(device):
+                return f'{device.getBusNumber()}-{".".join(map(str, device.getPortNumberList()))}'
+
+            for device in context.getDeviceIterator(skip_on_error=True):
+                if device_path(device) == spec:
+                    found = device
+                    break
+                device.close()
         else:
             # Look for a compatible device by index
             def device_is_bluetooth_hci(device):
@@ -540,7 +547,7 @@
             except usb1.USBError:
                 logger.warning('failed to set configuration')
 
-        source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
+        source = UsbPacketSource(device, device_metadata, acl_in, events_in)
         sink = UsbPacketSink(device, acl_out)
         return UsbTransport(context, device, interface, setting, source, sink)
     except usb1.USBError as error:
diff --git a/bumble/utils.py b/bumble/utils.py
index a562618..e6aae4d 100644
--- a/bumble/utils.py
+++ b/bumble/utils.py
@@ -17,9 +17,10 @@
 # -----------------------------------------------------------------------------
 from __future__ import annotations
 import asyncio
-import logging
-import traceback
 import collections
+import enum
+import functools
+import logging
 import sys
 import warnings
 from typing import (
@@ -34,7 +35,7 @@
     Union,
     overload,
 )
-from functools import wraps, partial
+
 from pyee import EventEmitter
 
 from .colors import color
@@ -131,13 +132,14 @@
         Args:
             emitter: EventEmitter to watch
             event: Event name
-            handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
+            handler: (Optional) Event handler. When nothing is passed, this method
+            works as a decorator.
         '''
 
-        def wrapper(f: _Handler) -> _Handler:
-            self.handlers.append((emitter, event, f))
-            emitter.on(event, f)
-            return f
+        def wrapper(wrapped: _Handler) -> _Handler:
+            self.handlers.append((emitter, event, wrapped))
+            emitter.on(event, wrapped)
+            return wrapped
 
         return wrapper if handler is None else wrapper(handler)
 
@@ -157,13 +159,14 @@
         Args:
             emitter: EventEmitter to watch
             event: Event name
-            handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
+            handler: (Optional) Event handler. When nothing passed, this method works
+            as a decorator.
         '''
 
-        def wrapper(f: _Handler) -> _Handler:
-            self.handlers.append((emitter, event, f))
-            emitter.once(event, f)
-            return f
+        def wrapper(wrapped: _Handler) -> _Handler:
+            self.handlers.append((emitter, event, wrapped))
+            emitter.once(event, wrapped)
+            return wrapped
 
         return wrapper if handler is None else wrapper(handler)
 
@@ -223,13 +226,13 @@
         if self._listener:
             # Call the deregistration methods for each base class that has them
             for cls in self._listener.__class__.mro():
-                if hasattr(cls, '_bumble_register_composite'):
-                    cls._bumble_deregister_composite(listener, self)
+                if '_bumble_register_composite' in cls.__dict__:
+                    cls._bumble_deregister_composite(self._listener, self)
         self._listener = listener
         if listener:
             # Call the registration methods for each base class that has them
             for cls in listener.__class__.mro():
-                if hasattr(cls, '_bumble_deregister_composite'):
+                if '_bumble_deregister_composite' in cls.__dict__:
                     cls._bumble_register_composite(listener, self)
 
 
@@ -276,21 +279,18 @@
         """
 
         def decorator(func):
-            @wraps(func)
+            @functools.wraps(func)
             def wrapper(*args, **kwargs):
                 coroutine = func(*args, **kwargs)
                 if queue is None:
-                    # Create a task to run the coroutine
+                    # Spawn the coroutine as a task
                     async def run():
                         try:
                             await coroutine
                         except Exception:
-                            logger.warning(
-                                f'{color("!!! Exception in wrapper:", "red")} '
-                                f'{traceback.format_exc()}'
-                            )
+                            logger.exception(color("!!! Exception in wrapper:", "red"))
 
-                    asyncio.create_task(run())
+                    AsyncRunner.spawn(run())
                 else:
                     # Queue the coroutine to be awaited by the work queue
                     queue.enqueue(coroutine)
@@ -413,30 +413,35 @@
             self.check_pump()
 
 
+# -----------------------------------------------------------------------------
 async def async_call(function, *args, **kwargs):
     """
-    Immediately calls the function with provided args and kwargs, wrapping it in an async function.
-    Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject a running loop.
+    Immediately calls the function with provided args and kwargs, wrapping it in an
+    async function.
+    Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject
+    a running loop.
 
     result = await async_call(some_function, ...)
     """
     return function(*args, **kwargs)
 
 
+# -----------------------------------------------------------------------------
 def wrap_async(function):
     """
     Wraps the provided function in an async function.
     """
-    return partial(async_call, function)
+    return functools.partial(async_call, function)
 
 
+# -----------------------------------------------------------------------------
 def deprecated(msg: str):
     """
-    Throw deprecation warning before execution
+    Throw deprecation warning before execution.
     """
 
     def wrapper(function):
-        @wraps(function)
+        @functools.wraps(function)
         def inner(*args, **kwargs):
             warnings.warn(msg, DeprecationWarning)
             return function(*args, **kwargs)
@@ -444,3 +449,39 @@
         return inner
 
     return wrapper
+
+
+# -----------------------------------------------------------------------------
+def experimental(msg: str):
+    """
+    Throws a future warning before execution.
+    """
+
+    def wrapper(function):
+        @functools.wraps(function)
+        def inner(*args, **kwargs):
+            warnings.warn(msg, FutureWarning)
+            return function(*args, **kwargs)
+
+        return inner
+
+    return wrapper
+
+
+# -----------------------------------------------------------------------------
+class OpenIntEnum(enum.IntEnum):
+    """
+    Subclass of enum.IntEnum that can hold integer values outside the set of
+    predefined values. This is convenient for implementing protocols where some
+    integer constants may be added over time.
+    """
+
+    @classmethod
+    def _missing_(cls, value):
+        if not isinstance(value, int):
+            return None
+
+        obj = int.__new__(cls, value)
+        obj._value_ = value
+        obj._name_ = f"{cls.__name__}[{value}]"
+        return obj
diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml
index 67bdd7c..6590d12 100644
--- a/docs/mkdocs/mkdocs.yml
+++ b/docs/mkdocs/mkdocs.yml
@@ -10,7 +10,7 @@
     - Contributing: development/contributing.md
     - Code Style: development/code_style.md
   - Use Cases:
-    - Overview: use_cases/index.md
+    - use_cases/index.md
     - Use Case 1: use_cases/use_case_1.md
     - Use Case 2: use_cases/use_case_2.md
     - Use Case 3: use_cases/use_case_3.md
@@ -23,7 +23,7 @@
     - GATT: components/gatt.md
     - Security Manager: components/security_manager.md
   - Transports:
-    - Overview: transports/index.md
+    - transports/index.md
     - Serial: transports/serial.md
     - USB: transports/usb.md
     - PTY: transports/pty.md
@@ -37,14 +37,14 @@
     - Android Emulator: transports/android_emulator.md
     - File: transports/file.md
   - Drivers:
-    - Overview: drivers/index.md
+    - drivers/index.md
     - Realtek: drivers/realtek.md
   - API:
     - Guide: api/guide.md
     - Examples: api/examples.md
     - Reference: api/reference.md
   - Apps & Tools:
-    - Overview: apps_and_tools/index.md
+    - apps_and_tools/index.md
     - Console: apps_and_tools/console.md
     - Bench: apps_and_tools/bench.md
     - Speaker: apps_and_tools/speaker.md
@@ -57,19 +57,25 @@
     - USB Probe: apps_and_tools/usb_probe.md
     - Link Relay: apps_and_tools/link_relay.md
   - Hardware:
-    - Overview: hardware/index.md
+    - hardware/index.md
   - Platforms:
-    - Overview: platforms/index.md
+    - platforms/index.md
     - macOS: platforms/macos.md
     - Linux: platforms/linux.md
     - Windows: platforms/windows.md
     - Android: platforms/android.md
     - Zephyr: platforms/zephyr.md
   - Examples:
-    - Overview: examples/index.md
+    - examples/index.md
   - Extras:
-    - Overview: extras/index.md
+    - extras/index.md
     - Android Remote HCI: extras/android_remote_hci.md
+    - Android BT Bench: extras/android_bt_bench.md
+  - Hive:
+    - hive/index.md
+    - Speaker: hive/web/speaker/speaker.html
+    - Scanner: hive/web/scanner/scanner.html
+    - Heart Rate Monitor: hive/web/heart_rate_monitor/heart_rate_monitor.html
 
 copyright: Copyright 2021-2023 Google LLC
 
@@ -78,6 +84,8 @@
   logo: 'images/logo.png'
   favicon: 'images/favicon.ico'
   custom_dir: 'theme'
+  features:
+    - navigation.indexes
 
 plugins:
   - mkdocstrings:
@@ -102,6 +110,8 @@
   - pymdownx.emoji:
       emoji_index: !!python/name:materialx.emoji.twemoji
       emoji_generator: !!python/name:materialx.emoji.to_svg
+  - pymdownx.tabbed:
+      alternate_style: true
   - codehilite:
       guess_lang: false
   - toc:
diff --git a/docs/mkdocs/src/apps_and_tools/bench.md b/docs/mkdocs/src/apps_and_tools/bench.md
index db785d6..be68161 100644
--- a/docs/mkdocs/src/apps_and_tools/bench.md
+++ b/docs/mkdocs/src/apps_and_tools/bench.md
@@ -7,16 +7,36 @@
 # General Usage
 
 ```
-Usage: bench.py [OPTIONS] COMMAND [ARGS]...
+Usage: bumble-bench [OPTIONS] COMMAND [ARGS]...
 
 Options:
   --device-config FILENAME        Device configuration file
   --role [sender|receiver|ping|pong]
   --mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
   --att-mtu MTU                   GATT MTU (gatt-client mode)  [23<=x<=517]
-  -s, --packet-size SIZE          Packet size (server role)  [8<=x<=4096]
-  -c, --packet-count COUNT        Packet count (server role)
-  -sd, --start-delay SECONDS      Start delay (server role)
+  --extended-data-length TEXT     Request a data length upon connection,
+                                  specified as tx_octets/tx_time
+  --rfcomm-channel INTEGER        RFComm channel to use
+  --rfcomm-uuid TEXT              RFComm service UUID to use (ignored if
+                                  --rfcomm-channel is not 0)
+  --l2cap-psm INTEGER             L2CAP PSM to use
+  --l2cap-mtu INTEGER             L2CAP MTU to use
+  --l2cap-mps INTEGER             L2CAP MPS to use
+  --l2cap-max-credits INTEGER     L2CAP maximum number of credits allowed for
+                                  the peer
+  -s, --packet-size SIZE          Packet size (client or ping role)
+                                  [8<=x<=4096]
+  -c, --packet-count COUNT        Packet count (client or ping role)
+  -sd, --start-delay SECONDS      Start delay (client or ping role)
+  --repeat N                      Repeat the run N times (client and ping
+                                  roles)(0, which is the fault, to run just
+                                  once)
+  --repeat-delay SECONDS          Delay, in seconds, between repeats
+  --pace MILLISECONDS             Wait N milliseconds between packets (0,
+                                  which is the fault, to send as fast as
+                                  possible)
+  --linger                        Don't exit at the end of a run (server and
+                                  pong roles)
   --help                          Show this message and exit.
 
 Commands:
@@ -35,17 +55,18 @@
   --connection-interval, --ci CONNECTION_INTERVAL
                                   Connection interval (in ms)
   --phy [1m|2m|coded]             PHY to use
+  --authenticate                  Authenticate (RFComm only)
+  --encrypt                       Encrypt the connection (RFComm only)
   --help                          Show this message and exit.
 ```
 
-
-To test once device against another, one of the two devices must be running 
+To test once device against another, one of the two devices must be running
 the ``peripheral`` command and the other the ``central`` command. The device
 running the ``peripheral`` command will accept connections from the device
 running the ``central`` command.
 When using Bluetooth LE (all modes except for ``rfcomm-server`` and ``rfcomm-client``utils),
-the default addresses configured in the tool should be sufficient. But when using 
-Bluetooth Classic, the address of the Peripheral must be specified on the Central 
+the default addresses configured in the tool should be sufficient. But when using
+Bluetooth Classic, the address of the Peripheral must be specified on the Central
 using the ``--peripheral`` option. The address will be printed by the Peripheral when
 it starts.
 
@@ -83,7 +104,7 @@
     $ bumble-bench central usb:1
     ```
 
-    In this default configuration, the Central runs a Sender, as a GATT client, 
+    In this default configuration, the Central runs a Sender, as a GATT client,
     connecting to the Peripheral running a Receiver, as a GATT server.
 
 !!! example "L2CAP Throughput"
diff --git a/docs/mkdocs/src/apps_and_tools/hci_bridge.md b/docs/mkdocs/src/apps_and_tools/hci_bridge.md
index d0ea1fc..9b39c94 100644
--- a/docs/mkdocs/src/apps_and_tools/hci_bridge.md
+++ b/docs/mkdocs/src/apps_and_tools/hci_bridge.md
@@ -12,12 +12,25 @@
     ```
     python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
     ```
+    The command-short-circuit-list field is specified by a series of comma separated Opcode Group 
+    Field (OGF) : OpCode Command Field (OCF) pairs. The OGF/OCF values are specified in the Blutooth
+    core specification.
+
+    For the commands that are listed in the short-circuit-list, the HCI bridge will always generate
+    a Command Complete Event for the specified op code. The return parameter will be HCI_SUCCESS.
+
+    This feature can only be used for commands that return Command Complete. Other events will not be
+    generated by the HCI bridge tool.
 
 !!! example "UDP to Serial"
     ```
     python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
     ```
 
+    In this example, the short circuit list is specified to respond to the Vendor-specific Opcode Group 
+    Field (0x3f) commands 0x70, 0x74, 0x77, 0x78 with Command Complete. The short circuit list can be
+    used where the Host uses some HCI commands that are not supported/implemented by the Controller.
+
 !!! example "PTY to Link Relay"
     ```
     python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
@@ -28,3 +41,4 @@
     (through which the communication with other virtual controllers will be mediated).
 
     NOTE: this assumes you're running a Link Relay on port `10723`.
+
diff --git a/docs/mkdocs/src/drivers/index.md b/docs/mkdocs/src/drivers/index.md
index a904e00..aa5f0a1 100644
--- a/docs/mkdocs/src/drivers/index.md
+++ b/docs/mkdocs/src/drivers/index.md
@@ -5,6 +5,15 @@
 This may include, for instance, loading a Firmware image or patch,
 loading a configuration.
 
+By default, drivers will be automatically probed to determine if they should be
+used with particular HCI controller.
+When the transport for an HCI controller is instantiated from a transport name,
+a driver may also be forced by specifying ``driver=<driver-name>`` in the optional
+metadata portion of the transport name. For example,
+``usb:[driver=rtk]0`` indicates that the ``rtk`` driver should be used with the
+first USB device, even if a normal probe would not have selected it based on the
+USB vendor ID and product ID.
+
 Drivers included in the module are:
 
   * [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.
\ No newline at end of file
diff --git a/docs/mkdocs/src/drivers/realtek.md b/docs/mkdocs/src/drivers/realtek.md
index acbce49..599ce04 100644
--- a/docs/mkdocs/src/drivers/realtek.md
+++ b/docs/mkdocs/src/drivers/realtek.md
@@ -1,13 +1,16 @@
 REALTEK DRIVER
 ==============
 
-This driver supports loading firmware images and optional config data to 
+This driver supports loading firmware images and optional config data to
 USB dongles with a Realtek chipset.
 A number of USB dongles are supported, but likely not all.
-When using a USB dongle, the USB product ID and manufacturer ID are used
+When using a USB dongle, the USB product ID and vendor ID are used
 to find whether a matching set of firmware image and config data
 is needed for that specific model. If a match exists, the driver will try
 load the firmware image and, if needed, config data.
+Alternatively, the metadata property ``driver=rtk`` may be specified in a transport
+name to force that driver to be used (ex: ``usb:[driver=rtk]0`` instead of just
+``usb:0`` for the first USB device).
 The driver will look for those files by name, in order, in:
 
   * The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
diff --git a/docs/mkdocs/src/extras/android_bt_bench.md b/docs/mkdocs/src/extras/android_bt_bench.md
new file mode 100644
index 0000000..2417e00
--- /dev/null
+++ b/docs/mkdocs/src/extras/android_bt_bench.md
@@ -0,0 +1,64 @@
+ANDROID BENCH APP
+=================
+
+This Android app that is compatible with the Bumble `bench` command line app.
+This app can be used to test the throughput and latency between two Android
+devices, or between an Android device and another device running the Bumble
+`bench` app.
+Only the RFComm Client, RFComm Server, L2CAP Client and L2CAP Server modes are
+supported.
+
+Building
+--------
+
+You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `BtBench` top level directory.
+You can also build with Android Studio: open the `BtBench` project. You can build and/or debug from there.
+
+If the build succeeds, you can find the app APKs (debug and release) at:
+
+  * [Release] ``app/build/outputs/apk/release/app-release-unsigned.apk``
+  * [Debug] ``app/build/outputs/apk/debug/app-debug.apk``
+
+
+Running
+-------
+
+### Starting the app
+You can start the app from the Android launcher, from Android Studio, or with `adb`
+
+#### Launching from the launcher
+Just tap the app icon on the launcher, check the parameters, and tap
+one of the benchmark action buttons.
+
+#### Launching with `adb`
+Using the `am` command, you can start the activity, and pass it arguments so that you can
+automatically start the benchmark test, and/or set the parameters.
+
+| Parameter Name         | Parameter Type | Description
+|------------------------|----------------|------------
+| autostart              | String         | Benchmark to start. (rfcomm-client, rfcomm-server, l2cap-client or l2cap-server)
+| packet-count           | Integer        | Number of packets to send (rfcomm-client and l2cap-client only)
+| packet-size            | Integer        | Number of bytes per packet (rfcomm-client and l2cap-client only)
+| peer-bluetooth-address | Integer        | Peer Bluetooth address to connect to (rfcomm-client and l2cap-client | only)
+
+
+!!! tip "Launching from adb with auto-start"
+    In this example, we auto-start the Rfcomm Server bench action.
+    ```bash
+    $ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-server
+    ```
+
+!!! tip "Launching from adb with auto-start and some parameters"
+    In this example, we auto-start the Rfcomm Client bench action, set the packet count to 100,
+    and the packet size to 1024, and connect to DA:4C:10:DE:17:02
+    ```bash
+    $ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-client --ei packet-count 100 --ei packet-size 1024 --es peer-bluetooth-address DA:4C:10:DE:17:02
+    ```
+
+#### Selecting a Peer Bluetooth Address
+The app's main activity has a "Peer Bluetooth Address" setting where you can change the address.
+
+!!! note "Bluetooth Address for L2CAP vs RFComm"
+    For BLE (L2CAP mode), the address of a device typically changes regularly (it is randomized for privacy), whereas the Bluetooth Classic addresses will remain the same (RFComm mode).
+    If two devices are paired and bonded, then they will each "see" a non-changing address for each other even with BLE (Resolvable Private Address)
+
diff --git a/docs/mkdocs/src/extras/android_remote_hci.md b/docs/mkdocs/src/extras/android_remote_hci.md
index 4eab132..735c31b 100644
--- a/docs/mkdocs/src/extras/android_remote_hci.md
+++ b/docs/mkdocs/src/extras/android_remote_hci.md
@@ -1,19 +1,19 @@
 ANDROID REMOTE HCI APP
 ======================
 
-This application allows using an android phone's built-in Bluetooth controller with 
+This application allows using an android phone's built-in Bluetooth controller with
 a Bumble host stack running outside the phone (typically a development laptop or desktop).
 The app runs an HCI proxy between a TCP socket on the "outside" and the Bluetooth HCI HAL
-on the "inside". (See [this page](https://source.android.com/docs/core/connect/bluetooth) for a high level 
+on the "inside". (See [this page](https://source.android.com/docs/core/connect/bluetooth) for a high level
 description of the Android Bluetooth HCI HAL).
-The HCI packets received on the TCP socket are forwarded to the phone's controller, and the 
+The HCI packets received on the TCP socket are forwarded to the phone's controller, and the
 packets coming from the controller are forwarded to the TCP socket.
 
 
 Building
 --------
 
-You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `RemoteHCI` top level directory.
+You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `extras/android/RemoteHCI` top level directory.
 You can also build with Android Studio: open the `RemoteHCI` project. You can build and/or debug from there.
 
 If the build succeeds, you can find the app APKs (debug and release) at:
@@ -25,9 +25,23 @@
 Running
 -------
 
+!!! note
+    In the following examples, it is assumed that shell commands are executed while in the
+    app's root directory, `extras/android/RemoteHCI`. If you are in a different directory,
+    adjust the relative paths accordingly.
+
 ### Preconditions
-When the proxy starts (tapping the "Start" button in the app's main activity), it will try to 
-bind to the Bluetooth HAL. This requires disabling SELinux temporarily, and being the only HAL client.
+When the proxy starts (tapping the "Start" button in the app's main activity, or running the proxy
+from an `adb shell` command line), it will try to bind to the Bluetooth HAL.
+This requires that there is no other HAL client, and requires certain privileges.
+For running as a regular app, this requires disabling SELinux temporarily.
+For running as a command-line executable, this just requires a root shell.
+
+#### Root Shell
+!!! tip "Restart `adb` as root"
+    ```bash
+    $ adb root
+    ```
 
 #### Disabling SELinux
 Binding to the Bluetooth HCI HAL requires certain SELinux permissions that can't simply be changed
@@ -56,8 +70,8 @@
     This state will also reset to the normal SELinux enforcement when you reboot.
 
 #### Stopping the bluetooth process
-Since the Bluetooth HAL service can only accept one client, and that in normal conditions 
-that client is the Android's bluetooth stack, it is required to first shut down the 
+Since the Bluetooth HAL service can only accept one client, and that in normal conditions
+that client is the Android's bluetooth stack, it is required to first shut down the
 Android bluetooth stack process.
 
 !!! tip "Checking if the Bluetooth process is running"
@@ -79,7 +93,33 @@
     $ adb shell cmd bluetooth_manager disable
     ```
 
-### Starting the app
+### Running as a command line app
+
+You push the built APK to a temporary location on the phone's filesystem, then launch the command
+line executable with an `adb shell` command.
+
+!!! tip "Pushing the executable"
+    ```bash
+    $ adb push app/build/outputs/apk/release/app-release-unsigned.apk /data/local/tmp/remotehci.apk
+    ```
+    Do this every time you rebuild. Alternatively, you can push the `debug` APK instead:
+    ```bash
+    $ adb push app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/remotehci.apk
+    ```
+
+!!! tip "Start the proxy from the command line"
+    ```bash
+    adb shell "CLASSPATH=/data/local/tmp/remotehci.apk app_process /system/bin com.github.google.bumble.remotehci.CommandLineInterface"
+    ```
+    This will run the proxy, listening on the default TCP port.
+    If you want a different port, pass it as a command line parameter
+
+!!! tip "Start the proxy from the command line with a specific TCP port"
+    ```bash
+    adb shell "CLASSPATH=/data/local/tmp/remotehci.apk app_process /system/bin com.github.google.bumble.remotehci.CommandLineInterface 12345"
+    ```
+
+### Running as a normal app
 You can start the app from the Android launcher, from Android Studio, or with `adb`
 
 #### Launching from the launcher
@@ -103,11 +143,11 @@
 
 #### Selecting a TCP port
 The RemoteHCI app's main activity has a "TCP Port" setting where you can change the port on
-which the proxy is accepting connections. If the default value isn't suitable, you can 
+which the proxy is accepting connections. If the default value isn't suitable, you can
 change it there (you can also use the special value 0 to let the OS assign a port number for you).
 
 ### Connecting to the proxy
-To connect the Bumble stack to the proxy, you need to be able to reach the phone's network 
+To connect the Bumble stack to the proxy, you need to be able to reach the phone's network
 stack. This can be done over the phone's WiFi connection, or, alternatively, using an `adb`
 TCP forward (which should be faster than over WiFi).
 
@@ -116,7 +156,7 @@
     ```bash
     $ adb forward tcp:<outside-port> tcp:<inside-port>
     ```
-    Where ``<outside-port>`` is the port number for a listening socket on your laptop or 
+    Where ``<outside-port>`` is the port number for a listening socket on your laptop or
     desktop machine, and <inside-port> is the TCP port selected in the app's user interface.
     Those two ports may be the same, of course.
     For example, with the default TCP port 9993:
@@ -125,7 +165,7 @@
     ```
 
 Once you've ensured that you can reach the proxy's TCP port on the phone, either directly or
-via an `adb` forward, you can then use it as a Bumble transport, using the transport name: 
+via an `adb` forward, you can then use it as a Bumble transport, using the transport name:
 ``tcp-client:<host>:<port>`` syntax.
 
 !!! example "Connecting a Bumble client"
diff --git a/docs/mkdocs/src/extras/index.md b/docs/mkdocs/src/extras/index.md
index ae906c1..59af838 100644
--- a/docs/mkdocs/src/extras/index.md
+++ b/docs/mkdocs/src/extras/index.md
@@ -8,4 +8,12 @@
 
 Allows using an Android phone's built-in Bluetooth controller with a Bumble
 stack running on a development machine.
-See [Android Remote HCI](android_remote_hci.md) for details.
\ No newline at end of file
+See [Android Remote HCI](android_remote_hci.md) for details.
+
+Android BT Bench
+----------------
+
+An Android app that is compatible with the Bumble `bench` command line app.
+This app can be used to test the throughput and latency between two Android
+devices, or between an Android device and another device running the Bumble
+`bench` app.
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/index.md b/docs/mkdocs/src/hive/index.md
new file mode 100644
index 0000000..0b6ca2c
--- /dev/null
+++ b/docs/mkdocs/src/hive/index.md
@@ -0,0 +1,59 @@
+HIVE
+====
+
+Welcome to the Bumble Hive.
+This is a collection of apps and virtual devices that can run entirely in a browser page.
+The code for the apps and devices, as well as the Bumble runtime code, runs via [Pyodide](https://pyodide.org/).
+Pyodide is a Python distribution for the browser and Node.js based on WebAssembly.
+
+The Bumble stack uses a WebSocket to exchange HCI packets with a virtual or physical
+Bluetooth controller.
+
+The apps and devices in the hive can be accessed by following the links below. Each
+page has a settings button that may be used to configure the WebSocket URL to use for
+the virtual HCI connection. This will typically be the WebSocket URL for a `netsim`
+daemon.
+There is also a [TOML index](index.toml) that can be used by tools to know at which URL to access
+each of the apps and devices, as well as their names and short descriptions.
+
+!!! tip "Using `netsim`"
+    When the `netsimd` daemon is running (for example when using the Android Emulator that
+    is included in Android Studio), the daemon listens for connections on a TCP port.
+    To find out what this TCP port is, you can read the `netsim.ini` file that `netsimd`
+    creates, it includes a line with `web.port=<tcp-port>` (for example `web.port=7681`).
+    The location of the `netsim.ini` file is platform-specific.
+
+    === "macOS"
+        On macOS, the directory where `netsim.ini` is stored is $TMPDIR
+        ```bash
+            $ cat $TMPDIR/netsim.ini
+        ```
+
+    === "Linux"
+        On Linux, the directory where `netsim.ini` is stored is $XDG_RUNTIME_DIR
+        ```bash
+            $ cat $XDG_RUNTIME_DIR/netsim.ini
+        ```
+
+
+!!! tip "Using a local radio"
+    You can connect the hive virtual apps and devices to a local Bluetooth radio, like,
+    for example, a USB dongle.
+    For that, you need to run a local HCI bridge to bridge a local HCI device to a WebSocket
+    that a web page can connect to.
+    Use the `bumble-hci-bridge` app, with the host transport set to a WebSocket server on an
+    available port (ex: `ws-server:_:7682`) and the controller transport set to the transport
+    name for the radio you want to use (ex: `usb:0` for the first USB dongle)
+
+
+Applications
+------------
+
+  * [Scanner](web/scanner/scanner.html) - Scans for BLE devices.
+
+Virtual Devices
+---------------
+
+  * [Speaker](web/speaker/speaker.html) - Virtual speaker that plays audio in a browser page.
+  * [Heart Rate Monitor](web/heart_rate_monitor/heart_rate_monitor.html) - Virtual heart rate monitor.
+
diff --git a/docs/mkdocs/src/hive/index.toml b/docs/mkdocs/src/hive/index.toml
new file mode 100644
index 0000000..5b187e3
--- /dev/null
+++ b/docs/mkdocs/src/hive/index.toml
@@ -0,0 +1,21 @@
+version = "1.0.0"
+base_url = "https://google.github.io/bumble/hive/web"
+default_hci_query_param = "hci"
+
+[[index]]
+name = "speaker"
+description = "Bumble Virtual Speaker"
+type = "Device"
+url = "speaker/speaker.html"
+
+[[index]]
+name = "scanner"
+description = "Simple Scanner Application"
+type = "Application"
+url = "scanner/scanner.html"
+
+[[index]]
+name = "heart-rate-monitor"
+description = "Virtual Heart Rate Monitor"
+type = "Device"
+url = "heart_rate_monitor/heart_rate_monitor.html"
diff --git a/docs/mkdocs/src/hive/web/bumble.js b/docs/mkdocs/src/hive/web/bumble.js
new file mode 120000
index 0000000..237a974
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/bumble.js
@@ -0,0 +1 @@
+../../../../../web/bumble.js
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html
new file mode 120000
index 0000000..ef6e18a
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html
@@ -0,0 +1 @@
+../../../../../../web/heart_rate_monitor/heart_rate_monitor.html
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js
new file mode 120000
index 0000000..1d1dc8b
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js
@@ -0,0 +1 @@
+../../../../../../web/heart_rate_monitor/heart_rate_monitor.js
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py
new file mode 120000
index 0000000..cb0f459
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py
@@ -0,0 +1 @@
+../../../../../../web/heart_rate_monitor/heart_rate_monitor.py
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.css b/docs/mkdocs/src/hive/web/scanner/scanner.css
new file mode 120000
index 0000000..acc0f9e
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/scanner/scanner.css
@@ -0,0 +1 @@
+../../../../../../web/scanner/scanner.css
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.html b/docs/mkdocs/src/hive/web/scanner/scanner.html
new file mode 120000
index 0000000..fda63fc
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/scanner/scanner.html
@@ -0,0 +1 @@
+../../../../../../web/scanner/scanner.html
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.js b/docs/mkdocs/src/hive/web/scanner/scanner.js
new file mode 120000
index 0000000..4d270f4
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/scanner/scanner.js
@@ -0,0 +1 @@
+../../../../../../web/scanner/scanner.js
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.py b/docs/mkdocs/src/hive/web/scanner/scanner.py
new file mode 120000
index 0000000..4ae502a
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/scanner/scanner.py
@@ -0,0 +1 @@
+../../../../../../web/scanner/scanner.py
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/logo.svg b/docs/mkdocs/src/hive/web/speaker/logo.svg
new file mode 120000
index 0000000..0da5d27
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/logo.svg
@@ -0,0 +1 @@
+../../../../../../web/speaker/logo.svg
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.css b/docs/mkdocs/src/hive/web/speaker/speaker.css
new file mode 120000
index 0000000..046d971
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/speaker.css
@@ -0,0 +1 @@
+../../../../../../web/speaker/speaker.css
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.html b/docs/mkdocs/src/hive/web/speaker/speaker.html
new file mode 120000
index 0000000..9acaa1d
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/speaker.html
@@ -0,0 +1 @@
+../../../../../../web/speaker/speaker.html
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.js b/docs/mkdocs/src/hive/web/speaker/speaker.js
new file mode 120000
index 0000000..2ebaf50
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/speaker.js
@@ -0,0 +1 @@
+../../../../../../web/speaker/speaker.js
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.py b/docs/mkdocs/src/hive/web/speaker/speaker.py
new file mode 120000
index 0000000..1d6e95e
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/speaker.py
@@ -0,0 +1 @@
+../../../../../../web/speaker/speaker.py
\ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/ui.js b/docs/mkdocs/src/hive/web/ui.js
new file mode 120000
index 0000000..71419c3
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/ui.js
@@ -0,0 +1 @@
+../../../../../web/ui.js
\ No newline at end of file
diff --git a/docs/mkdocs/src/index.md b/docs/mkdocs/src/index.md
index c81f7ff..aae6e54 100644
--- a/docs/mkdocs/src/index.md
+++ b/docs/mkdocs/src/index.md
@@ -152,11 +152,23 @@
 
 See the [Platforms page](platforms/index.md) for details.
 
+
+Hive
+----
+
+The Hive is a collection of example apps and virtual devices that are implemented using the
+Python Bumble API, running entirely in a web page. This is a convenient way to try out some
+of the examples without any Python installation, when you have some other virtual Bluetooth
+device that you can connect to or from, such as the Android Emulator.
+
+See the [Bumble Hive](hive/index.md) for details.
+
 Roadmap
 -------
 
 Future features to be considered include:
 
+  * More profiles
   * More device examples
   * Add a new type of virtual link (beyond the two existing ones) to allow for link-level simulation (timing, loss, etc)
   * Bindings for languages other than Python
diff --git a/docs/mkdocs/src/transports/android_emulator.md b/docs/mkdocs/src/transports/android_emulator.md
index 974ba4f..becff54 100644
--- a/docs/mkdocs/src/transports/android_emulator.md
+++ b/docs/mkdocs/src/transports/android_emulator.md
@@ -14,7 +14,7 @@
 
 ## Moniker
 The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
-where `<options>` is a ','-separated list of `<name>=<value>` pairs`.
+where `<options>` is a comma-separated list of `<name>=<value>` pairs.
 The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode).
 Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process).
 
diff --git a/docs/mkdocs/src/transports/usb.md b/docs/mkdocs/src/transports/usb.md
index e400630..08949f0 100644
--- a/docs/mkdocs/src/transports/usb.md
+++ b/docs/mkdocs/src/transports/usb.md
@@ -10,6 +10,7 @@
   * `usb:<vendor>:<product>`
   * `usb:<vendor>:<product>/<serial-number>`
   * `usb:<vendor>:<product>#<index>`
+  * `usb:<bus>-<port_numbers>`
 
 with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
 In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
@@ -17,6 +18,8 @@
 
 `<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
 
+with `<port_numbers>` as a list of all port numbers from root separated with dots `.`
+
 In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
 the first USB interface of the device will be used, regardless of the interface class/subclass.
 This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
@@ -37,6 +40,9 @@
     `usb:0B05:17CB!`
     The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
 
+    `usb:3-3.4.1`
+    The BT USB dongle on bus 3 on port path 3, 4, 1.
+
 
 ## Alternative
 The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
diff --git a/examples/avrcp_as_sink.html b/examples/avrcp_as_sink.html
new file mode 100644
index 0000000..7c96744
--- /dev/null
+++ b/examples/avrcp_as_sink.html
@@ -0,0 +1,274 @@
+<html>
+
+<head>
+    <style>
+        * {
+            font-family: sans-serif;
+        }
+    </style>
+</head>
+<body>
+    Server Port <input id="port" type="text" value="8989"></input> <button id="connectButton" onclick="connect()">Connect</button><br>
+    <div id="socketState"></div>
+    <br>
+    <div id="buttons"></div><br>
+    <hr>
+    <button onclick="onGetPlayStatusButtonClicked()">Get Play Status</button><br>
+    <div id="getPlayStatusResponseTable"></div>
+    <hr>
+    <button onclick="onGetElementAttributesButtonClicked()">Get Element Attributes</button><br>
+    <div id="getElementAttributesResponseTable"></div>
+    <hr>
+    <table>
+        <tr>
+            <b>VOLUME</b>:
+            <button onclick="onVolumeDownButtonClicked()">-</button>
+            <button onclick="onVolumeUpButtonClicked()">+</button>&nbsp;
+            <span id="volumeText"></span><br>
+        </tr>
+        <tr>
+            <td><b>PLAYBACK STATUS</b></td><td><span id="playbackStatusText"></span></td>
+        </tr>
+        <tr>
+            <td><b>POSITION</b></td><td><span id="positionText"></span></td>
+        </tr>
+        <tr>
+            <td><b>TRACK</b></td><td><span id="trackText"></span></td>
+        </tr>
+        <tr>
+            <td><b>ADDRESSED PLAYER</b></td><td><span id="addressedPlayerText"></span></td>
+        </tr>
+        <tr>
+            <td><b>UID COUNTER</b></td><td><span id="uidCounterText"></span></td>
+        </tr>
+        <tr>
+            <td><b>SUPPORTED EVENTS</b></td><td><span id="supportedEventsText"></span></td>
+        </tr>
+        <tr>
+            <td><b>PLAYER SETTINGS</b></td><td><div id="playerSettingsTable"></div></td>
+        </tr>
+    </table>
+    <script>
+        const portInput = document.getElementById("port")
+        const connectButton = document.getElementById("connectButton")
+        const socketState = document.getElementById("socketState")
+        const volumeText = document.getElementById("volumeText")
+        const positionText = document.getElementById("positionText")
+        const trackText = document.getElementById("trackText")
+        const playbackStatusText = document.getElementById("playbackStatusText")
+        const addressedPlayerText = document.getElementById("addressedPlayerText")
+        const uidCounterText = document.getElementById("uidCounterText")
+        const supportedEventsText = document.getElementById("supportedEventsText")
+        const playerSettingsTable = document.getElementById("playerSettingsTable")
+        const getPlayStatusResponseTable = document.getElementById("getPlayStatusResponseTable")
+        const getElementAttributesResponseTable = document.getElementById("getElementAttributesResponseTable")
+        let socket
+        let volume = 0
+
+        const keyNames = [
+            "SELECT",
+            "UP",
+            "DOWN",
+            "LEFT",
+            "RIGHT",
+            "RIGHT_UP",
+            "RIGHT_DOWN",
+            "LEFT_UP",
+            "LEFT_DOWN",
+            "ROOT_MENU",
+            "SETUP_MENU",
+            "CONTENTS_MENU",
+            "FAVORITE_MENU",
+            "EXIT",
+            "NUMBER_0",
+            "NUMBER_1",
+            "NUMBER_2",
+            "NUMBER_3",
+            "NUMBER_4",
+            "NUMBER_5",
+            "NUMBER_6",
+            "NUMBER_7",
+            "NUMBER_8",
+            "NUMBER_9",
+            "DOT",
+            "ENTER",
+            "CLEAR",
+            "CHANNEL_UP",
+            "CHANNEL_DOWN",
+            "PREVIOUS_CHANNEL",
+            "SOUND_SELECT",
+            "INPUT_SELECT",
+            "DISPLAY_INFORMATION",
+            "HELP",
+            "PAGE_UP",
+            "PAGE_DOWN",
+            "POWER",
+            "VOLUME_UP",
+            "VOLUME_DOWN",
+            "MUTE",
+            "PLAY",
+            "STOP",
+            "PAUSE",
+            "RECORD",
+            "REWIND",
+            "FAST_FORWARD",
+            "EJECT",
+            "FORWARD",
+            "BACKWARD",
+            "ANGLE",
+            "SUBPICTURE",
+            "F1",
+            "F2",
+            "F3",
+            "F4",
+            "F5",
+        ]
+
+        document.addEventListener('keydown', onKeyDown)
+        document.addEventListener('keyup', onKeyUp)
+
+        const buttons = document.getElementById("buttons")
+        keyNames.forEach(name => {
+            const button = document.createElement("BUTTON")
+            button.appendChild(document.createTextNode(name))
+            button.addEventListener("mousedown", event => {
+                send({type: 'send-key-down', key: name})
+            })
+            button.addEventListener("mouseup", event => {
+                send({type: 'send-key-up', key: name})
+            })
+            buttons.appendChild(button)
+        })
+
+        updateVolume(0)
+
+        function connect() {
+            socket = new WebSocket(`ws://localhost:${portInput.value}`);
+            socket.onopen = _ => {
+                socketState.innerText = 'OPEN'
+                connectButton.disabled = true
+            }
+            socket.onclose = _ => {
+                socketState.innerText = 'CLOSED'
+                connectButton.disabled = false
+            }
+            socket.onerror = (error) => {
+                socketState.innerText = 'ERROR'
+                console.log(`ERROR: ${error}`)
+                connectButton.disabled = false
+            }
+            socket.onmessage = (message) => {
+                onMessage(JSON.parse(message.data))
+            }
+        }
+
+        function send(message) {
+            if (socket && socket.readyState == WebSocket.OPEN) {
+                socket.send(JSON.stringify(message))
+            }
+        }
+
+        function hmsText(position) {
+            const h_1 = 1000 * 60 * 60
+            const h = Math.floor(position / h_1)
+            position -= h * h_1
+            const m_1 = 1000 * 60
+            const m = Math.floor(position / m_1)
+            position -= m * m_1
+            const s_1 = 1000
+            const s = Math.floor(position / s_1)
+            position -= s * s_1
+
+            return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}:${position}`
+        }
+
+        function setTableHead(table, columns) {
+            let thead = table.createTHead()
+            let row = thead.insertRow()
+            for (let column of columns) {
+                let th = document.createElement("th")
+                let text = document.createTextNode(column)
+                th.appendChild(text)
+                row.appendChild(th)
+            }
+        }
+
+        function createTable(rows) {
+            const table = document.createElement("table")
+
+            if (rows.length != 0) {
+                columns = Object.keys(rows[0])
+                setTableHead(table, columns)
+            }
+            for (let element of rows) {
+                let row = table.insertRow()
+                for (key in element) {
+                    let cell = row.insertCell()
+                    let text = document.createTextNode(element[key])
+                    cell.appendChild(text)
+                }
+            }
+            return table
+        }
+
+        function onMessage(message) {
+            console.log(message)
+            if (message.type == "set-volume") {
+                updateVolume(message.params.volume)
+            } else if (message.type == "supported-events") {
+                supportedEventsText.innerText = JSON.stringify(message.params.events)
+            } else if (message.type == "playback-position-changed") {
+                positionText.innerText = hmsText(message.params.position)
+            } else if (message.type == "playback-status-changed") {
+                playbackStatusText.innerText = message.params.status
+            } else if (message.type == "player-settings-changed") {
+                playerSettingsTable.replaceChildren(message.params.settings)
+            } else if (message.type == "track-changed") {
+                trackText.innerText = message.params.identifier
+            } else if (message.type == "addressed-player-changed") {
+                addressedPlayerText.innerText = JSON.stringify(message.params.player)
+            } else if (message.type == "uids-changed") {
+                uidCounterText.innerText = message.params.uid_counter
+            } else if (message.type == "get-play-status-response") {
+                getPlayStatusResponseTable.replaceChildren(message.params)
+            } else if (message.type == "get-element-attributes-response") {
+                getElementAttributesResponseTable.replaceChildren(createTable(message.params))
+            }
+        }
+
+        function updateVolume(newVolume) {
+            volume = newVolume
+            volumeText.innerText = `${volume} (${Math.round(100*volume/0x7F)}%)`
+        }
+
+        function onKeyDown(event) {
+            console.log(event)
+            send({ type: 'send-key-down', key: event.key })
+        }
+
+        function onKeyUp(event) {
+            console.log(event)
+            send({ type: 'send-key-up', key: event.key })
+        }
+
+        function onVolumeUpButtonClicked() {
+            updateVolume(Math.min(volume + 5, 0x7F))
+            send({ type: 'set-volume', volume })
+        }
+
+        function onVolumeDownButtonClicked() {
+            updateVolume(Math.max(volume - 5, 0))
+            send({ type: 'set-volume', volume })
+        }
+
+        function onGetPlayStatusButtonClicked() {
+            send({ type: 'get-play-status', volume })
+        }
+
+        function onGetElementAttributesButtonClicked() {
+            send({ type: 'get-element-attributes' })
+        }
+</script>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/examples/heart_rate_server.py b/examples/heart_rate_server.py
index 32f53b1..fad809f 100644
--- a/examples/heart_rate_server.py
+++ b/examples/heart_rate_server.py
@@ -29,6 +29,7 @@
 from bumble.transport import open_transport_or_link
 from bumble.profiles.device_information_service import DeviceInformationService
 from bumble.profiles.heart_rate_service import HeartRateService
+from bumble.utils import AsyncRunner
 
 
 # -----------------------------------------------------------------------------
@@ -98,6 +99,17 @@
             )
         )
 
+        # Notify subscribers of the current value as soon as they subscribe
+        @heart_rate_service.heart_rate_measurement_characteristic.on('subscription')
+        def on_subscription(connection, notify_enabled, indicate_enabled):
+            if notify_enabled or indicate_enabled:
+                AsyncRunner.spawn(
+                    device.notify_subscriber(
+                        connection,
+                        heart_rate_service.heart_rate_measurement_characteristic,
+                    )
+                )
+
         # Go!
         await device.power_on()
         await device.start_advertising(auto_restart=True)
diff --git a/examples/hid_keyboard.json b/examples/hid_keyboard.json
new file mode 100644
index 0000000..b7b1409
--- /dev/null
+++ b/examples/hid_keyboard.json
@@ -0,0 +1,5 @@
+{
+    "name": "Bumble HID Keyboard",
+    "class_of_device": 9664,
+    "keystore": "JsonKeyStore"
+}
diff --git a/examples/keyboard.html b/examples/keyboard.html
index 6ad83a7..7d44a03 100644
--- a/examples/keyboard.html
+++ b/examples/keyboard.html
@@ -40,9 +40,9 @@
                 }
             }
             function onMouseMove(event) {
-                //console.log(event.clientX, event.clientY)
-                mouseInfo.innerText = `MOUSE: x=${event.clientX}, y=${event.clientY}`
-                send({ type:'mousemove', x: event.clientX, y: event.clientY })
+                //console.log(event.movementX, event.movementY)
+                mouseInfo.innerText = `MOUSE: x=${event.movementX}, y=${event.movementY}`
+                send({ type:'mousemove', x: event.movementX, y: event.movementY })
             }
 
             function onKeyDown(event) {
diff --git a/examples/leaudio.json b/examples/leaudio.json
new file mode 100644
index 0000000..ad5f6c8
--- /dev/null
+++ b/examples/leaudio.json
@@ -0,0 +1,7 @@
+{
+    "name": "Bumble-LEA",
+    "keystore": "JsonKeyStore",
+    "address": "F0:F1:F2:F3:F4:FA",
+    "class_of_device": 2376708,
+    "advertising_interval": 100
+}
diff --git a/examples/leaudio_with_classic.json b/examples/leaudio_with_classic.json
new file mode 100644
index 0000000..8b0d593
--- /dev/null
+++ b/examples/leaudio_with_classic.json
@@ -0,0 +1,9 @@
+{
+    "name": "Bumble-LEA",
+    "keystore": "JsonKeyStore",
+    "address": "F0:F1:F2:F3:F4:FA",
+    "classic_enabled": true,
+    "cis_enabled": true,
+    "class_of_device": 2376708,
+    "advertising_interval": 100
+}
diff --git a/examples/run_a2dp_info.py b/examples/run_a2dp_info.py
index 2f21cfa..3a35695 100644
--- a/examples/run_a2dp_info.py
+++ b/examples/run_a2dp_info.py
@@ -53,10 +53,10 @@
 
 # -----------------------------------------------------------------------------
 # pylint: disable-next=too-many-nested-blocks
-async def find_a2dp_service(device, connection):
+async def find_a2dp_service(connection):
     # Connect to the SDP Server
-    sdp_client = SDP_Client(device)
-    await sdp_client.connect(connection)
+    sdp_client = SDP_Client(connection)
+    await sdp_client.connect()
 
     # Search for services with an Audio Sink service class
     search_result = await sdp_client.search_attributes(
@@ -177,7 +177,7 @@
         print('*** Encryption on')
 
         # Look for an A2DP service
-        avdtp_version = await find_a2dp_service(device, connection)
+        avdtp_version = await find_a2dp_service(connection)
         if not avdtp_version:
             print(color('!!! no AVDTP service found'))
             return
diff --git a/examples/run_a2dp_source.py b/examples/run_a2dp_source.py
index 69dc2d0..4645229 100644
--- a/examples/run_a2dp_source.py
+++ b/examples/run_a2dp_source.py
@@ -74,7 +74,7 @@
 # -----------------------------------------------------------------------------
 def on_avdtp_connection(read_function, protocol):
     packet_source = SbcPacketSource(
-        read_function, protocol.l2cap_channel.mtu, codec_capabilities()
+        read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
     )
     packet_pump = MediaPacketPump(packet_source.packets)
     protocol.add_source(packet_source.codec_capabilities, packet_pump)
@@ -98,7 +98,7 @@
 
     # Stream the packets
     packet_source = SbcPacketSource(
-        read_function, protocol.l2cap_channel.mtu, codec_capabilities()
+        read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
     )
     packet_pump = MediaPacketPump(packet_source.packets)
     source = protocol.add_source(packet_source.codec_capabilities, packet_pump)
@@ -165,9 +165,7 @@
                 print('*** Encryption on')
 
                 # Look for an A2DP service
-                avdtp_version = await find_avdtp_service_with_connection(
-                    device, connection
-                )
+                avdtp_version = await find_avdtp_service_with_connection(connection)
                 if not avdtp_version:
                     print(color('!!! no A2DP service found'))
                     return
diff --git a/examples/run_advertiser.py b/examples/run_advertiser.py
index 56b1b8b..fb59426 100644
--- a/examples/run_advertiser.py
+++ b/examples/run_advertiser.py
@@ -19,9 +19,11 @@
 import logging
 import sys
 import os
+import struct
+
+from bumble.core import AdvertisingData
 from bumble.device import AdvertisingType, Device
 from bumble.hci import Address
-
 from bumble.transport import open_transport_or_link
 
 
@@ -52,6 +54,16 @@
         print('<<< connected')
 
         device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
+
+        if advertising_type.is_scannable:
+            device.scan_response_data = bytes(
+                AdvertisingData(
+                    [
+                        (AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340)),
+                    ]
+                )
+            )
+
         await device.power_on()
         await device.start_advertising(advertising_type=advertising_type, target=target)
         await hci_source.wait_for_termination()
diff --git a/examples/run_avrcp.py b/examples/run_avrcp.py
new file mode 100644
index 0000000..4bb4143
--- /dev/null
+++ b/examples/run_avrcp.py
@@ -0,0 +1,408 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import asyncio
+import json
+import sys
+import os
+import logging
+import websockets
+
+from bumble.device import Device
+from bumble.transport import open_transport_or_link
+from bumble.core import BT_BR_EDR_TRANSPORT
+from bumble import avc
+from bumble import avrcp
+from bumble import avdtp
+from bumble import a2dp
+from bumble import utils
+
+
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+def sdp_records():
+    a2dp_sink_service_record_handle = 0x00010001
+    avrcp_controller_service_record_handle = 0x00010002
+    avrcp_target_service_record_handle = 0x00010003
+    # pylint: disable=line-too-long
+    return {
+        a2dp_sink_service_record_handle: a2dp.make_audio_sink_service_sdp_records(
+            a2dp_sink_service_record_handle
+        ),
+        avrcp_controller_service_record_handle: avrcp.make_controller_service_sdp_records(
+            avrcp_controller_service_record_handle
+        ),
+        avrcp_target_service_record_handle: avrcp.make_target_service_sdp_records(
+            avrcp_controller_service_record_handle
+        ),
+    }
+
+
+# -----------------------------------------------------------------------------
+def codec_capabilities():
+    return avdtp.MediaCodecCapabilities(
+        media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
+        media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
+        media_codec_information=a2dp.SbcMediaCodecInformation.from_lists(
+            sampling_frequencies=[48000, 44100, 32000, 16000],
+            channel_modes=[
+                a2dp.SBC_MONO_CHANNEL_MODE,
+                a2dp.SBC_DUAL_CHANNEL_MODE,
+                a2dp.SBC_STEREO_CHANNEL_MODE,
+                a2dp.SBC_JOINT_STEREO_CHANNEL_MODE,
+            ],
+            block_lengths=[4, 8, 12, 16],
+            subbands=[4, 8],
+            allocation_methods=[
+                a2dp.SBC_LOUDNESS_ALLOCATION_METHOD,
+                a2dp.SBC_SNR_ALLOCATION_METHOD,
+            ],
+            minimum_bitpool_value=2,
+            maximum_bitpool_value=53,
+        ),
+    )
+
+
+# -----------------------------------------------------------------------------
+def on_avdtp_connection(server):
+    # Add a sink endpoint to the server
+    sink = server.add_sink(codec_capabilities())
+    sink.on('rtp_packet', on_rtp_packet)
+
+
+# -----------------------------------------------------------------------------
+def on_rtp_packet(packet):
+    print(f'RTP: {packet}')
+
+
+# -----------------------------------------------------------------------------
+def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketServer):
+    async def get_supported_events():
+        events = await avrcp_protocol.get_supported_events()
+        print("SUPPORTED EVENTS:", events)
+        websocket_server.send_message(
+            {
+                "type": "supported-events",
+                "params": {"events": [event.name for event in events]},
+            }
+        )
+
+        if avrcp.EventId.TRACK_CHANGED in events:
+            utils.AsyncRunner.spawn(monitor_track_changed())
+
+        if avrcp.EventId.PLAYBACK_STATUS_CHANGED in events:
+            utils.AsyncRunner.spawn(monitor_playback_status())
+
+        if avrcp.EventId.PLAYBACK_POS_CHANGED in events:
+            utils.AsyncRunner.spawn(monitor_playback_position())
+
+        if avrcp.EventId.PLAYER_APPLICATION_SETTING_CHANGED in events:
+            utils.AsyncRunner.spawn(monitor_player_application_settings())
+
+        if avrcp.EventId.AVAILABLE_PLAYERS_CHANGED in events:
+            utils.AsyncRunner.spawn(monitor_available_players())
+
+        if avrcp.EventId.ADDRESSED_PLAYER_CHANGED in events:
+            utils.AsyncRunner.spawn(monitor_addressed_player())
+
+        if avrcp.EventId.UIDS_CHANGED in events:
+            utils.AsyncRunner.spawn(monitor_uids())
+
+        if avrcp.EventId.VOLUME_CHANGED in events:
+            utils.AsyncRunner.spawn(monitor_volume())
+
+    utils.AsyncRunner.spawn(get_supported_events())
+
+    async def monitor_track_changed():
+        async for identifier in avrcp_protocol.monitor_track_changed():
+            print("TRACK CHANGED:", identifier.hex())
+            websocket_server.send_message(
+                {"type": "track-changed", "params": {"identifier": identifier.hex()}}
+            )
+
+    async def monitor_playback_status():
+        async for playback_status in avrcp_protocol.monitor_playback_status():
+            print("PLAYBACK STATUS CHANGED:", playback_status.name)
+            websocket_server.send_message(
+                {
+                    "type": "playback-status-changed",
+                    "params": {"status": playback_status.name},
+                }
+            )
+
+    async def monitor_playback_position():
+        async for playback_position in avrcp_protocol.monitor_playback_position(
+            playback_interval=1
+        ):
+            print("PLAYBACK POSITION CHANGED:", playback_position)
+            websocket_server.send_message(
+                {
+                    "type": "playback-position-changed",
+                    "params": {"position": playback_position},
+                }
+            )
+
+    async def monitor_player_application_settings():
+        async for settings in avrcp_protocol.monitor_player_application_settings():
+            print("PLAYER APPLICATION SETTINGS:", settings)
+            settings_as_dict = [
+                {"attribute": setting.attribute_id.name, "value": setting.value_id.name}
+                for setting in settings
+            ]
+            websocket_server.send_message(
+                {
+                    "type": "player-settings-changed",
+                    "params": {"settings": settings_as_dict},
+                }
+            )
+
+    async def monitor_available_players():
+        async for _ in avrcp_protocol.monitor_available_players():
+            print("AVAILABLE PLAYERS CHANGED")
+            websocket_server.send_message(
+                {"type": "available-players-changed", "params": {}}
+            )
+
+    async def monitor_addressed_player():
+        async for player in avrcp_protocol.monitor_addressed_player():
+            print("ADDRESSED PLAYER CHANGED")
+            websocket_server.send_message(
+                {
+                    "type": "addressed-player-changed",
+                    "params": {
+                        "player": {
+                            "player_id": player.player_id,
+                            "uid_counter": player.uid_counter,
+                        }
+                    },
+                }
+            )
+
+    async def monitor_uids():
+        async for uid_counter in avrcp_protocol.monitor_uids():
+            print("UIDS CHANGED")
+            websocket_server.send_message(
+                {
+                    "type": "uids-changed",
+                    "params": {
+                        "uid_counter": uid_counter,
+                    },
+                }
+            )
+
+    async def monitor_volume():
+        async for volume in avrcp_protocol.monitor_volume():
+            print("VOLUME CHANGED:", volume)
+            websocket_server.send_message(
+                {"type": "volume-changed", "params": {"volume": volume}}
+            )
+
+
+# -----------------------------------------------------------------------------
+class WebSocketServer:
+    def __init__(
+        self, avrcp_protocol: avrcp.Protocol, avrcp_delegate: Delegate
+    ) -> None:
+        self.socket = None
+        self.delegate = None
+        self.avrcp_protocol = avrcp_protocol
+        self.avrcp_delegate = avrcp_delegate
+
+    async def start(self) -> None:
+        # pylint: disable-next=no-member
+        await websockets.serve(self.serve, 'localhost', 8989)  # type: ignore
+
+    async def serve(self, socket, _path) -> None:
+        print('### WebSocket connected')
+        self.socket = socket
+        while True:
+            try:
+                message = await socket.recv()
+                print('Received: ', str(message))
+
+                parsed = json.loads(message)
+                message_type = parsed['type']
+                if message_type == 'send-key-down':
+                    await self.on_send_key_down(parsed)
+                elif message_type == 'send-key-up':
+                    await self.on_send_key_up(parsed)
+                elif message_type == 'set-volume':
+                    await self.on_set_volume(parsed)
+                elif message_type == 'get-play-status':
+                    await self.on_get_play_status()
+                elif message_type == 'get-element-attributes':
+                    await self.on_get_element_attributes()
+            except websockets.exceptions.ConnectionClosedOK:
+                self.socket = None
+                break
+
+    async def on_send_key_down(self, message: dict) -> None:
+        key = avc.PassThroughFrame.OperationId[message["key"]]
+        await self.avrcp_protocol.send_key_event(key, True)
+
+    async def on_send_key_up(self, message: dict) -> None:
+        key = avc.PassThroughFrame.OperationId[message["key"]]
+        await self.avrcp_protocol.send_key_event(key, False)
+
+    async def on_set_volume(self, message: dict) -> None:
+        volume = message["volume"]
+        self.avrcp_delegate.volume = volume
+        self.avrcp_protocol.notify_volume_changed(volume)
+
+    async def on_get_play_status(self) -> None:
+        play_status = await self.avrcp_protocol.get_play_status()
+        self.send_message(
+            {
+                "type": "get-play-status-response",
+                "params": {
+                    "song_length": play_status.song_length,
+                    "song_position": play_status.song_position,
+                    "play_status": play_status.play_status.name,
+                },
+            }
+        )
+
+    async def on_get_element_attributes(self) -> None:
+        attributes = await self.avrcp_protocol.get_element_attributes(
+            0,
+            [
+                avrcp.MediaAttributeId.TITLE,
+                avrcp.MediaAttributeId.ARTIST_NAME,
+                avrcp.MediaAttributeId.ALBUM_NAME,
+                avrcp.MediaAttributeId.TRACK_NUMBER,
+                avrcp.MediaAttributeId.TOTAL_NUMBER_OF_TRACKS,
+                avrcp.MediaAttributeId.GENRE,
+                avrcp.MediaAttributeId.PLAYING_TIME,
+                avrcp.MediaAttributeId.DEFAULT_COVER_ART,
+            ],
+        )
+        self.send_message(
+            {
+                "type": "get-element-attributes-response",
+                "params": [
+                    {
+                        "attribute_id": attribute.attribute_id.name,
+                        "attribute_value": attribute.attribute_value,
+                    }
+                    for attribute in attributes
+                ],
+            }
+        )
+
+    def send_message(self, message: dict) -> None:
+        if self.socket is None:
+            print("no socket, dropping message")
+            return
+        serialized = json.dumps(message)
+        utils.AsyncRunner.spawn(self.socket.send(serialized))
+
+
+# -----------------------------------------------------------------------------
+class Delegate(avrcp.Delegate):
+    def __init__(self):
+        super().__init__(
+            [avrcp.EventId.VOLUME_CHANGED, avrcp.EventId.PLAYBACK_STATUS_CHANGED]
+        )
+        self.websocket_server = None
+
+    async def set_absolute_volume(self, volume: int) -> None:
+        await super().set_absolute_volume(volume)
+        if self.websocket_server is not None:
+            self.websocket_server.send_message(
+                {"type": "set-volume", "params": {"volume": volume}}
+            )
+
+
+# -----------------------------------------------------------------------------
+async def main():
+    if len(sys.argv) < 3:
+        print(
+            'Usage: run_avrcp_controller.py <device-config> <transport-spec> '
+            '<sbc-file> [<bt-addr>]'
+        )
+        print('example: run_avrcp_controller.py classic1.json usb:0')
+        return
+
+    print('<<< connecting to HCI...')
+    async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
+        print('<<< connected')
+
+        # Create a device
+        device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
+        device.classic_enabled = True
+
+        # Setup the SDP to expose the sink service
+        device.sdp_service_records = sdp_records()
+
+        # Start the controller
+        await device.power_on()
+
+        # Create a listener to wait for AVDTP connections
+        listener = avdtp.Listener(avdtp.Listener.create_registrar(device))
+        listener.on('connection', on_avdtp_connection)
+
+        avrcp_delegate = Delegate()
+        avrcp_protocol = avrcp.Protocol(avrcp_delegate)
+        avrcp_protocol.listen(device)
+
+        websocket_server = WebSocketServer(avrcp_protocol, avrcp_delegate)
+        avrcp_delegate.websocket_server = websocket_server
+        avrcp_protocol.on(
+            "start", lambda: on_avrcp_start(avrcp_protocol, websocket_server)
+        )
+        await websocket_server.start()
+
+        if len(sys.argv) >= 5:
+            # Connect to the peer
+            target_address = sys.argv[4]
+            print(f'=== Connecting to {target_address}...')
+            connection = await device.connect(
+                target_address, transport=BT_BR_EDR_TRANSPORT
+            )
+            print(f'=== Connected to {connection.peer_address}!')
+
+            # Request authentication
+            print('*** Authenticating...')
+            await connection.authenticate()
+            print('*** Authenticated')
+
+            # Enable encryption
+            print('*** Enabling encryption...')
+            await connection.encrypt()
+            print('*** Encryption on')
+
+            server = await avdtp.Protocol.connect(connection)
+            listener.set_server(connection, server)
+            sink = server.add_sink(codec_capabilities())
+            sink.on('rtp_packet', on_rtp_packet)
+
+            await avrcp_protocol.connect(connection)
+
+        else:
+            # Start being discoverable and connectable
+            await device.set_discoverable(True)
+            await device.set_connectable(True)
+
+        await asyncio.get_event_loop().create_future()
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_cig_setup.py b/examples/run_cig_setup.py
new file mode 100644
index 0000000..29a54ad
--- /dev/null
+++ b/examples/run_cig_setup.py
@@ -0,0 +1,103 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+from bumble.device import (
+    Device,
+    Connection,
+    AdvertisingParameters,
+    AdvertisingEventProperties,
+)
+from bumble.hci import (
+    OwnAddressType,
+)
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+    if len(sys.argv) < 3:
+        print(
+            'Usage: run_cig_setup.py <config-file>'
+            '<transport-spec-for-device-1> <transport-spec-for-device-2>'
+        )
+        print(
+            'example: run_cig_setup.py device1.json'
+            'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
+        )
+        return
+
+    print('<<< connecting to HCI...')
+    hci_transports = await asyncio.gather(
+        open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
+    )
+    print('<<< connected')
+
+    devices = [
+        Device.from_config_file_with_hci(
+            sys.argv[1], hci_transport.source, hci_transport.sink
+        )
+        for hci_transport in hci_transports
+    ]
+
+    devices[0].cis_enabled = True
+    devices[1].cis_enabled = True
+
+    await asyncio.gather(*[device.power_on() for device in devices])
+    advertising_set = await devices[0].create_advertising_set()
+
+    connection = await devices[1].connect(
+        devices[0].public_address, own_address_type=OwnAddressType.PUBLIC
+    )
+
+    cid_ids = [2, 3]
+    cis_handles = await devices[1].setup_cig(
+        cig_id=1,
+        cis_id=cid_ids,
+        sdu_interval=(10000, 0),
+        framing=0,
+        max_sdu=(120, 0),
+        retransmission_number=13,
+        max_transport_latency=(100, 0),
+    )
+
+    def on_cis_request(
+        connection: Connection, cis_handle: int, _cig_id: int, _cis_id: int
+    ):
+        connection.abort_on('disconnection', devices[0].accept_cis_request(cis_handle))
+
+    devices[0].on('cis_request', on_cis_request)
+
+    cis_links = await devices[1].create_cis(
+        [(cis, connection.handle) for cis in cis_handles]
+    )
+
+    for cis_link in cis_links:
+        await cis_link.disconnect()
+
+    await asyncio.gather(
+        *[hci_transport.source.terminated for hci_transport in hci_transports]
+    )
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_classic_connect.py b/examples/run_classic_connect.py
index 3ae6ed8..0acaedd 100644
--- a/examples/run_classic_connect.py
+++ b/examples/run_classic_connect.py
@@ -63,8 +63,8 @@
             print(f'=== Connected to {connection.peer_address}!')
 
             # Connect to the SDP Server
-            sdp_client = SDP_Client(device)
-            await sdp_client.connect(connection)
+            sdp_client = SDP_Client(connection)
+            await sdp_client.connect()
 
             # List all services in the root browse group
             service_record_handles = await sdp_client.search_services(
diff --git a/examples/run_csis_servers.py b/examples/run_csis_servers.py
new file mode 100644
index 0000000..9853523
--- /dev/null
+++ b/examples/run_csis_servers.py
@@ -0,0 +1,110 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+import secrets
+
+from bumble.core import AdvertisingData
+from bumble.device import Device
+from bumble.hci import (
+    Address,
+    OwnAddressType,
+    HCI_LE_Set_Extended_Advertising_Parameters_Command,
+)
+from bumble.profiles.cap import CommonAudioServiceService
+from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+    if len(sys.argv) < 3:
+        print(
+            'Usage: run_cig_setup.py <config-file>'
+            '<transport-spec-for-device-1> <transport-spec-for-device-2>'
+        )
+        print(
+            'example: run_cig_setup.py device1.json'
+            'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
+        )
+        return
+
+    print('<<< connecting to HCI...')
+    hci_transports = await asyncio.gather(
+        open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
+    )
+    print('<<< connected')
+
+    devices = [
+        Device.from_config_file_with_hci(
+            sys.argv[1], hci_transport.source, hci_transport.sink
+        )
+        for hci_transport in hci_transports
+    ]
+
+    sirk = secrets.token_bytes(16)
+
+    for i, device in enumerate(devices):
+        device.random_address = Address(secrets.token_bytes(6))
+        await device.power_on()
+        csis = CoordinatedSetIdentificationService(
+            set_identity_resolving_key=sirk,
+            set_identity_resolving_key_type=SirkType.PLAINTEXT,
+            coordinated_set_size=2,
+        )
+        device.add_service(CommonAudioServiceService(csis))
+        advertising_data = (
+            bytes(
+                AdvertisingData(
+                    [
+                        (
+                            AdvertisingData.COMPLETE_LOCAL_NAME,
+                            bytes(f'Bumble LE Audio-{i}', 'utf-8'),
+                        ),
+                        (
+                            AdvertisingData.FLAGS,
+                            bytes(
+                                [
+                                    AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
+                                    | AdvertisingData.BR_EDR_HOST_FLAG
+                                    | AdvertisingData.BR_EDR_CONTROLLER_FLAG
+                                ]
+                            ),
+                        ),
+                        (
+                            AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+                            bytes(CoordinatedSetIdentificationService.UUID),
+                        ),
+                    ]
+                )
+            )
+            + csis.get_advertising_data()
+        )
+        await device.create_advertising_set(advertising_data=advertising_data)
+
+    await asyncio.gather(
+        *[hci_transport.source.terminated for hci_transport in hci_transports]
+    )
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_esco_connection.py b/examples/run_esco_connection.py
new file mode 100644
index 0000000..6f3e800
--- /dev/null
+++ b/examples/run_esco_connection.py
@@ -0,0 +1,86 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import dataclasses
+import logging
+import sys
+import os
+from bumble.core import BT_BR_EDR_TRANSPORT
+from bumble.device import Device, ScoLink
+from bumble.hci import HCI_Enhanced_Setup_Synchronous_Connection_Command
+from bumble.hfp import DefaultCodecParameters, ESCO_PARAMETERS
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+    if len(sys.argv) < 3:
+        print(
+            'Usage: run_esco_connection.py <config-file>'
+            '<transport-spec-for-device-1> <transport-spec-for-device-2>'
+        )
+        print(
+            'example: run_esco_connection.py classic1.json'
+            'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
+        )
+        return
+
+    print('<<< connecting to HCI...')
+    hci_transports = await asyncio.gather(
+        open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
+    )
+    print('<<< connected')
+
+    devices = [
+        Device.from_config_file_with_hci(
+            sys.argv[1], hci_transport.source, hci_transport.sink
+        )
+        for hci_transport in hci_transports
+    ]
+
+    devices[0].classic_enabled = True
+    devices[1].classic_enabled = True
+
+    await asyncio.gather(*[device.power_on() for device in devices])
+
+    connections = await asyncio.gather(
+        devices[0].accept(devices[1].public_address),
+        devices[1].connect(devices[0].public_address, transport=BT_BR_EDR_TRANSPORT),
+    )
+
+    def on_sco(sco_link: ScoLink):
+        connections[0].abort_on('disconnection', sco_link.disconnect())
+
+    devices[0].once('sco_connection', on_sco)
+
+    await devices[0].send_command(
+        HCI_Enhanced_Setup_Synchronous_Connection_Command(
+            connection_handle=connections[0].handle,
+            **ESCO_PARAMETERS[DefaultCodecParameters.ESCO_CVSD_S3].asdict(),
+        )
+    )
+
+    await asyncio.gather(
+        *[hci_transport.source.terminated for hci_transport in hci_transports]
+    )
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_extended_advertiser.py b/examples/run_extended_advertiser.py
new file mode 100644
index 0000000..6605cfa
--- /dev/null
+++ b/examples/run_extended_advertiser.py
@@ -0,0 +1,73 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+from bumble.device import (
+    AdvertisingParameters,
+    AdvertisingEventProperties,
+    AdvertisingType,
+    Device,
+)
+from bumble.hci import Address
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+    if len(sys.argv) < 3:
+        print(
+            'Usage: run_extended_advertiser.py <config-file> <transport-spec> [type] [address]'
+        )
+        print('example: run_extended_advertiser.py device1.json usb:0')
+        return
+
+    if len(sys.argv) >= 4:
+        advertising_properties = AdvertisingEventProperties.from_advertising_type(
+            AdvertisingType(int(sys.argv[3]))
+        )
+    else:
+        advertising_properties = AdvertisingEventProperties()
+
+    if len(sys.argv) >= 5:
+        peer_address = Address(sys.argv[4])
+    else:
+        peer_address = Address.ANY
+
+    print('<<< connecting to HCI...')
+    async with await open_transport_or_link(sys.argv[2]) as hci_transport:
+        print('<<< connected')
+
+        device = Device.from_config_file_with_hci(
+            sys.argv[1], hci_transport.source, hci_transport.sink
+        )
+        await device.power_on()
+        await device.create_advertising_set(
+            advertising_parameters=AdvertisingParameters(
+                advertising_event_properties=advertising_properties,
+                peer_address=peer_address,
+            )
+        )
+        await hci_transport.source.terminated
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_extended_advertiser_2.py b/examples/run_extended_advertiser_2.py
new file mode 100644
index 0000000..735e1c5
--- /dev/null
+++ b/examples/run_extended_advertiser_2.py
@@ -0,0 +1,99 @@
+# Copyright 2021-2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+from bumble.device import AdvertisingParameters, AdvertisingEventProperties, Device
+from bumble.hci import Address
+from bumble.core import AdvertisingData
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+    if len(sys.argv) < 3:
+        print('Usage: run_extended_advertiser_2.py <config-file> <transport-spec>')
+        print('example: run_extended_advertiser_2.py device1.json usb:0')
+        return
+
+    print('<<< connecting to HCI...')
+    async with await open_transport_or_link(sys.argv[2]) as hci_transport:
+        print('<<< connected')
+
+        device = Device.from_config_file_with_hci(
+            sys.argv[1], hci_transport.source, hci_transport.sink
+        )
+        await device.power_on()
+
+        if not device.supports_le_extended_advertising:
+            print("Device does not support extended advertising")
+            return
+
+        print("Max advertising sets:", device.host.number_of_supported_advertising_sets)
+        print(
+            "Max advertising data length:", device.host.maximum_advertising_data_length
+        )
+
+        if device.host.number_of_supported_advertising_sets >= 1:
+            advertising_data1 = AdvertisingData(
+                [(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 1".encode("utf-8"))]
+            )
+
+            set1 = await device.create_advertising_set(
+                advertising_data=bytes(advertising_data1),
+            )
+            print("Selected TX power 1:", set1.selected_tx_power)
+
+            advertising_data2 = AdvertisingData(
+                [(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
+            )
+
+        if device.host.number_of_supported_advertising_sets >= 2:
+            set2 = await device.create_advertising_set(
+                random_address=Address("F0:F0:F0:F0:F0:F1"),
+                advertising_parameters=AdvertisingParameters(),
+                advertising_data=bytes(advertising_data2),
+                auto_start=False,
+                auto_restart=True,
+            )
+            print("Selected TX power 2:", set2.selected_tx_power)
+            await set2.start()
+
+        if device.host.number_of_supported_advertising_sets >= 3:
+            scan_response_data3 = AdvertisingData(
+                [(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 3".encode("utf-8"))]
+            )
+
+            set3 = await device.create_advertising_set(
+                random_address=Address("F0:F0:F0:F0:F0:F2"),
+                advertising_parameters=AdvertisingParameters(
+                    advertising_event_properties=AdvertisingEventProperties(
+                        is_connectable=False, is_scannable=True
+                    )
+                ),
+                scan_response_data=bytes(scan_response_data3),
+            )
+            print("Selected TX power 3:", set2.selected_tx_power)
+
+        await hci_transport.source.terminated
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_hfp_gateway.py b/examples/run_hfp_gateway.py
index 13a2ed9..c3b392d 100644
--- a/examples/run_hfp_gateway.py
+++ b/examples/run_hfp_gateway.py
@@ -31,6 +31,7 @@
     BT_BR_EDR_TRANSPORT,
 )
 from bumble import rfcomm, hfp
+from bumble.hci import HCI_SynchronousDataPacket
 from bumble.sdp import (
     Client as SDP_Client,
     DataElement,
@@ -48,8 +49,8 @@
 # pylint: disable-next=too-many-nested-blocks
 async def list_rfcomm_channels(device, connection):
     # Connect to the SDP Server
-    sdp_client = SDP_Client(device)
-    await sdp_client.connect(connection)
+    sdp_client = SDP_Client(connection)
+    await sdp_client.connect()
 
     # Search for services that support the Handsfree Profile
     search_result = await sdp_client.search_attributes(
@@ -183,7 +184,7 @@
 
         # Create a client and start it
         print('@@@ Starting to RFCOMM client...')
-        rfcomm_client = rfcomm.Client(device, connection)
+        rfcomm_client = rfcomm.Client(connection)
         rfcomm_mux = await rfcomm_client.start()
         print('@@@ Started')
 
@@ -197,6 +198,13 @@
             print('@@@ Disconnected from RFCOMM server')
             return
 
+        def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket):
+            # Reset packet and loopback
+            packet.packet_status = 0
+            device.host.send_hci_packet(packet)
+
+        device.host.on('sco_packet', on_sco)
+
         # Protocol loop (just for testing at this point)
         protocol = hfp.HfpProtocol(session)
         while True:
diff --git a/examples/run_hfp_handsfree.py b/examples/run_hfp_handsfree.py
index 5f747fc..f4e445e 100644
--- a/examples/run_hfp_handsfree.py
+++ b/examples/run_hfp_handsfree.py
@@ -21,11 +21,13 @@
 import logging
 import json
 import websockets
+import functools
 from typing import Optional
 
-from bumble.device import Device
+from bumble import rfcomm
+from bumble import hci
+from bumble.device import Device, Connection
 from bumble.transport import open_transport_or_link
-from bumble.rfcomm import Server as RfcommServer
 from bumble import hfp
 from bumble.hfp import HfProtocol
 
@@ -57,12 +59,44 @@
 
 
 # -----------------------------------------------------------------------------
-def on_dlc(dlc, configuration: hfp.Configuration):
+def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
     print('*** DLC connected', dlc)
     protocol = HfProtocol(dlc, configuration)
     UiServer.protocol = protocol
     asyncio.create_task(protocol.run())
 
+    def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
+        if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
+            if link_type == hci.HCI_Connection_Complete_Event.SCO_LINK_TYPE:
+                esco_parameters = hfp.ESCO_PARAMETERS[
+                    hfp.DefaultCodecParameters.SCO_CVSD_D1
+                ]
+            elif protocol.active_codec == hfp.AudioCodec.MSBC:
+                esco_parameters = hfp.ESCO_PARAMETERS[
+                    hfp.DefaultCodecParameters.ESCO_MSBC_T2
+                ]
+            elif protocol.active_codec == hfp.AudioCodec.CVSD:
+                esco_parameters = hfp.ESCO_PARAMETERS[
+                    hfp.DefaultCodecParameters.ESCO_CVSD_S4
+                ]
+            connection.abort_on(
+                'disconnection',
+                connection.device.send_command(
+                    hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(
+                        bd_addr=connection.peer_address, **esco_parameters.asdict()
+                    )
+                ),
+            )
+
+    handler = functools.partial(on_sco_request, protocol=protocol)
+    dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
+    dlc.multiplexer.l2cap_channel.once(
+        'close',
+        lambda: dlc.multiplexer.l2cap_channel.connection.device.remove_listener(
+            'sco_request', handler
+        ),
+    )
+
 
 # -----------------------------------------------------------------------------
 async def main():
@@ -101,7 +135,7 @@
         device.classic_enabled = True
 
         # Create and register a server
-        rfcomm_server = RfcommServer(device)
+        rfcomm_server = rfcomm.Server(device)
 
         # Listen for incoming DLC connections
         channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
diff --git a/examples/run_hid_device.py b/examples/run_hid_device.py
new file mode 100644
index 0000000..9aebfc2
--- /dev/null
+++ b/examples/run_hid_device.py
@@ -0,0 +1,748 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import sys
+import os
+import logging
+import json
+import websockets
+from bumble.colors import color
+
+from bumble.device import Device
+from bumble.transport import open_transport_or_link
+from bumble.core import (
+    BT_BR_EDR_TRANSPORT,
+    BT_L2CAP_PROTOCOL_ID,
+    BT_HUMAN_INTERFACE_DEVICE_SERVICE,
+    BT_HIDP_PROTOCOL_ID,
+    UUID,
+)
+from bumble.hci import Address
+from bumble.hid import (
+    Device as HID_Device,
+    HID_CONTROL_PSM,
+    HID_INTERRUPT_PSM,
+    Message,
+)
+from bumble.sdp import (
+    Client as SDP_Client,
+    DataElement,
+    ServiceAttribute,
+    SDP_PUBLIC_BROWSE_ROOT,
+    SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+    SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+    SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+    SDP_ALL_ATTRIBUTES_RANGE,
+    SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
+    SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+    SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+    SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+)
+from bumble.utils import AsyncRunner
+
+# -----------------------------------------------------------------------------
+# SDP attributes for Bluetooth HID devices
+SDP_HID_SERVICE_NAME_ATTRIBUTE_ID = 0x0100
+SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID = 0x0101
+SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID = 0x0102
+SDP_HID_DEVICE_RELEASE_NUMBER_ATTRIBUTE_ID = 0x0200  # [DEPRECATED]
+SDP_HID_PARSER_VERSION_ATTRIBUTE_ID = 0x0201
+SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID = 0x0202
+SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID = 0x0203
+SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID = 0x0204
+SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID = 0x0205
+SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0x0206
+SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID = 0x0207
+SDP_HID_SDP_DISABLE_ATTRIBUTE_ID = 0x0208  # [DEPRECATED]
+SDP_HID_BATTERY_POWER_ATTRIBUTE_ID = 0x0209
+SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID = 0x020A
+SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID = 0x020B  # DEPRECATED]
+SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID = 0x020C
+SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID = 0x020D
+SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID = 0x020E
+SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID = 0x020F
+SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
+
+# Refer to HID profile specification v1.1.1, "5.3 Service Discovery Protocol (SDP)" for details
+# HID SDP attribute values
+LANGUAGE = 0x656E  # 0x656E uint16 “en” (English)
+ENCODING = 0x6A  # 0x006A uint16 UTF-8 encoding
+PRIMARY_LANGUAGE_BASE_ID = 0x100  # 0x0100 uint16 PrimaryLanguageBaseID
+VERSION_NUMBER = 0x0101  # 0x0101 uint16 version number (v1.1)
+SERVICE_NAME = b'Bumble HID'
+SERVICE_DESCRIPTION = b'Bumble'
+PROVIDER_NAME = b'Bumble'
+HID_PARSER_VERSION = 0x0111  # uint16 0x0111 (v1.1.1)
+HID_DEVICE_SUBCLASS = 0xC0  # Combo keyboard/pointing device
+HID_COUNTRY_CODE = 0x21  # 0x21 Uint8, USA
+HID_VIRTUAL_CABLE = True  # Virtual cable enabled
+HID_RECONNECT_INITIATE = True  #  Reconnect initiate enabled
+REPORT_DESCRIPTOR_TYPE = 0x22  # 0x22 Type = Report Descriptor
+HID_LANGID_BASE_LANGUAGE = 0x0409  # 0x0409 Language = English (United States)
+HID_LANGID_BASE_BLUETOOTH_STRING_OFFSET = 0x100  # 0x0100 Default
+HID_BATTERY_POWER = True  #  Battery power enabled
+HID_REMOTE_WAKE = True  #  Remote wake enabled
+HID_SUPERVISION_TIMEOUT = 0xC80  # uint16 0xC80 (2s)
+HID_NORMALLY_CONNECTABLE = True  #  Normally connectable enabled
+HID_BOOT_DEVICE = True  #  Boot device support enabled
+HID_SSR_HOST_MAX_LATENCY = 0x640  # uint16 0x640 (1s)
+HID_SSR_HOST_MIN_TIMEOUT = 0xC80  # uint16 0xC80 (2s)
+HID_REPORT_MAP = bytes(  # Text String, 50 Octet Report Descriptor
+    # pylint: disable=line-too-long
+    [
+        0x05,
+        0x01,  # Usage Page (Generic Desktop Ctrls)
+        0x09,
+        0x06,  # Usage (Keyboard)
+        0xA1,
+        0x01,  # Collection (Application)
+        0x85,
+        0x01,  # . Report ID (1)
+        0x05,
+        0x07,  # . Usage Page (Kbrd/Keypad)
+        0x19,
+        0xE0,  # . Usage Minimum (0xE0)
+        0x29,
+        0xE7,  # . Usage Maximum (0xE7)
+        0x15,
+        0x00,  # . Logical Minimum (0)
+        0x25,
+        0x01,  # . Logical Maximum (1)
+        0x75,
+        0x01,  # . Report Size (1)
+        0x95,
+        0x08,  # . Report Count (8)
+        0x81,
+        0x02,  # . Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
+        0x95,
+        0x01,  # . Report Count (1)
+        0x75,
+        0x08,  # . Report Size (8)
+        0x81,
+        0x03,  # . Input (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
+        0x95,
+        0x05,  # . Report Count (5)
+        0x75,
+        0x01,  # . Report Size (1)
+        0x05,
+        0x08,  # . Usage Page (LEDs)
+        0x19,
+        0x01,  # . Usage Minimum (Num Lock)
+        0x29,
+        0x05,  # . Usage Maximum (Kana)
+        0x91,
+        0x02,  # . Output (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
+        0x95,
+        0x01,  # . Report Count (1)
+        0x75,
+        0x03,  # . Report Size (3)
+        0x91,
+        0x03,  # . Output (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
+        0x95,
+        0x06,  # . Report Count (6)
+        0x75,
+        0x08,  # . Report Size (8)
+        0x15,
+        0x00,  # . Logical Minimum (0)
+        0x25,
+        0x65,  # . Logical Maximum (101)
+        0x05,
+        0x07,  # . Usage Page (Kbrd/Keypad)
+        0x19,
+        0x00,  # . Usage Minimum (0x00)
+        0x29,
+        0x65,  # . Usage Maximum (0x65)
+        0x81,
+        0x00,  # . Input (Data,Array,Abs,No Wrap,Linear,Preferred State,No Null Position)
+        0xC0,  # End Collection
+        0x05,
+        0x01,  # Usage Page (Generic Desktop Ctrls)
+        0x09,
+        0x02,  # Usage (Mouse)
+        0xA1,
+        0x01,  # Collection (Application)
+        0x85,
+        0x02,  # . Report ID (2)
+        0x09,
+        0x01,  # . Usage (Pointer)
+        0xA1,
+        0x00,  # . Collection (Physical)
+        0x05,
+        0x09,  # .   Usage Page (Button)
+        0x19,
+        0x01,  # .   Usage Minimum (0x01)
+        0x29,
+        0x03,  # .   Usage Maximum (0x03)
+        0x15,
+        0x00,  # .   Logical Minimum (0)
+        0x25,
+        0x01,  # .   Logical Maximum (1)
+        0x95,
+        0x03,  # .   Report Count (3)
+        0x75,
+        0x01,  # .   Report Size (1)
+        0x81,
+        0x02,  # .   Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
+        0x95,
+        0x01,  # .   Report Count (1)
+        0x75,
+        0x05,  # .   Report Size (5)
+        0x81,
+        0x03,  # .   Input (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
+        0x05,
+        0x01,  # .   Usage Page (Generic Desktop Ctrls)
+        0x09,
+        0x30,  # .   Usage (X)
+        0x09,
+        0x31,  # .   Usage (Y)
+        0x15,
+        0x81,  # .   Logical Minimum (-127)
+        0x25,
+        0x7F,  # .   Logical Maximum (127)
+        0x75,
+        0x08,  # .   Report Size (8)
+        0x95,
+        0x02,  # .   Report Count (2)
+        0x81,
+        0x06,  # .   Input (Data,Var,Rel,No Wrap,Linear,Preferred State,No Null Position)
+        0xC0,  # . End Collection
+        0xC0,  # End Collection
+    ]
+)
+
+
+# Default protocol mode set to report protocol
+protocol_mode = Message.ProtocolMode.REPORT_PROTOCOL
+
+# -----------------------------------------------------------------------------
+def sdp_records():
+    service_record_handle = 0x00010002
+    return {
+        service_record_handle: [
+            ServiceAttribute(
+                SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+                DataElement.unsigned_integer_32(service_record_handle),
+            ),
+            ServiceAttribute(
+                SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+                DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+            ),
+            ServiceAttribute(
+                SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [DataElement.uuid(BT_HUMAN_INTERFACE_DEVICE_SERVICE)]
+                ),
+            ),
+            ServiceAttribute(
+                SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [
+                        DataElement.sequence(
+                            [
+                                DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
+                                DataElement.unsigned_integer_16(HID_CONTROL_PSM),
+                            ]
+                        ),
+                        DataElement.sequence(
+                            [
+                                DataElement.uuid(BT_HIDP_PROTOCOL_ID),
+                            ]
+                        ),
+                    ]
+                ),
+            ),
+            ServiceAttribute(
+                SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [
+                        DataElement.unsigned_integer_16(LANGUAGE),
+                        DataElement.unsigned_integer_16(ENCODING),
+                        DataElement.unsigned_integer_16(PRIMARY_LANGUAGE_BASE_ID),
+                    ]
+                ),
+            ),
+            ServiceAttribute(
+                SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [
+                        DataElement.sequence(
+                            [
+                                DataElement.uuid(BT_HUMAN_INTERFACE_DEVICE_SERVICE),
+                                DataElement.unsigned_integer_16(VERSION_NUMBER),
+                            ]
+                        ),
+                    ]
+                ),
+            ),
+            ServiceAttribute(
+                SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [
+                        DataElement.sequence(
+                            [
+                                DataElement.sequence(
+                                    [
+                                        DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
+                                        DataElement.unsigned_integer_16(
+                                            HID_INTERRUPT_PSM
+                                        ),
+                                    ]
+                                ),
+                                DataElement.sequence(
+                                    [
+                                        DataElement.uuid(BT_HIDP_PROTOCOL_ID),
+                                    ]
+                                ),
+                            ]
+                        ),
+                    ]
+                ),
+            ),
+            ServiceAttribute(
+                SDP_HID_SERVICE_NAME_ATTRIBUTE_ID,
+                DataElement(DataElement.TEXT_STRING, SERVICE_NAME),
+            ),
+            ServiceAttribute(
+                SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID,
+                DataElement(DataElement.TEXT_STRING, SERVICE_DESCRIPTION),
+            ),
+            ServiceAttribute(
+                SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID,
+                DataElement(DataElement.TEXT_STRING, PROVIDER_NAME),
+            ),
+            ServiceAttribute(
+                SDP_HID_PARSER_VERSION_ATTRIBUTE_ID,
+                DataElement.unsigned_integer_32(HID_PARSER_VERSION),
+            ),
+            ServiceAttribute(
+                SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID,
+                DataElement.unsigned_integer_32(HID_DEVICE_SUBCLASS),
+            ),
+            ServiceAttribute(
+                SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID,
+                DataElement.unsigned_integer_32(HID_COUNTRY_CODE),
+            ),
+            ServiceAttribute(
+                SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID,
+                DataElement.boolean(HID_VIRTUAL_CABLE),
+            ),
+            ServiceAttribute(
+                SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID,
+                DataElement.boolean(HID_RECONNECT_INITIATE),
+            ),
+            ServiceAttribute(
+                SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [
+                        DataElement.sequence(
+                            [
+                                DataElement.unsigned_integer_16(REPORT_DESCRIPTOR_TYPE),
+                                DataElement(DataElement.TEXT_STRING, HID_REPORT_MAP),
+                            ]
+                        ),
+                    ]
+                ),
+            ),
+            ServiceAttribute(
+                SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [
+                        DataElement.sequence(
+                            [
+                                DataElement.unsigned_integer_16(
+                                    HID_LANGID_BASE_LANGUAGE
+                                ),
+                                DataElement.unsigned_integer_16(
+                                    HID_LANGID_BASE_BLUETOOTH_STRING_OFFSET
+                                ),
+                            ]
+                        ),
+                    ]
+                ),
+            ),
+            ServiceAttribute(
+                SDP_HID_BATTERY_POWER_ATTRIBUTE_ID,
+                DataElement.boolean(HID_BATTERY_POWER),
+            ),
+            ServiceAttribute(
+                SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID,
+                DataElement.boolean(HID_REMOTE_WAKE),
+            ),
+            ServiceAttribute(
+                SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID,
+                DataElement.unsigned_integer_16(HID_SUPERVISION_TIMEOUT),
+            ),
+            ServiceAttribute(
+                SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID,
+                DataElement.boolean(HID_NORMALLY_CONNECTABLE),
+            ),
+            ServiceAttribute(
+                SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID,
+                DataElement.boolean(HID_BOOT_DEVICE),
+            ),
+            ServiceAttribute(
+                SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID,
+                DataElement.unsigned_integer_16(HID_SSR_HOST_MAX_LATENCY),
+            ),
+            ServiceAttribute(
+                SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID,
+                DataElement.unsigned_integer_16(HID_SSR_HOST_MIN_TIMEOUT),
+            ),
+        ]
+    }
+
+
+# -----------------------------------------------------------------------------
+async def get_stream_reader(pipe) -> asyncio.StreamReader:
+    loop = asyncio.get_event_loop()
+    reader = asyncio.StreamReader(loop=loop)
+    protocol = asyncio.StreamReaderProtocol(reader)
+    await loop.connect_read_pipe(lambda: protocol, pipe)
+    return reader
+
+
+class DeviceData:
+    def __init__(self) -> None:
+        self.keyboardData = bytearray(
+            [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
+        )
+        self.mouseData = bytearray([0x02, 0x00, 0x00, 0x00])
+
+
+# Device's live data - Mouse and Keyboard will be stored in this
+deviceData = DeviceData()
+
+# -----------------------------------------------------------------------------
+async def keyboard_device(hid_device):
+
+    # Start a Websocket server to receive events from a web page
+    async def serve(websocket, _path):
+        global deviceData
+        while True:
+            try:
+                message = await websocket.recv()
+                print('Received: ', str(message))
+                parsed = json.loads(message)
+                message_type = parsed['type']
+                if message_type == 'keydown':
+                    # Only deal with keys a to z for now
+                    key = parsed['key']
+                    if len(key) == 1:
+                        code = ord(key)
+                        if ord('a') <= code <= ord('z'):
+                            hid_code = 0x04 + code - ord('a')
+                            deviceData.keyboardData = bytearray(
+                                [
+                                    0x01,
+                                    0x00,
+                                    0x00,
+                                    hid_code,
+                                    0x00,
+                                    0x00,
+                                    0x00,
+                                    0x00,
+                                    0x00,
+                                ]
+                            )
+                            hid_device.send_data(deviceData.keyboardData)
+                elif message_type == 'keyup':
+                    deviceData.keyboardData = bytearray(
+                        [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
+                    )
+                    hid_device.send_data(deviceData.keyboardData)
+                elif message_type == "mousemove":
+                    # logical min and max values
+                    log_min = -127
+                    log_max = 127
+                    x = parsed['x']
+                    y = parsed['y']
+                    # limiting x and y values within logical max and min range
+                    x = max(log_min, min(log_max, x))
+                    y = max(log_min, min(log_max, y))
+                    x_cord = x.to_bytes(signed=True)
+                    y_cord = y.to_bytes(signed=True)
+                    deviceData.mouseData = bytearray([0x02, 0x00]) + x_cord + y_cord
+                    hid_device.send_data(deviceData.mouseData)
+            except websockets.exceptions.ConnectionClosedOK:
+                pass
+
+    # pylint: disable-next=no-member
+    await websockets.serve(serve, 'localhost', 8989)
+    await asyncio.get_event_loop().create_future()
+
+
+# -----------------------------------------------------------------------------
+async def main():
+    if len(sys.argv) < 3:
+        print(
+            'Usage: python run_hid_device.py <device-config> <transport-spec> <command>'
+            '  where <command> is one of:\n'
+            '  test-mode (run with menu enabled for testing)\n'
+            '  web (run a keyboard with keypress input from a web page, '
+            'see keyboard.html'
+        )
+        print('example: python run_hid_device.py hid_keyboard.json usb:0 web')
+        print('example: python run_hid_device.py hid_keyboard.json usb:0 test-mode')
+
+        return
+
+    async def handle_virtual_cable_unplug():
+        hid_host_bd_addr = str(hid_device.remote_device_bd_address)
+        await hid_device.disconnect_interrupt_channel()
+        await hid_device.disconnect_control_channel()
+        await device.keystore.delete(hid_host_bd_addr)  # type: ignore
+        connection = hid_device.connection
+        if connection is not None:
+            await connection.disconnect()
+
+    def on_hid_data_cb(pdu: bytes):
+        print(f'Received Data, PDU: {pdu.hex()}')
+
+    def on_get_report_cb(report_id: int, report_type: int, buffer_size: int):
+        retValue = hid_device.GetSetStatus()
+        print(
+            "GET_REPORT report_id: "
+            + str(report_id)
+            + "report_type: "
+            + str(report_type)
+            + "buffer_size:"
+            + str(buffer_size)
+        )
+        if report_type == Message.ReportType.INPUT_REPORT:
+            if report_id == 1:
+                retValue.data = deviceData.keyboardData[1:]
+                retValue.status = hid_device.GetSetReturn.SUCCESS
+            elif report_id == 2:
+                retValue.data = deviceData.mouseData[1:]
+                retValue.status = hid_device.GetSetReturn.SUCCESS
+            else:
+                retValue.status = hid_device.GetSetReturn.REPORT_ID_NOT_FOUND
+
+            if buffer_size:
+                data_len = buffer_size - 1
+                retValue.data = retValue.data[:data_len]
+        elif report_type == Message.ReportType.OUTPUT_REPORT:
+            # This sample app has nothing to do with the report received, to enable PTS
+            # testing, we will return single byte random data.
+            retValue.data = bytearray([0x11])
+            retValue.status = hid_device.GetSetReturn.SUCCESS
+        elif report_type == Message.ReportType.FEATURE_REPORT:
+            retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+        elif report_type == Message.ReportType.OTHER_REPORT:
+            if report_id == 3:
+                retValue.status = hid_device.GetSetReturn.REPORT_ID_NOT_FOUND
+        else:
+            retValue.status = hid_device.GetSetReturn.FAILURE
+
+        return retValue
+
+    def on_set_report_cb(
+        report_id: int, report_type: int, report_size: int, data: bytes
+    ):
+        retValue = hid_device.GetSetStatus()
+        print(
+            "SET_REPORT report_id: "
+            + str(report_id)
+            + "report_type: "
+            + str(report_type)
+            + "report_size "
+            + str(report_size)
+            + "data:"
+            + str(data)
+        )
+        if report_type == Message.ReportType.FEATURE_REPORT:
+            retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+        elif report_type == Message.ReportType.INPUT_REPORT:
+            if report_id == 1 and report_size != len(deviceData.keyboardData):
+                retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+            elif report_id == 2 and report_size != len(deviceData.mouseData):
+                retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+            elif report_id == 3:
+                retValue.status = hid_device.GetSetReturn.REPORT_ID_NOT_FOUND
+            else:
+                retValue.status = hid_device.GetSetReturn.SUCCESS
+        else:
+            retValue.status = hid_device.GetSetReturn.SUCCESS
+
+        return retValue
+
+    def on_get_protocol_cb():
+        retValue = hid_device.GetSetStatus()
+        retValue.data = protocol_mode.to_bytes()
+        retValue.status = hid_device.GetSetReturn.SUCCESS
+        return retValue
+
+    def on_set_protocol_cb(protocol: int):
+        retValue = hid_device.GetSetStatus()
+        # We do not support SET_PROTOCOL.
+        print(f"SET_PROTOCOL report_id: {protocol}")
+        retValue.status = hid_device.GetSetReturn.ERR_UNSUPPORTED_REQUEST
+        return retValue
+
+    def on_virtual_cable_unplug_cb():
+        print('Received Virtual Cable Unplug')
+        asyncio.create_task(handle_virtual_cable_unplug())
+
+    print('<<< connecting to HCI...')
+    async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
+        print('<<< connected')
+
+        # Create a device
+        device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
+        device.classic_enabled = True
+
+        # Create and register HID device
+        hid_device = HID_Device(device)
+
+        # Register for  call backs
+        hid_device.on('interrupt_data', on_hid_data_cb)
+
+        hid_device.register_get_report_cb(on_get_report_cb)
+        hid_device.register_set_report_cb(on_set_report_cb)
+        hid_device.register_get_protocol_cb(on_get_protocol_cb)
+        hid_device.register_set_protocol_cb(on_set_protocol_cb)
+
+        # Register for virtual cable unplug call back
+        hid_device.on('virtual_cable_unplug', on_virtual_cable_unplug_cb)
+
+        # Setup the SDP to advertise HID Device service
+        device.sdp_service_records = sdp_records()
+
+        # Start the controller
+        await device.power_on()
+
+        # Start being discoverable and connectable
+        await device.set_discoverable(True)
+        await device.set_connectable(True)
+
+        async def menu():
+            reader = await get_stream_reader(sys.stdin)
+            while True:
+                print(
+                    "\n************************ HID Device Menu *****************************\n"
+                )
+                print(" 1. Connect Control Channel")
+                print(" 2. Connect Interrupt Channel")
+                print(" 3. Disconnect Control Channel")
+                print(" 4. Disconnect Interrupt Channel")
+                print(" 5. Send Report on Interrupt Channel")
+                print(" 6. Virtual Cable Unplug")
+                print(" 7. Disconnect device")
+                print(" 8. Delete Bonding")
+                print(" 9. Re-connect to device")
+                print("10. Exit ")
+                print("\nEnter your choice : \n")
+
+                choice = await reader.readline()
+                choice = choice.decode('utf-8').strip()
+
+                if choice == '1':
+                    await hid_device.connect_control_channel()
+
+                elif choice == '2':
+                    await hid_device.connect_interrupt_channel()
+
+                elif choice == '3':
+                    await hid_device.disconnect_control_channel()
+
+                elif choice == '4':
+                    await hid_device.disconnect_interrupt_channel()
+
+                elif choice == '5':
+                    print(" 1. Report ID 0x01")
+                    print(" 2. Report ID 0x02")
+                    print(" 3. Invalid Report ID")
+
+                    choice1 = await reader.readline()
+                    choice1 = choice1.decode('utf-8').strip()
+
+                    if choice1 == '1':
+                        data = bytearray(
+                            [0x01, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]
+                        )
+                        hid_device.send_data(data)
+                        data = bytearray(
+                            [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
+                        )
+                        hid_device.send_data(data)
+
+                    elif choice1 == '2':
+                        data = bytearray([0x02, 0x00, 0x00, 0xF6])
+                        hid_device.send_data(data)
+                        data = bytearray([0x02, 0x00, 0x00, 0x00])
+                        hid_device.send_data(data)
+
+                    elif choice1 == '3':
+                        data = bytearray([0x00, 0x00, 0x00, 0x00])
+                        hid_device.send_data(data)
+                        data = bytearray([0x00, 0x00, 0x00, 0x00])
+                        hid_device.send_data(data)
+
+                    else:
+                        print('Incorrect option selected')
+
+                elif choice == '6':
+                    hid_device.virtual_cable_unplug()
+                    try:
+                        hid_host_bd_addr = str(hid_device.remote_device_bd_address)
+                        await device.keystore.delete(hid_host_bd_addr)
+                    except KeyError:
+                        print('Device not found or Device already unpaired.')
+
+                elif choice == '7':
+                    connection = hid_device.connection
+                    if connection is not None:
+                        await connection.disconnect()
+                    else:
+                        print("Already disconnected from device")
+
+                elif choice == '8':
+                    try:
+                        hid_host_bd_addr = str(hid_device.remote_device_bd_address)
+                        await device.keystore.delete(hid_host_bd_addr)
+                    except KeyError:
+                        print('Device NOT found or Device already unpaired.')
+
+                elif choice == '9':
+                    hid_host_bd_addr = str(hid_device.remote_device_bd_address)
+                    connection = await device.connect(
+                        hid_host_bd_addr, transport=BT_BR_EDR_TRANSPORT
+                    )
+                    await connection.authenticate()
+                    await connection.encrypt()
+
+                elif choice == '10':
+                    sys.exit("Exit successful")
+
+                else:
+                    print("Invalid option selected.")
+
+        if (len(sys.argv) > 3) and (sys.argv[3] == 'test-mode'):
+            # Test mode for PTS/Unit testing
+            await menu()
+        else:
+            # default option is using keyboard.html (web)
+            print("Executing in Web mode")
+            await keyboard_device(hid_device)
+
+        await hci_source.wait_for_termination()
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_hid_host.py b/examples/run_hid_host.py
index a174444..7519b4e 100644
--- a/examples/run_hid_host.py
+++ b/examples/run_hid_host.py
@@ -22,12 +22,9 @@
 
 from bumble.colors import color
 
-import bumble.core
 from bumble.device import Device
 from bumble.transport import open_transport_or_link
 from bumble.core import (
-    BT_L2CAP_PROTOCOL_ID,
-    BT_HIDP_PROTOCOL_ID,
     BT_HUMAN_INTERFACE_DEVICE_SERVICE,
     BT_BR_EDR_TRANSPORT,
 )
@@ -35,8 +32,6 @@
 from bumble.hid import Host, Message
 from bumble.sdp import (
     Client as SDP_Client,
-    DataElement,
-    ServiceAttribute,
     SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
     SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
     SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -75,11 +70,11 @@
 # -----------------------------------------------------------------------------
 
 
-async def get_hid_device_sdp_record(device, connection):
+async def get_hid_device_sdp_record(connection):
 
     # Connect to the SDP Server
-    sdp_client = SDP_Client(device)
-    await sdp_client.connect(connection)
+    sdp_client = SDP_Client(connection)
+    await sdp_client.connect()
     if sdp_client:
         print(color('Connected to SDP Server', 'blue'))
     else:
@@ -290,7 +285,10 @@
         print('example: run_hid_host.py classic1.json usb:0 E1:CA:72:48:C4:E8/P')
         return
 
-    def on_hid_data_cb(pdu):
+    def on_hid_control_data_cb(pdu: bytes):
+        print(f'Received Control Data, PDU: {pdu.hex()}')
+
+    def on_hid_interrupt_data_cb(pdu: bytes):
         report_type = pdu[0] & 0x0F
         if len(pdu) == 1:
             print(color(f'Warning: No report received', 'yellow'))
@@ -310,7 +308,7 @@
 
         if (report_length <= 1) or (report_id == 0):
             return
-
+        # Parse report over interrupt channel
         if report_type == Message.ReportType.INPUT_REPORT:
             ReportParser.parse_input_report(pdu[1:])  # type: ignore
 
@@ -318,7 +316,9 @@
         await hid_host.disconnect_interrupt_channel()
         await hid_host.disconnect_control_channel()
         await device.keystore.delete(target_address)  # type: ignore
-        await connection.disconnect()
+        connection = hid_host.connection
+        if connection is not None:
+            await connection.disconnect()
 
     def on_hid_virtual_cable_unplug_cb():
         asyncio.create_task(handle_virtual_cable_unplug())
@@ -330,6 +330,18 @@
         # Create a device
         device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
         device.classic_enabled = True
+
+        # Create HID host and start it
+        print('@@@ Starting HID Host...')
+        hid_host = Host(device)
+
+        # Register for HID data call back
+        hid_host.on('interrupt_data', on_hid_interrupt_data_cb)
+        hid_host.on('control_data', on_hid_control_data_cb)
+
+        # Register for virtual cable unplug call back
+        hid_host.on('virtual_cable_unplug', on_hid_virtual_cable_unplug_cb)
+
         await device.power_on()
 
         # Connect to a peer
@@ -348,17 +360,7 @@
         await connection.encrypt()
         print('*** Encryption on')
 
-        await get_hid_device_sdp_record(device, connection)
-
-        # Create HID host and start it
-        print('@@@ Starting HID Host...')
-        hid_host = Host(device, connection)
-
-        # Register for HID data call back
-        hid_host.on('data', on_hid_data_cb)
-
-        # Register for virtual cable unplug call back
-        hid_host.on('virtual_cable_unplug', on_hid_virtual_cable_unplug_cb)
+        await get_hid_device_sdp_record(connection)
 
         async def menu():
             reader = await get_stream_reader(sys.stdin)
@@ -374,13 +376,14 @@
                 print(" 6. Set Report")
                 print(" 7. Set Protocol Mode")
                 print(" 8. Get Protocol Mode")
-                print(" 9. Send Report")
+                print(" 9. Send Report on Interrupt Channel")
                 print("10. Suspend")
                 print("11. Exit Suspend")
                 print("12. Virtual Cable Unplug")
                 print("13. Disconnect device")
                 print("14. Delete Bonding")
                 print("15. Re-connect to device")
+                print("16. Exit")
                 print("\nEnter your choice : \n")
 
                 choice = await reader.readline()
@@ -399,21 +402,40 @@
                     await hid_host.disconnect_interrupt_channel()
 
                 elif choice == '5':
-                    print(" 1. Report ID 0x02")
-                    print(" 2. Report ID 0x03")
-                    print(" 3. Report ID 0x05")
+                    print(" 1. Input Report with ID 0x01")
+                    print(" 2. Input Report with ID 0x02")
+                    print(" 3. Input Report with ID 0x0F - Invalid ReportId")
+                    print(" 4. Output Report with ID 0x02")
+                    print(" 5. Feature Report with ID 0x05 - Unsupported Request")
+                    print(" 6. Input Report with ID 0x02, BufferSize 3")
+                    print(" 7. Output Report with ID 0x03, BufferSize 2")
+                    print(" 8. Feature Report with ID 0x05,  BufferSize 3")
                     choice1 = await reader.readline()
                     choice1 = choice1.decode('utf-8').strip()
 
                     if choice1 == '1':
-                        hid_host.get_report(1, 2, 3)
+                        hid_host.get_report(1, 1, 0)
 
                     elif choice1 == '2':
-                        hid_host.get_report(2, 3, 2)
+                        hid_host.get_report(1, 2, 0)
 
                     elif choice1 == '3':
-                        hid_host.get_report(3, 5, 3)
+                        hid_host.get_report(1, 5, 0)
 
+                    elif choice1 == '4':
+                        hid_host.get_report(2, 2, 0)
+
+                    elif choice1 == '5':
+                        hid_host.get_report(3, 15, 0)
+
+                    elif choice1 == '6':
+                        hid_host.get_report(1, 2, 3)
+
+                    elif choice1 == '7':
+                        hid_host.get_report(2, 3, 2)
+
+                    elif choice1 == '8':
+                        hid_host.get_report(3, 5, 3)
                     else:
                         print('Incorrect option selected')
 
@@ -489,6 +511,7 @@
                     hid_host.virtual_cable_unplug()
                     try:
                         await device.keystore.delete(target_address)
+                        print("Unpair successful")
                     except KeyError:
                         print('Device not found or Device already unpaired.')
 
@@ -518,6 +541,9 @@
                     await connection.authenticate()
                     await connection.encrypt()
 
+                elif choice == '16':
+                    sys.exit("Exit successful")
+
                 else:
                     print("Invalid option selected.")
 
diff --git a/examples/run_rfcomm_client.py b/examples/run_rfcomm_client.py
index 9a94278..39ee776 100644
--- a/examples/run_rfcomm_client.py
+++ b/examples/run_rfcomm_client.py
@@ -42,10 +42,10 @@
 
 
 # -----------------------------------------------------------------------------
-async def list_rfcomm_channels(device, connection):
+async def list_rfcomm_channels(connection):
     # Connect to the SDP Server
-    sdp_client = SDP_Client(device)
-    await sdp_client.connect(connection)
+    sdp_client = SDP_Client(connection)
+    await sdp_client.connect()
 
     # Search for services with an L2CAP service attribute
     search_result = await sdp_client.search_attributes(
@@ -194,7 +194,7 @@
 
         channel = sys.argv[4]
         if channel == 'discover':
-            await list_rfcomm_channels(device, connection)
+            await list_rfcomm_channels(connection)
             return
 
         # Request authentication
@@ -209,7 +209,7 @@
 
         # Create a client and start it
         print('@@@ Starting RFCOMM client...')
-        rfcomm_client = Client(device, connection)
+        rfcomm_client = Client(connection)
         rfcomm_mux = await rfcomm_client.start()
         print('@@@ Started')
 
diff --git a/examples/run_unicast_server.py b/examples/run_unicast_server.py
new file mode 100644
index 0000000..4fac1d6
--- /dev/null
+++ b/examples/run_unicast_server.py
@@ -0,0 +1,190 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+import struct
+import secrets
+from bumble.core import AdvertisingData
+from bumble.device import Device, CisLink, AdvertisingParameters
+from bumble.hci import (
+    CodecID,
+    CodingFormat,
+    OwnAddressType,
+    HCI_IsoDataPacket,
+)
+from bumble.profiles.bap import (
+    CodecSpecificCapabilities,
+    ContextType,
+    AudioLocation,
+    SupportedSamplingFrequency,
+    SupportedFrameDuration,
+    PacRecord,
+    PublishedAudioCapabilitiesService,
+    AudioStreamControlService,
+)
+from bumble.profiles.cap import CommonAudioServiceService
+from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+    if len(sys.argv) < 3:
+        print('Usage: run_cig_setup.py <config-file>' '<transport-spec-for-device>')
+        return
+
+    print('<<< connecting to HCI...')
+    async with await open_transport_or_link(sys.argv[2]) as hci_transport:
+        print('<<< connected')
+
+        device = Device.from_config_file_with_hci(
+            sys.argv[1], hci_transport.source, hci_transport.sink
+        )
+        device.cis_enabled = True
+
+        await device.power_on()
+
+        csis = CoordinatedSetIdentificationService(
+            set_identity_resolving_key=secrets.token_bytes(16),
+            set_identity_resolving_key_type=SirkType.PLAINTEXT,
+        )
+        device.add_service(CommonAudioServiceService(csis))
+        device.add_service(
+            PublishedAudioCapabilitiesService(
+                supported_source_context=ContextType.PROHIBITED,
+                available_source_context=ContextType.PROHIBITED,
+                supported_sink_context=ContextType.MEDIA,
+                available_sink_context=ContextType.MEDIA,
+                sink_audio_locations=(
+                    AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
+                ),
+                sink_pac=[
+                    # Codec Capability Setting 16_2
+                    PacRecord(
+                        coding_format=CodingFormat(CodecID.LC3),
+                        codec_specific_capabilities=CodecSpecificCapabilities(
+                            supported_sampling_frequencies=(
+                                SupportedSamplingFrequency.FREQ_16000
+                            ),
+                            supported_frame_durations=(
+                                SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+                            ),
+                            supported_audio_channel_counts=[1],
+                            min_octets_per_codec_frame=40,
+                            max_octets_per_codec_frame=40,
+                            supported_max_codec_frames_per_sdu=1,
+                        ),
+                    ),
+                    # Codec Capability Setting 24_2
+                    PacRecord(
+                        coding_format=CodingFormat(CodecID.LC3),
+                        codec_specific_capabilities=CodecSpecificCapabilities(
+                            supported_sampling_frequencies=(
+                                SupportedSamplingFrequency.FREQ_48000
+                            ),
+                            supported_frame_durations=(
+                                SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+                            ),
+                            supported_audio_channel_counts=[1],
+                            min_octets_per_codec_frame=120,
+                            max_octets_per_codec_frame=120,
+                            supported_max_codec_frames_per_sdu=1,
+                        ),
+                    ),
+                ],
+            )
+        )
+
+        device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2]))
+
+        advertising_data = (
+            bytes(
+                AdvertisingData(
+                    [
+                        (
+                            AdvertisingData.COMPLETE_LOCAL_NAME,
+                            bytes('Bumble LE Audio', 'utf-8'),
+                        ),
+                        (
+                            AdvertisingData.FLAGS,
+                            bytes(
+                                [
+                                    AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
+                                    | AdvertisingData.BR_EDR_HOST_FLAG
+                                    | AdvertisingData.BR_EDR_CONTROLLER_FLAG
+                                ]
+                            ),
+                        ),
+                        (
+                            AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+                            bytes(PublishedAudioCapabilitiesService.UUID),
+                        ),
+                    ]
+                )
+            )
+            + csis.get_advertising_data()
+        )
+        subprocess = await asyncio.create_subprocess_shell(
+            f'dlc3 | ffplay pipe:0',
+            stdin=asyncio.subprocess.PIPE,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        stdin = subprocess.stdin
+        assert stdin
+
+        # Write a fake LC3 header to dlc3.
+        stdin.write(
+            bytes([0x1C, 0xCC])  # Header.
+            + struct.pack(
+                '<HHHHHHI',
+                18,  # Header length.
+                48000 // 100,  # Sampling Rate(/100Hz).
+                0,  # Bitrate(unused).
+                1,  # Channels.
+                10000 // 10,  # Frame duration(/10us).
+                0,  # RFU.
+                0x0FFFFFFF,  # Frame counts.
+            )
+        )
+
+        def on_pdu(pdu: HCI_IsoDataPacket):
+            # LC3 format: |frame_length(2)| + |frame(length)|.
+            if pdu.iso_sdu_length:
+                stdin.write(struct.pack('<H', pdu.iso_sdu_length))
+            stdin.write(pdu.iso_sdu_fragment)
+
+        def on_cis(cis_link: CisLink):
+            cis_link.on('pdu', on_pdu)
+
+        device.once('cis_establishment', on_cis)
+
+        advertising_set = await device.create_advertising_set(
+            advertising_data=advertising_data,
+        )
+
+        await hci_transport.source.terminated
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_vcp_renderer.py b/examples/run_vcp_renderer.py
new file mode 100644
index 0000000..b695956
--- /dev/null
+++ b/examples/run_vcp_renderer.py
@@ -0,0 +1,191 @@
+# Copyright 2021-2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+import secrets
+import websockets
+import json
+
+from bumble.core import AdvertisingData
+from bumble.device import Device, AdvertisingParameters, AdvertisingEventProperties
+from bumble.hci import (
+    CodecID,
+    CodingFormat,
+    OwnAddressType,
+)
+from bumble.profiles.bap import (
+    CodecSpecificCapabilities,
+    ContextType,
+    AudioLocation,
+    SupportedSamplingFrequency,
+    SupportedFrameDuration,
+    PacRecord,
+    PublishedAudioCapabilitiesService,
+    AudioStreamControlService,
+)
+from bumble.profiles.cap import CommonAudioServiceService
+from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
+from bumble.profiles.vcp import VolumeControlService
+
+from bumble.transport import open_transport_or_link
+
+from typing import Optional
+
+
+def dumps_volume_state(volume_setting: int, muted: int, change_counter: int) -> str:
+    return json.dumps(
+        {
+            'volume_setting': volume_setting,
+            'muted': muted,
+            'change_counter': change_counter,
+        }
+    )
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+    if len(sys.argv) < 3:
+        print('Usage: run_vcp_renderer.py <config-file>' '<transport-spec-for-device>')
+        return
+
+    print('<<< connecting to HCI...')
+    async with await open_transport_or_link(sys.argv[2]) as hci_transport:
+        print('<<< connected')
+
+        device = Device.from_config_file_with_hci(
+            sys.argv[1], hci_transport.source, hci_transport.sink
+        )
+
+        await device.power_on()
+
+        # Add "placeholder" services to enable Android LEA features.
+        csis = CoordinatedSetIdentificationService(
+            set_identity_resolving_key=secrets.token_bytes(16),
+            set_identity_resolving_key_type=SirkType.PLAINTEXT,
+        )
+        device.add_service(CommonAudioServiceService(csis))
+        device.add_service(
+            PublishedAudioCapabilitiesService(
+                supported_source_context=ContextType.PROHIBITED,
+                available_source_context=ContextType.PROHIBITED,
+                supported_sink_context=ContextType.MEDIA,
+                available_sink_context=ContextType.MEDIA,
+                sink_audio_locations=(
+                    AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
+                ),
+                sink_pac=[
+                    # Codec Capability Setting 48_4
+                    PacRecord(
+                        coding_format=CodingFormat(CodecID.LC3),
+                        codec_specific_capabilities=CodecSpecificCapabilities(
+                            supported_sampling_frequencies=(
+                                SupportedSamplingFrequency.FREQ_48000
+                            ),
+                            supported_frame_durations=(
+                                SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+                            ),
+                            supported_audio_channel_counts=[1],
+                            min_octets_per_codec_frame=120,
+                            max_octets_per_codec_frame=120,
+                            supported_max_codec_frames_per_sdu=1,
+                        ),
+                    ),
+                ],
+            )
+        )
+        device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2]))
+
+        vcs = VolumeControlService()
+        device.add_service(vcs)
+
+        ws: Optional[websockets.WebSocketServerProtocol] = None
+
+        def on_volume_state(volume_setting: int, muted: int, change_counter: int):
+            if ws:
+                asyncio.create_task(
+                    ws.send(dumps_volume_state(volume_setting, muted, change_counter))
+                )
+
+        vcs.on('volume_state', on_volume_state)
+
+        advertising_data = (
+            bytes(
+                AdvertisingData(
+                    [
+                        (
+                            AdvertisingData.COMPLETE_LOCAL_NAME,
+                            bytes('Bumble LE Audio', 'utf-8'),
+                        ),
+                        (
+                            AdvertisingData.FLAGS,
+                            bytes(
+                                [
+                                    AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
+                                    | AdvertisingData.BR_EDR_HOST_FLAG
+                                    | AdvertisingData.BR_EDR_CONTROLLER_FLAG
+                                ]
+                            ),
+                        ),
+                        (
+                            AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+                            bytes(PublishedAudioCapabilitiesService.UUID),
+                        ),
+                    ]
+                )
+            )
+            + csis.get_advertising_data()
+        )
+
+        await device.create_advertising_set(
+            advertising_parameters=AdvertisingParameters(
+                advertising_event_properties=AdvertisingEventProperties(),
+                own_address_type=OwnAddressType.PUBLIC,
+            ),
+            advertising_data=advertising_data,
+        )
+
+        async def serve(websocket: websockets.WebSocketServerProtocol, _path):
+            nonlocal ws
+            await websocket.send(
+                dumps_volume_state(vcs.volume_setting, vcs.muted, vcs.change_counter)
+            )
+            ws = websocket
+            async for message in websocket:
+                volume_state = json.loads(message)
+                vcs.volume_state_bytes = bytes(
+                    [
+                        volume_state['volume_setting'],
+                        volume_state['muted'],
+                        volume_state['change_counter'],
+                    ]
+                )
+                await device.notify_subscribers(
+                    vcs.volume_state, vcs.volume_state_bytes
+                )
+            ws = None
+
+        await websockets.serve(serve, 'localhost', 8989)
+
+        await hci_transport.source.terminated
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/vcp_renderer.html b/examples/vcp_renderer.html
new file mode 100644
index 0000000..c438950
--- /dev/null
+++ b/examples/vcp_renderer.html
@@ -0,0 +1,103 @@
+<html data-bs-theme="dark">
+
+<head>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
+        integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
+
+</head>
+
+<body>
+
+    <div class="container">
+
+        <label for="server-port" class="form-label">Server Port</label>
+        <div class="input-group mb-3">
+            <input type="text" class="form-control" aria-label="Port Number" value="8989" id="port">
+            <button class="btn btn-primary" type="button" onclick="connect()">Connect</button>
+        </div>
+
+        <div class="row">
+            <div class="col">
+                <label for="volume_setting" class="form-label">Volume Setting</label>
+                <input type="range" class="form-range" min="0" max="255" id="volume_setting">
+            </div>
+            <div class="col">
+                <label for="change_counter" class="form-label">Change Counter</label>
+                <input type="range" class="form-range" min="0" max="255" id="change_counter">
+            </div>
+            <div class="col">
+                <div class="form-check form-switch">
+                    <input class="form-check-input" type="checkbox" role="switch" id="muted">
+                    <label class="form-check-label" for="muted">Muted</label>
+                </div>
+            </div>
+        </div>
+
+        <button class="btn btn-primary" type="button" onclick="update_state()">Notify New Volume State</button>
+
+
+        <hr>
+        <div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
+            <h3>Log</h3>
+            <code id="socketState">
+            </code>
+        </div>
+    </div>
+
+    <script>
+        let portInput = document.getElementById("port")
+        let volumeSetting = document.getElementById("volume_setting")
+        let muted = document.getElementById("muted")
+        let changeCounter = document.getElementById("change_counter")
+        let socket = null
+
+        function connect() {
+            if (socket != null) {
+                return
+            }
+            socket = new WebSocket(`ws://localhost:${portInput.value}`);
+            socket.onopen = _ => {
+                socketState.innerText += 'OPEN\n'
+            }
+            socket.onclose = _ => {
+                socketState.innerText += 'CLOSED\n'
+                socket = null
+            }
+            socket.onerror = (error) => {
+                socketState.innerText += 'ERROR\n'
+                console.log(`ERROR: ${error}`)
+            }
+            socket.onmessage = (event) => {
+                socketState.innerText += `<- ${event.data}\n`
+                let volume_state = JSON.parse(event.data)
+                volumeSetting.value = volume_state.volume_setting
+                changeCounter.value = volume_state.change_counter
+                muted.checked = volume_state.muted ? true : false
+            }
+        }
+
+        function send(message) {
+            if (socket && socket.readyState == WebSocket.OPEN) {
+                let jsonMessage = JSON.stringify(message)
+                socketState.innerText += `-> ${jsonMessage}\n`
+                socket.send(jsonMessage)
+            } else {
+                socketState.innerText += 'NOT CONNECTED\n'
+            }
+        }
+
+        function update_state() {
+            send({
+                volume_setting: parseInt(volumeSetting.value),
+                change_counter: parseInt(changeCounter.value),
+                muted: muted.checked ? 1 : 0
+            })
+        }
+    </script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
+        integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
+        crossorigin="anonymous"></script>
+
+</body>
+
+</html>
\ No newline at end of file
diff --git a/extras/android/BtBench/.gitignore b/extras/android/BtBench/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/extras/android/BtBench/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/extras/android/BtBench/app/.gitignore b/extras/android/BtBench/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/extras/android/BtBench/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/extras/android/BtBench/app/build.gradle.kts b/extras/android/BtBench/app/build.gradle.kts
new file mode 100644
index 0000000..ffde197
--- /dev/null
+++ b/extras/android/BtBench/app/build.gradle.kts
@@ -0,0 +1,70 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+    alias(libs.plugins.androidApplication)
+    alias(libs.plugins.kotlinAndroid)
+}
+
+android {
+    namespace = "com.github.google.bumble.btbench"
+    compileSdk = 34
+
+    defaultConfig {
+        applicationId = "com.github.google.bumble.btbench"
+        minSdk = 30
+        targetSdk = 34
+        versionCode = 1
+        versionName = "1.0"
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        vectorDrawables {
+            useSupportLibrary = true
+        }
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_1_8
+        targetCompatibility = JavaVersion.VERSION_1_8
+    }
+    kotlinOptions {
+        jvmTarget = "1.8"
+    }
+    buildFeatures {
+        compose = true
+    }
+    composeOptions {
+        kotlinCompilerExtensionVersion = "1.5.1"
+    }
+    packaging {
+        resources {
+            excludes += "/META-INF/{AL2.0,LGPL2.1}"
+        }
+    }
+}
+
+dependencies {
+
+    implementation(libs.core.ktx)
+    implementation(libs.lifecycle.runtime.ktx)
+    implementation(libs.activity.compose)
+    implementation(platform(libs.compose.bom))
+    implementation(libs.ui)
+    implementation(libs.ui.graphics)
+    implementation(libs.ui.tooling.preview)
+    implementation(libs.material3)
+    testImplementation(libs.junit)
+    androidTestImplementation(libs.androidx.test.ext.junit)
+    androidTestImplementation(libs.espresso.core)
+    androidTestImplementation(platform(libs.compose.bom))
+    androidTestImplementation(libs.ui.test.junit4)
+    debugImplementation(libs.ui.tooling)
+    debugImplementation(libs.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/proguard-rules.pro b/extras/android/BtBench/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/extras/android/BtBench/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/AndroidManifest.xml b/extras/android/BtBench/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c77eb1a
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.github.google.bumble.btbench">
+     <uses-sdk android:minSdkVersion="30" android:targetSdkVersion="34" />
+    <!-- Request legacy Bluetooth permissions on older devices. -->
+    <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
+
+    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
+    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+
+    <uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
+    <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
+
+    <application
+        android:allowBackup="true"
+        android:dataExtractionRules="@xml/data_extraction_rules"
+        android:fullBackupContent="@xml/backup_rules"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.BTBench"
+        >
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:theme="@style/Theme.BTBench">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+<!--        <profileable android:shell="true"/>-->
+    </application>
+
+</manifest>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/ic_launcher-playstore.png b/extras/android/BtBench/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..d27fdd2
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/ic_launcher-playstore.png
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt
new file mode 100644
index 0000000..95cdae6
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt
@@ -0,0 +1,101 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothProfile
+import android.content.Context
+import android.os.Build
+import java.util.logging.Logger
+
+private val Log = Logger.getLogger("btbench.l2cap-client")
+
+class L2capClient(
+    private val viewModel: AppViewModel,
+    private val bluetoothAdapter: BluetoothAdapter,
+    private val context: Context
+) {
+    @SuppressLint("MissingPermission")
+    fun run() {
+        viewModel.running = true
+        val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
+        val address = viewModel.peerBluetoothAddress.take(17)
+        val remoteDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            bluetoothAdapter.getRemoteLeDevice(
+                address,
+                if (addressIsPublic) {
+                    BluetoothDevice.ADDRESS_TYPE_PUBLIC
+                } else {
+                    BluetoothDevice.ADDRESS_TYPE_RANDOM
+                }
+            )
+        } else {
+            bluetoothAdapter.getRemoteDevice(address)
+        }
+
+        val gatt = remoteDevice.connectGatt(
+            context,
+            false,
+            object : BluetoothGattCallback() {
+                override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
+                    Log.info("MTU update: mtu=$mtu status=$status")
+                    viewModel.mtu = mtu
+                }
+
+                override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
+                    Log.info("PHY update: tx=$txPhy, rx=$rxPhy, status=$status")
+                    viewModel.txPhy = txPhy
+                    viewModel.rxPhy = rxPhy
+                }
+
+                override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
+                    Log.info("PHY: tx=$txPhy, rx=$rxPhy, status=$status")
+                    viewModel.txPhy = txPhy
+                    viewModel.rxPhy = rxPhy
+                }
+
+                override fun onConnectionStateChange(
+                    gatt: BluetoothGatt?, status: Int, newState: Int
+                ) {
+                    if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
+                        if (viewModel.use2mPhy) {
+                            gatt.setPreferredPhy(
+                                BluetoothDevice.PHY_LE_2M_MASK,
+                                BluetoothDevice.PHY_LE_2M_MASK,
+                                BluetoothDevice.PHY_OPTION_NO_PREFERRED
+                            )
+                        }
+                        gatt.readPhy()
+
+                        // Request an MTU update, even though we don't use GATT, because Android
+                        // won't request a larger link layer maximum data length otherwise.
+                        gatt.requestMtu(517)
+                    }
+                }
+            },
+            BluetoothDevice.TRANSPORT_LE,
+            if (viewModel.use2mPhy) BluetoothDevice.PHY_LE_2M_MASK else BluetoothDevice.PHY_LE_1M_MASK
+        )
+
+        val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
+
+        val client = SocketClient(viewModel, socket)
+        client.run()
+    }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt
new file mode 100644
index 0000000..76c297b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt
@@ -0,0 +1,61 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.AdvertiseData
+import android.bluetooth.le.AdvertiseSettings
+import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
+import android.os.Build
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.l2cap-server")
+
+class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
+    @SuppressLint("MissingPermission")
+    fun run() {
+        // Advertise so that the peer can find us and connect.
+        val callback = object: AdvertiseCallback() {
+            override fun onStartFailure(errorCode: Int) {
+                Log.warning("failed to start advertising: $errorCode")
+            }
+
+            override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
+                Log.info("advertising started: $settingsInEffect")
+            }
+        }
+        val advertiseSettingsBuilder = AdvertiseSettings.Builder()
+            .setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
+            .setConnectable(true)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            advertiseSettingsBuilder.setDiscoverable(true)
+        }
+        val advertiseSettings = advertiseSettingsBuilder.build()
+        val advertiseData = AdvertiseData.Builder().build()
+        val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
+        val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
+
+        val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
+        viewModel.l2capPsm = serverSocket.psm
+        Log.info("psm = $serverSocket.psm")
+
+        val server = SocketServer(viewModel, serverSocket)
+        server.run({ advertiser.stopAdvertising(callback) }, { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) })
+    }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt
new file mode 100644
index 0000000..6081837
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt
@@ -0,0 +1,347 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
+import com.github.google.bumble.btbench.ui.theme.BTBenchTheme
+import java.util.logging.Logger
+
+private val Log = Logger.getLogger("bumble.main-activity")
+
+const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
+const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
+const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
+
+class MainActivity : ComponentActivity() {
+    private val appViewModel = AppViewModel()
+    private var bluetoothAdapter: BluetoothAdapter? = null
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE))
+        checkPermissions()
+    }
+
+    private fun checkPermissions() {
+        val neededPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            arrayOf(
+                Manifest.permission.BLUETOOTH_ADVERTISE,
+                Manifest.permission.BLUETOOTH_SCAN,
+                Manifest.permission.BLUETOOTH_CONNECT
+            )
+        } else {
+            arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
+        }
+        val missingPermissions = neededPermissions.filter {
+            ContextCompat.checkSelfPermission(baseContext, it) != PackageManager.PERMISSION_GRANTED
+        }
+
+        if (missingPermissions.isEmpty()) {
+            start()
+            return
+        }
+
+        val requestPermissionsLauncher = registerForActivityResult(
+            ActivityResultContracts.RequestMultiplePermissions()
+        ) { permissions ->
+            permissions.entries.forEach {
+                Log.info("permission: ${it.key} = ${it.value}")
+            }
+            val grantCount = permissions.count { it.value }
+            if (grantCount == neededPermissions.size) {
+                // We have all the permissions we need.
+                start()
+            } else {
+                Log.warning("not all permissions granted")
+            }
+        }
+
+        requestPermissionsLauncher.launch(missingPermissions.toTypedArray())
+        return
+    }
+
+    @SuppressLint("MissingPermission")
+    private fun initBluetooth() {
+        val bluetoothManager = ContextCompat.getSystemService(this, BluetoothManager::class.java)
+        bluetoothAdapter = bluetoothManager?.adapter
+
+        if (bluetoothAdapter == null) {
+            Log.warning("no bluetooth adapter")
+            return
+        }
+
+        if (!bluetoothAdapter!!.isEnabled) {
+            Log.warning("bluetooth not enabled")
+            return
+        }
+    }
+
+    private fun start() {
+        initBluetooth()
+        setContent {
+            MainView(
+                appViewModel,
+                ::becomeDiscoverable,
+                ::runRfcommClient,
+                ::runRfcommServer,
+                ::runL2capClient,
+                ::runL2capServer
+            )
+        }
+
+        // Process intent parameters, if any.
+        intent.getStringExtra("peer-bluetooth-address")?.let {
+            appViewModel.peerBluetoothAddress = it
+        }
+        val packetCount = intent.getIntExtra("packet-count", 0)
+        if (packetCount > 0) {
+            appViewModel.senderPacketCount = packetCount
+        }
+        appViewModel.updateSenderPacketCountSlider()
+        val packetSize = intent.getIntExtra("packet-size", 0)
+        if (packetSize > 0) {
+            appViewModel.senderPacketSize = packetSize
+        }
+        appViewModel.updateSenderPacketSizeSlider()
+        intent.getStringExtra("autostart")?.let {
+            when (it) {
+                "rfcomm-client" -> runRfcommClient()
+                "rfcomm-server" -> runRfcommServer()
+                "l2cap-client" -> runL2capClient()
+                "l2cap-server" -> runL2capServer()
+            }
+        }
+    }
+
+    private fun runRfcommClient() {
+        val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
+        rfcommClient?.run()
+    }
+
+    private fun runRfcommServer() {
+        val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
+        rfcommServer?.run()
+    }
+
+    private fun runL2capClient() {
+        val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
+        l2capClient?.run()
+    }
+
+    private fun runL2capServer() {
+        val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
+        l2capServer?.run()
+    }
+
+    @SuppressLint("MissingPermission")
+    fun becomeDiscoverable() {
+        val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
+        discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300)
+        startActivity(discoverableIntent)
+    }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun MainView(
+    appViewModel: AppViewModel,
+    becomeDiscoverable: () -> Unit,
+    runRfcommClient: () -> Unit,
+    runRfcommServer: () -> Unit,
+    runL2capClient: () -> Unit,
+    runL2capServer: () -> Unit
+) {
+    BTBenchTheme {
+        val scrollState = rememberScrollState()
+        Surface(
+            modifier = Modifier
+                .fillMaxSize()
+                .verticalScroll(scrollState),
+            color = MaterialTheme.colorScheme.background
+        ) {
+            Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+                Text(
+                    text = "Bumble Bench",
+                    fontSize = 24.sp,
+                    fontWeight = FontWeight.Bold,
+                    textAlign = TextAlign.Center
+                )
+                Divider()
+                val keyboardController = LocalSoftwareKeyboardController.current
+                val focusRequester = remember { FocusRequester() }
+                val focusManager = LocalFocusManager.current
+                TextField(
+                    label = {
+                        Text(text = "Peer Bluetooth Address")
+                    },
+                    value = appViewModel.peerBluetoothAddress,
+                    modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
+                    keyboardOptions = KeyboardOptions.Default.copy(
+                        keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
+                    ),
+                    onValueChange = {
+                        appViewModel.updatePeerBluetoothAddress(it)
+                    },
+                    keyboardActions = KeyboardActions(onDone = {
+                        keyboardController?.hide()
+                        focusManager.clearFocus()
+                    })
+                )
+                Divider()
+                TextField(label = {
+                    Text(text = "L2CAP PSM")
+                },
+                    value = appViewModel.l2capPsm.toString(),
+                    modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
+                    keyboardOptions = KeyboardOptions.Default.copy(
+                        keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+                    ),
+                    onValueChange = {
+                        if (it.isNotEmpty()) {
+                            val psm = it.toIntOrNull()
+                            if (psm != null) {
+                                appViewModel.l2capPsm = psm
+                            }
+                        }
+                    },
+                    keyboardActions = KeyboardActions(onDone = {
+                        keyboardController?.hide()
+                        focusManager.clearFocus()
+                    })
+                )
+                Divider()
+                Slider(
+                    value = appViewModel.senderPacketCountSlider, onValueChange = {
+                        appViewModel.senderPacketCountSlider = it
+                        appViewModel.updateSenderPacketCount()
+                    }, steps = 4
+                )
+                Text(text = "Packet Count: " + appViewModel.senderPacketCount.toString())
+                Divider()
+                Slider(
+                    value = appViewModel.senderPacketSizeSlider, onValueChange = {
+                        appViewModel.senderPacketSizeSlider = it
+                        appViewModel.updateSenderPacketSize()
+                    }, steps = 4
+                )
+                Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
+                Divider()
+                ActionButton(
+                    text = "Become Discoverable", onClick = becomeDiscoverable, true
+                )
+                Row(
+                    horizontalArrangement = Arrangement.SpaceBetween,
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    Text(text = "2M PHY")
+                    Spacer(modifier = Modifier.padding(start = 8.dp))
+                    Switch(
+                        checked = appViewModel.use2mPhy,
+                        onCheckedChange = { appViewModel.use2mPhy = it }
+                    )
+
+                }
+                Row {
+                    ActionButton(
+                        text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
+                    )
+                    ActionButton(
+                        text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
+                    )
+                }
+                Row {
+                    ActionButton(
+                        text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
+                    )
+                    ActionButton(
+                        text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
+                    )
+                }
+                Divider()
+                Text(
+                    text = if (appViewModel.mtu != 0) "MTU: ${appViewModel.mtu}" else ""
+                )
+                Text(
+                    text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
+                )
+                Text(
+                    text = "Packets Sent: ${appViewModel.packetsSent}"
+                )
+                Text(
+                    text = "Packets Received: ${appViewModel.packetsReceived}"
+                )
+                Text(
+                    text = "Throughput: ${appViewModel.throughput}"
+                )
+                Divider()
+                ActionButton(
+                    text = "Abort", onClick = appViewModel::abort, appViewModel.running
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
+    Button(onClick = onClick, enabled = enabled) {
+        Text(text = text)
+    }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt
new file mode 100644
index 0000000..1a8cd6d
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt
@@ -0,0 +1,169 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.content.SharedPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import java.util.UUID
+
+val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
+const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
+const val DEFAULT_SENDER_PACKET_COUNT = 100
+const val DEFAULT_SENDER_PACKET_SIZE = 1024
+const val DEFAULT_PSM = 128
+
+class AppViewModel : ViewModel() {
+    private var preferences: SharedPreferences? = null
+    var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
+    var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
+    var use2mPhy by mutableStateOf(true)
+    var mtu by mutableIntStateOf(0)
+    var rxPhy by mutableIntStateOf(0)
+    var txPhy by mutableIntStateOf(0)
+    var senderPacketCountSlider by mutableFloatStateOf(0.0F)
+    var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
+    var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
+    var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
+    var packetsSent by mutableIntStateOf(0)
+    var packetsReceived by mutableIntStateOf(0)
+    var throughput by mutableIntStateOf(0)
+    var running by mutableStateOf(false)
+    var aborter: (() -> Unit)? = null
+
+    fun loadPreferences(preferences: SharedPreferences) {
+        this.preferences = preferences
+
+        val savedPeerBluetoothAddress = preferences.getString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, null)
+        if (savedPeerBluetoothAddress != null) {
+            peerBluetoothAddress = savedPeerBluetoothAddress
+        }
+
+        val savedSenderPacketCount = preferences.getInt(SENDER_PACKET_COUNT_PREF_KEY, 0)
+        if (savedSenderPacketCount != 0) {
+            senderPacketCount = savedSenderPacketCount
+        }
+        updateSenderPacketCountSlider()
+
+        val savedSenderPacketSize = preferences.getInt(SENDER_PACKET_SIZE_PREF_KEY, 0)
+        if (savedSenderPacketSize != 0) {
+            senderPacketSize = savedSenderPacketSize
+        }
+        updateSenderPacketSizeSlider()
+    }
+
+    fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
+        val address = peerBluetoothAddress.uppercase()
+        this.peerBluetoothAddress = address
+
+        // Save the address to the preferences
+        with(preferences!!.edit()) {
+            putString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, address)
+            apply()
+        }
+    }
+
+    fun updateSenderPacketCountSlider() {
+        senderPacketCountSlider = if (senderPacketCount <= 10) {
+            0.0F
+        } else if (senderPacketCount <= 50) {
+            0.2F
+        } else if (senderPacketCount <= 100) {
+            0.4F
+        } else if (senderPacketCount <= 500) {
+            0.6F
+        } else if (senderPacketCount <= 1000) {
+            0.8F
+        } else {
+            1.0F
+        }
+
+        with(preferences!!.edit()) {
+            putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
+            apply()
+        }
+    }
+
+    fun updateSenderPacketCount() {
+        senderPacketCount = if (senderPacketCountSlider < 0.1F) {
+            10
+        } else if (senderPacketCountSlider < 0.3F) {
+            50
+        } else if (senderPacketCountSlider < 0.5F) {
+            100
+        } else if (senderPacketCountSlider < 0.7F) {
+            500
+        } else if (senderPacketCountSlider < 0.9F) {
+            1000
+        } else {
+            10000
+        }
+
+        with(preferences!!.edit()) {
+            putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
+            apply()
+        }
+    }
+
+    fun updateSenderPacketSizeSlider() {
+        senderPacketSizeSlider = if (senderPacketSize <= 16) {
+            0.0F
+        } else if (senderPacketSize <= 256) {
+            0.02F
+        } else if (senderPacketSize <= 512) {
+            0.4F
+        } else if (senderPacketSize <= 1024) {
+            0.6F
+        } else if (senderPacketSize <= 2048) {
+            0.8F
+        } else {
+            1.0F
+        }
+
+        with(preferences!!.edit()) {
+            putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
+            apply()
+        }
+    }
+
+    fun updateSenderPacketSize() {
+        senderPacketSize = if (senderPacketSizeSlider < 0.1F) {
+            16
+        } else if (senderPacketSizeSlider < 0.3F) {
+            256
+        } else if (senderPacketSizeSlider < 0.5F) {
+            512
+        } else if (senderPacketSizeSlider < 0.7F) {
+            1024
+        } else if (senderPacketSizeSlider < 0.9F) {
+            2048
+        } else {
+            4096
+        }
+
+        with(preferences!!.edit()) {
+            putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
+            apply()
+        }
+    }
+
+    fun abort() {
+        aborter?.let { it() }
+    }
+}
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt
new file mode 100644
index 0000000..0fa8500
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt
@@ -0,0 +1,178 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.bluetooth.BluetoothSocket
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.util.logging.Logger
+import kotlin.math.min
+
+private val Log = Logger.getLogger("btbench.packet")
+
+fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
+
+abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) {
+    companion object {
+        const val RESET = 0
+        const val SEQUENCE = 1
+        const val ACK = 2
+
+        const val LAST_FLAG = 1
+
+        fun from(data: ByteArray): Packet {
+            return when (data[0].toInt()) {
+                RESET -> ResetPacket()
+                SEQUENCE -> SequencePacket(
+                    data[1].toInt(),
+                    ByteBuffer.wrap(data, 2, 4).getInt(),
+                    data.sliceArray(6..<data.size)
+                )
+
+                ACK -> AckPacket(data[1].toInt(), ByteBuffer.wrap(data, 2, 4).getInt())
+                else -> GenericPacket(data[0].toInt(), data.sliceArray(1..<data.size))
+            }
+        }
+    }
+
+    open fun toBytes(): ByteArray {
+        return ByteBuffer.allocate(1 + payload.size).put(type.toByte()).put(payload).array()
+    }
+}
+
+class GenericPacket(type: Int, payload: ByteArray) : Packet(type, payload)
+class ResetPacket : Packet(RESET)
+
+class AckPacket(val flags: Int, val sequenceNumber: Int) : Packet(ACK) {
+    override fun toBytes(): ByteArray {
+        return ByteBuffer.allocate(1 + 1 + 4).put(type.toByte()).put(flags.toByte())
+            .putInt(sequenceNumber).array()
+    }
+}
+
+class SequencePacket(val flags: Int, val sequenceNumber: Int, payload: ByteArray) :
+    Packet(SEQUENCE, payload) {
+    override fun toBytes(): ByteArray {
+        return ByteBuffer.allocate(1 + 1 + 4 + payload.size).put(type.toByte()).put(flags.toByte())
+            .putInt(sequenceNumber).put(payload).array()
+    }
+}
+
+abstract class PacketSink {
+    fun onPacket(packet: Packet) {
+        when (packet) {
+            is ResetPacket -> onResetPacket()
+            is AckPacket -> onAckPacket()
+            is SequencePacket -> onSequencePacket(packet)
+        }
+    }
+
+    abstract fun onResetPacket()
+    abstract fun onAckPacket()
+    abstract fun onSequencePacket(packet: SequencePacket)
+}
+
+interface DataSink {
+    fun onData(data: ByteArray)
+}
+
+interface PacketIO {
+    var packetSink: PacketSink?
+    fun sendPacket(packet: Packet)
+}
+
+class StreamedPacketIO(private val dataSink: DataSink) : PacketIO {
+    private var bytesNeeded: Int = 0
+    private var rxPacket: ByteBuffer? = null
+    private var rxHeader = ByteBuffer.allocate(2)
+
+    override var packetSink: PacketSink? = null
+
+    fun onData(data: ByteArray) {
+        var current = data
+        while (current.isNotEmpty()) {
+            if (bytesNeeded > 0) {
+                val chunk = current.sliceArray(0..<min(bytesNeeded, current.size))
+                rxPacket!!.put(chunk)
+                current = current.sliceArray(chunk.size..<current.size)
+                bytesNeeded -= chunk.size
+                if (bytesNeeded == 0) {
+                    // Packet completed.
+                    //Log.fine("packet complete: ${current.toHex()}")
+                    packetSink?.onPacket(Packet.from(rxPacket!!.array()))
+
+                    // Reset.
+                    reset()
+                }
+            } else {
+                val headerBytesNeeded = 2 - rxHeader.position()
+                val headerBytes = current.sliceArray(0..<min(headerBytesNeeded, current.size))
+                current = current.sliceArray(headerBytes.size..<current.size)
+                rxHeader.put(headerBytes)
+                if (rxHeader.position() != 2) {
+                    return
+                }
+                bytesNeeded = rxHeader.getShort(0).toInt()
+                if (bytesNeeded == 0) {
+                    Log.warning("found 0 size packet!")
+                    reset()
+                    return
+                }
+                rxPacket = ByteBuffer.allocate(bytesNeeded)
+            }
+        }
+    }
+
+    private fun reset() {
+        rxPacket = null
+        rxHeader.position(0)
+    }
+
+    override fun sendPacket(packet: Packet) {
+        val packetBytes = packet.toBytes()
+        val packetData =
+            ByteBuffer.allocate(2 + packetBytes.size).putShort(packetBytes.size.toShort())
+                .put(packetBytes).array()
+        dataSink.onData(packetData)
+    }
+}
+
+class SocketDataSink(private val socket: BluetoothSocket) : DataSink {
+    override fun onData(data: ByteArray) {
+        socket.outputStream.write(data)
+    }
+}
+
+class SocketDataSource(
+    private val socket: BluetoothSocket,
+    private val onData: (data: ByteArray) -> Unit
+) {
+    fun receive() {
+        val buffer = ByteArray(4096)
+        do {
+            try {
+                val bytesRead = socket.inputStream.read(buffer)
+                if (bytesRead <= 0) {
+                    break
+                }
+                onData(buffer.sliceArray(0..<bytesRead))
+            } catch (error: IOException) {
+                Log.warning("IO Exception: $error")
+                break
+            }
+        } while (true)
+        Log.info("end of stream")
+    }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt
new file mode 100644
index 0000000..c3844b8
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt
@@ -0,0 +1,60 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import java.util.logging.Logger
+import kotlin.time.DurationUnit
+import kotlin.time.TimeSource
+
+private val Log = Logger.getLogger("btbench.receiver")
+
+class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
+    private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
+    private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
+    private var bytesReceived = 0
+
+    init {
+        packetIO.packetSink = this
+    }
+
+    override fun onResetPacket() {
+        startTime = TimeSource.Monotonic.markNow()
+        lastPacketTime = startTime
+        bytesReceived = 0
+        viewModel.throughput = 0
+        viewModel.packetsSent = 0
+        viewModel.packetsReceived = 0
+    }
+
+    override fun onAckPacket() {
+
+    }
+
+    override fun onSequencePacket(packet: SequencePacket) {
+        val received = packet.payload.size + 6
+        bytesReceived += received
+        val now = TimeSource.Monotonic.markNow()
+        lastPacketTime = now
+        viewModel.packetsReceived += 1
+        if (packet.flags and Packet.LAST_FLAG != 0) {
+            Log.info("received last packet")
+            val elapsed = now - startTime
+            val throughput = (bytesReceived / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
+            Log.info("throughput: $throughput")
+            viewModel.throughput = throughput
+            packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
+        }
+    }
+}
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt
new file mode 100644
index 0000000..e976c42
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt
@@ -0,0 +1,37 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.rfcomm-client")
+
+class RfcommClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
+    @SuppressLint("MissingPermission")
+    fun run() {
+        val address = viewModel.peerBluetoothAddress.take(17)
+        val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
+        val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
+            DEFAULT_RFCOMM_UUID
+        )
+
+        val client = SocketClient(viewModel, socket)
+        client.run()
+    }
+}
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt
new file mode 100644
index 0000000..69612c5
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt
@@ -0,0 +1,35 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.rfcomm-server")
+
+class RfcommServer(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
+    @SuppressLint("MissingPermission")
+    fun run() {
+        val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
+            "BumbleBench", DEFAULT_RFCOMM_UUID
+        )
+
+        val server = SocketServer(viewModel, serverSocket)
+        server.run({}, {})
+    }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt
new file mode 100644
index 0000000..293ac9a
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt
@@ -0,0 +1,84 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import java.util.concurrent.Semaphore
+import java.util.logging.Logger
+import kotlin.time.DurationUnit
+import kotlin.time.TimeSource
+
+private val Log = Logger.getLogger("btbench.sender")
+
+class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
+    private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
+    private var bytesSent = 0
+    private val done = Semaphore(0)
+
+    init {
+        packetIO.packetSink = this
+    }
+
+    fun run() {
+        viewModel.packetsSent = 0
+        viewModel.packetsReceived = 0
+        viewModel.throughput = 0
+
+        Log.info("sending reset")
+        packetIO.sendPacket(ResetPacket())
+
+        startTime = TimeSource.Monotonic.markNow()
+
+        val packetCount = viewModel.senderPacketCount
+        val packetSize = viewModel.senderPacketSize
+        for (i in 0..<packetCount - 1) {
+            packetIO.sendPacket(SequencePacket(0, i, ByteArray(packetSize - 6)))
+            bytesSent += packetSize
+            viewModel.packetsSent = i + 1
+        }
+        packetIO.sendPacket(
+            SequencePacket(
+                Packet.LAST_FLAG,
+                packetCount - 1,
+                ByteArray(packetSize - 6)
+            )
+        )
+        bytesSent += packetSize
+        viewModel.packetsSent = packetCount
+
+        // Wait for the ACK
+        Log.info("waiting for ACK")
+        done.acquire()
+        Log.info("got ACK")
+    }
+
+    fun abort() {
+        done.release()
+    }
+
+    override fun onResetPacket() {
+    }
+
+    override fun onAckPacket() {
+        Log.info("received ACK")
+        val elapsed = TimeSource.Monotonic.markNow() - startTime
+        val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
+        Log.info("throughput: $throughput")
+        viewModel.throughput = throughput
+        done.release()
+    }
+
+    override fun onSequencePacket(packet: SequencePacket) {
+    }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt
new file mode 100644
index 0000000..bd5b7f4
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt
@@ -0,0 +1,69 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothSocket
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.socket-client")
+
+private const val DEFAULT_STARTUP_DELAY = 3000
+
+class SocketClient(private val viewModel: AppViewModel, private val socket: BluetoothSocket) {
+    @SuppressLint("MissingPermission")
+    fun run() {
+        viewModel.running = true
+        val socketDataSink = SocketDataSink(socket)
+        val streamIO = StreamedPacketIO(socketDataSink)
+        val socketDataSource = SocketDataSource(socket, streamIO::onData)
+        val sender = Sender(viewModel, streamIO)
+
+        fun cleanup() {
+            socket.close()
+            viewModel.aborter = {}
+            viewModel.running = false
+        }
+
+        thread(name = "SocketClient") {
+            viewModel.aborter = {
+                sender.abort()
+                socket.close()
+            }
+            Log.info("connecting to remote")
+            try {
+                socket.connect()
+            } catch (error: IOException) {
+                Log.warning("connection failed")
+                cleanup()
+                return@thread
+            }
+            Log.info("connected")
+
+            thread {
+                socketDataSource.receive()
+            }
+
+            Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
+            Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
+            Log.info("Starting to send")
+
+            sender.run()
+            cleanup()
+        }
+    }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt
new file mode 100644
index 0000000..e83a47f
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt
@@ -0,0 +1,67 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.bluetooth.BluetoothServerSocket
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.socket-server")
+
+class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
+    fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) {
+        var aborted = false
+        viewModel.running = true
+
+        fun cleanup() {
+            serverSocket.close()
+            viewModel.running = false
+        }
+
+        thread(name = "SocketServer") {
+            while (!aborted) {
+                viewModel.aborter = {
+                    serverSocket.close()
+                }
+                Log.info("waiting for connection...")
+                onDisconnected()
+                val socket = try {
+                    serverSocket.accept()
+                } catch (error: IOException) {
+                    Log.warning("server socket closed")
+                    cleanup()
+                    return@thread
+                }
+                Log.info("got connection from ${socket.remoteDevice.address}")
+                onConnected()
+
+                viewModel.aborter = {
+                    aborted = true
+                    socket.close()
+                }
+                viewModel.peerBluetoothAddress = socket.remoteDevice.address
+
+                val socketDataSink = SocketDataSink(socket)
+                val streamIO = StreamedPacketIO(socketDataSink)
+                val socketDataSource = SocketDataSource(socket, streamIO::onData)
+                val receiver = Receiver(viewModel, streamIO)
+                socketDataSource.receive()
+                socket.close()
+            }
+            cleanup()
+        }
+    }
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt
new file mode 100644
index 0000000..2b538c8
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt
new file mode 100644
index 0000000..1751579
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt
@@ -0,0 +1,63 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+    primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+    primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40
+
+    /* Other default colors to override
+    background = Color(0xFFFFFBFE),
+    surface = Color(0xFFFFFBFE),
+    onPrimary = Color.White,
+    onSecondary = Color.White,
+    onTertiary = Color.White,
+    onBackground = Color(0xFF1C1B1F),
+    onSurface = Color(0xFF1C1B1F),
+    */
+)
+
+@Composable
+fun BTBenchTheme(
+    darkTheme: Boolean = isSystemInDarkTheme(),
+    // Dynamic color is available on Android 12+
+    dynamicColor: Boolean = true, content: @Composable () -> Unit
+) {
+    val colorScheme = when {
+        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+            val context = LocalContext.current
+            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+        }
+
+        darkTheme -> DarkColorScheme
+        else -> LightColorScheme
+    }
+    val view = LocalView.current
+    if (!view.isInEditMode) {
+        SideEffect {
+            val window = (view.context as Activity).window
+            window.statusBarColor = colorScheme.primary.toArgb()
+            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
+        }
+    }
+
+    MaterialTheme(
+        colorScheme = colorScheme, typography = Typography, content = content
+    )
+}
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt
new file mode 100644
index 0000000..029f898
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt
@@ -0,0 +1,33 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+    bodyLarge = TextStyle(
+        fontFamily = FontFamily.Default,
+        fontWeight = FontWeight.Normal,
+        fontSize = 16.sp,
+        lineHeight = 24.sp,
+        letterSpacing = 0.5.sp
+    )/* Other default text styles to override
+    titleLarge = TextStyle(
+        fontFamily = FontFamily.Default,
+        fontWeight = FontWeight.Normal,
+        fontSize = 22.sp,
+        lineHeight = 28.sp,
+        letterSpacing = 0.sp
+    ),
+    labelSmall = TextStyle(
+        fontFamily = FontFamily.Default,
+        fontWeight = FontWeight.Medium,
+        fontSize = 11.sp,
+        lineHeight = 16.sp,
+        letterSpacing = 0.5.sp
+    )
+    */
+)
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..ca3826a
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector
+    android:height="108dp"
+    android:width="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="#3DDC84"
+          android:pathData="M0,0h108v108h-108z"/>
+    <path android:fillColor="#00000000" android:pathData="M9,0L9,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,0L19,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M29,0L29,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M39,0L39,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M49,0L49,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M59,0L59,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M69,0L69,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M79,0L79,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M89,0L89,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M99,0L99,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,9L108,9"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,19L108,19"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,29L108,29"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,39L108,39"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,49L108,49"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,59L108,59"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,69L108,69"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,79L108,79"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,89L108,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,99L108,99"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,29L89,29"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,39L89,39"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,49L89,49"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,59L89,59"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,69L89,69"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,79L89,79"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M29,19L29,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M39,19L39,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M49,19L49,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M59,19L59,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M69,19L69,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M79,19L79,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+</vector>
diff --git a/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
+        <aapt:attr name="android:fillColor">
+            <gradient
+                android:endX="85.84757"
+                android:endY="92.4963"
+                android:startX="42.9492"
+                android:startY="49.59793"
+                android:type="linear">
+                <item
+                    android:color="#44000000"
+                    android:offset="0.0" />
+                <item
+                    android:color="#00000000"
+                    android:offset="1.0" />
+            </gradient>
+        </aapt:attr>
+    </path>
+    <path
+        android:fillColor="#FFFFFF"
+        android:fillType="nonZero"
+        android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
+        android:strokeWidth="1"
+        android:strokeColor="#00000000" />
+</vector>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..7dc4135
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..37c0b56
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..e8f5332
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..ac1ae9b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..5e12fc6
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..19ac4bf
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..30516ad
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..7a39c13
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..a2b1c8b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..2bbc83f
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/values/colors.xml b/extras/android/BtBench/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="purple_200">#FFBB86FC</color>
+    <color name="purple_500">#FF6200EE</color>
+    <color name="purple_700">#FF3700B3</color>
+    <color name="teal_200">#FF03DAC5</color>
+    <color name="teal_700">#FF018786</color>
+    <color name="black">#FF000000</color>
+    <color name="white">#FFFFFFFF</color>
+</resources>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml b/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..c5d5899
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_launcher_background">#FFFFFF</color>
+</resources>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/strings.xml b/extras/android/BtBench/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..018c3f9
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+<resources>
+    <string name="app_name">BT Bench</string>
+</resources>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/themes.xml b/extras/android/BtBench/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..f0d08db
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <style name="Theme.BTBench" parent="android:Theme.Material.Light.NoActionBar" />
+</resources>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml b/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+   Sample backup rules file; uncomment and customize as necessary.
+   See https://developer.android.com/guide/topics/data/autobackup
+   for details.
+   Note: This file is ignored for devices older that API 31
+   See https://developer.android.com/about/versions/12/backup-restore
+-->
+<full-backup-content>
+    <!--
+   <include domain="sharedpref" path="."/>
+   <exclude domain="sharedpref" path="device.xml"/>
+-->
+</full-backup-content>
\ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml b/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+   Sample data extraction rules file; uncomment and customize as necessary.
+   See https://developer.android.com/about/versions/12/backup-restore#xml-changes
+   for details.
+-->
+<data-extraction-rules>
+    <cloud-backup>
+        <!-- TODO: Use <include> and <exclude> to control what is backed up.
+        <include .../>
+        <exclude .../>
+        -->
+    </cloud-backup>
+    <!--
+    <device-transfer>
+        <include .../>
+        <exclude .../>
+    </device-transfer>
+    -->
+</data-extraction-rules>
\ No newline at end of file
diff --git a/extras/android/BtBench/build.gradle.kts b/extras/android/BtBench/build.gradle.kts
new file mode 100644
index 0000000..20d87a7
--- /dev/null
+++ b/extras/android/BtBench/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+    alias(libs.plugins.androidApplication) apply false
+    alias(libs.plugins.kotlinAndroid) apply false
+}
+true // Needed to make the Suppress annotation work for the plugins block
\ No newline at end of file
diff --git a/extras/android/BtBench/gradle.properties b/extras/android/BtBench/gradle.properties
new file mode 100644
index 0000000..3c5031e
--- /dev/null
+++ b/extras/android/BtBench/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/extras/android/BtBench/gradle/libs.versions.toml b/extras/android/BtBench/gradle/libs.versions.toml
new file mode 100644
index 0000000..26945a9
--- /dev/null
+++ b/extras/android/BtBench/gradle/libs.versions.toml
@@ -0,0 +1,31 @@
+[versions]
+agp = "8.2.0"
+kotlin = "1.9.0"
+core-ktx = "1.12.0"
+junit = "4.13.2"
+androidx-test-ext-junit = "1.1.5"
+espresso-core = "3.5.1"
+lifecycle-runtime-ktx = "2.6.2"
+activity-compose = "1.7.2"
+compose-bom = "2023.08.00"
+
+[libraries]
+core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
+espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
+lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
+ui = { group = "androidx.compose.ui", name = "ui" }
+ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+material3 = { group = "androidx.compose.material3", name = "material3" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+
diff --git a/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
--- /dev/null
+++ b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..8ef5972
--- /dev/null
+++ b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Wed Oct 25 07:40:52 PDT 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/extras/android/BtBench/gradlew b/extras/android/BtBench/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/extras/android/BtBench/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+    echo "$*"
+}
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+  NONSTOP* )
+    nonstop=true
+    ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=`expr $i + 1`
+    done
+    case $i in
+        0) set -- ;;
+        1) set -- "$args0" ;;
+        2) set -- "$args0" "$args1" ;;
+        3) set -- "$args0" "$args1" "$args2" ;;
+        4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Escape application args
+save () {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/extras/android/BtBench/gradlew.bat b/extras/android/BtBench/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/extras/android/BtBench/gradlew.bat
@@ -0,0 +1,89 @@
+@rem

+@rem Copyright 2015 the original author or authors.

+@rem

+@rem Licensed under the Apache License, Version 2.0 (the "License");

+@rem you may not use this file except in compliance with the License.

+@rem You may obtain a copy of the License at

+@rem

+@rem      https://www.apache.org/licenses/LICENSE-2.0

+@rem

+@rem Unless required by applicable law or agreed to in writing, software

+@rem distributed under the License is distributed on an "AS IS" BASIS,

+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

+@rem See the License for the specific language governing permissions and

+@rem limitations under the License.

+@rem

+

+@if "%DEBUG%" == "" @echo off

+@rem ##########################################################################

+@rem

+@rem  Gradle startup script for Windows

+@rem

+@rem ##########################################################################

+

+@rem Set local scope for the variables with windows NT shell

+if "%OS%"=="Windows_NT" setlocal

+

+set DIRNAME=%~dp0

+if "%DIRNAME%" == "" set DIRNAME=.

+set APP_BASE_NAME=%~n0

+set APP_HOME=%DIRNAME%

+

+@rem Resolve any "." and ".." in APP_HOME to make it shorter.

+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

+

+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.

+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"

+

+@rem Find java.exe

+if defined JAVA_HOME goto findJavaFromJavaHome

+

+set JAVA_EXE=java.exe

+%JAVA_EXE% -version >NUL 2>&1

+if "%ERRORLEVEL%" == "0" goto execute

+

+echo.

+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.

+echo.

+echo Please set the JAVA_HOME variable in your environment to match the

+echo location of your Java installation.

+

+goto fail

+

+:findJavaFromJavaHome

+set JAVA_HOME=%JAVA_HOME:"=%

+set JAVA_EXE=%JAVA_HOME%/bin/java.exe

+

+if exist "%JAVA_EXE%" goto execute

+

+echo.

+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%

+echo.

+echo Please set the JAVA_HOME variable in your environment to match the

+echo location of your Java installation.

+

+goto fail

+

+:execute

+@rem Setup the command line

+

+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar

+

+

+@rem Execute Gradle

+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*

+

+:end

+@rem End local scope for the variables with windows NT shell

+if "%ERRORLEVEL%"=="0" goto mainEnd

+

+:fail

+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of

+rem the _cmd.exe /c_ return code!

+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1

+exit /b 1

+

+:mainEnd

+if "%OS%"=="Windows_NT" endlocal

+

+:omega

diff --git a/extras/android/BtBench/settings.gradle.kts b/extras/android/BtBench/settings.gradle.kts
new file mode 100644
index 0000000..9bdd1ab
--- /dev/null
+++ b/extras/android/BtBench/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+    repositories {
+        google {
+            content {
+                includeGroupByRegex("com\\.android.*")
+                includeGroupByRegex("com\\.google.*")
+                includeGroupByRegex("androidx.*")
+            }
+        }
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "BT Bench"
+include(":app")
+ 
\ No newline at end of file
diff --git a/extras/android/RemoteHCI/app/build.gradle.kts b/extras/android/RemoteHCI/app/build.gradle.kts
index 2e2df38..0e68a2f 100644
--- a/extras/android/RemoteHCI/app/build.gradle.kts
+++ b/extras/android/RemoteHCI/app/build.gradle.kts
@@ -10,7 +10,7 @@
 
     defaultConfig {
         applicationId = "com.github.google.bumble.remotehci"
-        minSdk = 26
+        minSdk = 29
         targetSdk = 33
         versionCode = 1
         versionName = "1.0"
diff --git a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/CommandLineInterface.kt b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/CommandLineInterface.kt
new file mode 100644
index 0000000..2f1b59e
--- /dev/null
+++ b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/CommandLineInterface.kt
@@ -0,0 +1,57 @@
+package com.github.google.bumble.remotehci
+
+import java.io.IOException
+
+class CommandLineInterface {
+    companion object {
+        fun printUsage() {
+            System.out.println("usage: <launch-command> [-h|--help] [<tcp-port>]")
+        }
+
+        @JvmStatic fun main(args: Array<String>) {
+            System.out.println("Starting proxy")
+
+            var tcpPort = DEFAULT_TCP_PORT
+            if (args.isNotEmpty()) {
+                if (args[0] == "-h" || args[0] == "--help") {
+                    printUsage()
+                    return
+                }
+                try {
+                    tcpPort = args[0].toInt()
+                } catch (error: NumberFormatException) {
+                    System.out.println("ERROR: invalid TCP port argument")
+                    printUsage()
+                    return
+                }
+            }
+
+            try {
+                val hciProxy = HciProxy(tcpPort, object : HciProxy.Listener {
+                    override fun onHostConnectionState(connected: Boolean) {
+                    }
+
+                    override fun onHciPacketCountChange(
+                        commandPacketsReceived: Int,
+                        aclPacketsReceived: Int,
+                        scoPacketsReceived: Int,
+                        eventPacketsSent: Int,
+                        aclPacketsSent: Int,
+                        scoPacketsSent: Int
+                    ) {
+                    }
+
+                    override fun onMessage(message: String?) {
+                        System.out.println(message)
+                    }
+
+                })
+                hciProxy.run()
+            } catch (error: IOException) {
+                System.err.println("Exception while running HCI Server: $error")
+            } catch (error: HciProxy.HalException) {
+                System.err.println("HAL exception: ${error.message}")
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java
index fd81921..a1bd8eb 100644
--- a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java
+++ b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java
@@ -4,6 +4,7 @@
 import android.os.IBinder;
 import android.os.RemoteException;
 import android.os.ServiceManager;
+import android.os.Trace;
 import android.util.Log;
 
 import java.util.ArrayList;
@@ -53,6 +54,7 @@
     private final android.hardware.bluetooth.V1_0.IBluetoothHci mHciService;
     private final HciHalCallback mHciCallbacks;
     private int mInitializationStatus = -1;
+    private final boolean mTracingEnabled = Trace.isEnabled();
 
 
     public static HciHidlHal create(HciHalCallback hciCallbacks) {
@@ -89,6 +91,7 @@
         }
 
         // Map the status code.
+        Log.d(TAG, "Initialization status = " + mInitializationStatus);
         switch (mInitializationStatus) {
             case android.hardware.bluetooth.V1_0.Status.SUCCESS:
                 return Status.SUCCESS;
@@ -108,6 +111,10 @@
     public void sendPacket(HciPacket.Type type, byte[] packet) {
         ArrayList<Byte> data = HciPacket.byteArrayToList(packet);
 
+        if (mTracingEnabled) {
+            Trace.beginAsyncSection("SEND_PACKET_TO_HAL", 1);
+        }
+
         try {
             switch (type) {
                 case COMMAND:
@@ -125,6 +132,10 @@
         } catch (RemoteException error) {
             Log.w(TAG, "failed to forward packet: " + error);
         }
+
+        if (mTracingEnabled) {
+            Trace.endAsyncSection("SEND_PACKET_TO_HAL", 1);
+        }
     }
 
     @Override
@@ -157,6 +168,7 @@
     private final android.hardware.bluetooth.IBluetoothHci mHciService;
     private final HciHalCallback mHciCallbacks;
     private int mInitializationStatus = android.hardware.bluetooth.Status.SUCCESS;
+    private final boolean mTracingEnabled = Trace.isEnabled();
 
     public static HciAidlHal create(HciHalCallback hciCallbacks) {
         IBinder binder = ServiceManager.getService("android.hardware.bluetooth.IBluetoothHci/default");
@@ -187,6 +199,7 @@
         }
 
         // Map the status code.
+        Log.d(TAG, "Initialization status = " + mInitializationStatus);
         switch (mInitializationStatus) {
             case android.hardware.bluetooth.Status.SUCCESS:
                 return Status.SUCCESS;
@@ -208,6 +221,10 @@
     // HciHal methods.
     @Override
     public void sendPacket(HciPacket.Type type, byte[] packet) {
+        if (mTracingEnabled) {
+            Trace.beginAsyncSection("SEND_PACKET_TO_HAL", 1);
+        }
+
         try {
             switch (type) {
                 case COMMAND:
@@ -229,6 +246,10 @@
         } catch (RemoteException error) {
             Log.w(TAG, "failed to forward packet: " + error);
         }
+
+        if (mTracingEnabled) {
+            Trace.endAsyncSection("SEND_PACKET_TO_HAL", 1);
+        }
     }
 
     // IBluetoothHciCallbacks methods.
diff --git a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java
index a78a86a..9332305 100644
--- a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java
+++ b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java
@@ -1,5 +1,6 @@
 package com.github.google.bumble.remotehci;
 
+import android.os.Trace;
 import android.util.Log;
 
 import java.io.IOException;
@@ -15,6 +16,7 @@
     private final int mPort;
     private final Listener mListener;
     private OutputStream mOutputStream;
+    private final boolean mTracingEnabled = Trace.isEnabled();
 
     public interface Listener extends HciParser.Sink {
         void onHostConnectionState(boolean connected);
@@ -27,6 +29,8 @@
     }
 
     public void run() throws IOException {
+        Log.i(TAG, "Tracing enabled: "  + mTracingEnabled);
+
         for (;;) {
             try {
                 loop();
@@ -42,6 +46,7 @@
         try (ServerSocket serverSocket = new ServerSocket(mPort)) {
             mListener.onMessage("Waiting for connection on port " + serverSocket.getLocalPort());
             try (Socket clientSocket = serverSocket.accept()) {
+                clientSocket.setTcpNoDelay(true);
                 mListener.onHostConnectionState(true);
                 mListener.onMessage("Connected");
                 HciParser parser = new HciParser(mListener);
@@ -72,6 +77,10 @@
     }
 
     public void sendPacket(HciPacket.Type type, byte[] packet) {
+        if (mTracingEnabled) {
+            Trace.beginAsyncSection("SEND_PACKET_FROM_HAL", 2);
+        }
+
         // Create a combined data buffer so we can write it out in a single call.
         byte[] data = new byte[packet.length + 1];
         data[0] = type.value;
@@ -88,5 +97,9 @@
                 Log.d(TAG, "no client, dropping packet");
             }
         }
+
+        if (mTracingEnabled) {
+            Trace.endAsyncSection("SEND_PACKET_FROM_HAL", 2);
+        }
     }
 }
diff --git a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt
index 3a2630a..493b7e5 100644
--- a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt
+++ b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt
@@ -10,8 +10,10 @@
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.text.KeyboardActions
 import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Button
 import androidx.compose.material3.Divider
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -71,7 +73,7 @@
         this.tcpPort = tcpPort
 
         // Save the port to the preferences
-        with (preferences!!.edit()) {
+        with(preferences!!.edit()) {
             putString(TCP_PORT_PREF_KEY, tcpPort.toString())
             apply()
         }
@@ -113,7 +115,7 @@
 
         val tcpPort = intent.getIntExtra("port", -1)
         if (tcpPort >= 0) {
-            appViewModel.tcpPort = tcpPport
+            appViewModel.tcpPort = tcpPort
         }
 
         setContent {
@@ -138,7 +140,8 @@
                 log.warning("Exception while running HCI Server: $error")
             } catch (error: HalException) {
                 log.warning("HAL exception: ${error.message}")
-                appViewModel.message = "Cannot bind to HAL (${error.message}). You may need to use the command 'setenforce 0' in a root adb shell."
+                appViewModel.message =
+                    "Cannot bind to HAL (${error.message}). You may need to use the command 'setenforce 0' in a root adb shell."
             }
             log.info("HCI Proxy thread ended")
             appViewModel.canStart = true
@@ -157,9 +160,12 @@
 @Composable
 fun MainView(appViewModel: AppViewModel, startProxy: () -> Unit) {
     RemoteHCITheme {
-        // A surface container using the 'background' color from the theme
+        val scrollState = rememberScrollState()
         Surface(
-            modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
+            modifier = Modifier
+                .fillMaxSize()
+                .verticalScroll(scrollState),
+            color = MaterialTheme.colorScheme.background
         ) {
             Column(modifier = Modifier.padding(horizontal = 16.dp)) {
                 Text(
@@ -174,13 +180,15 @@
                 )
                 Divider()
                 val keyboardController = LocalSoftwareKeyboardController.current
-                TextField(
-                    label = {
-                        Text(text = "TCP Port")
-                    },
+                TextField(label = {
+                    Text(text = "TCP Port")
+                },
                     value = appViewModel.tcpPort.toString(),
                     modifier = Modifier.fillMaxWidth(),
-                    keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
+                    keyboardOptions = KeyboardOptions.Default.copy(
+                        keyboardType = KeyboardType.Number,
+                        imeAction = ImeAction.Done
+                    ),
                     onValueChange = {
                         if (it.isNotEmpty()) {
                             val tcpPort = it.toIntOrNull()
@@ -189,10 +197,7 @@
                             }
                         }
                     },
-                    keyboardActions = KeyboardActions(
-                        onDone = {keyboardController?.hide()}
-                    )
-                )
+                    keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }))
                 Divider()
                 val connectState = if (appViewModel.hostConnected) "CONNECTED" else "DISCONNECTED"
                 Text(
diff --git a/extras/android/RemoteHCI/gradle/libs.versions.toml b/extras/android/RemoteHCI/gradle/libs.versions.toml
index cc1b0f5..8bdfdc7 100644
--- a/extras/android/RemoteHCI/gradle/libs.versions.toml
+++ b/extras/android/RemoteHCI/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
 [versions]
-agp = "8.3.0-alpha05"
+agp = "8.2.0"
 kotlin = "1.8.10"
 core-ktx = "1.9.0"
 junit = "4.13.2"
diff --git a/extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties b/extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties
index d58714b..0821cc9 100644
--- a/extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties
+++ b/extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 #Sun Aug 06 12:53:26 PDT 2023
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-rc-2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index c443561..3339339 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -1073,9 +1073,9 @@
 
 [[package]]
 name = "openssl"
-version = "0.10.57"
+version = "0.10.60"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c"
+checksum = "79a4c6c3a2b158f7f8f2a2fc5a969fa3a068df6fc9dbb4a43845436e3af7c800"
 dependencies = [
  "bitflags 2.4.0",
  "cfg-if",
@@ -1105,9 +1105,9 @@
 
 [[package]]
 name = "openssl-sys"
-version = "0.9.92"
+version = "0.9.96"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db7e971c2c2bba161b2d2fdf37080177eff520b3bc044787c7f1f5f9e78d869b"
+checksum = "3812c071ba60da8b5677cc12bcb1d42989a65553772897a7e0355545a819838f"
 dependencies = [
  "cc",
  "libc",
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 35a0f4c..8106114 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -10,7 +10,14 @@
 authors = ["Marshall Pierce <marshallpierce@google.com>"]
 keywords = ["bluetooth", "ble"]
 categories = ["api-bindings", "network-programming"]
-rust-version = "1.70.0"
+rust-version = "1.76.0"
+
+# https://github.com/frewsxcv/cargo-all-features#options
+[package.metadata.cargo-all-features]
+# We are interested in testing subset combinations of this feature, so this is redundant
+denylist = ["unstable"]
+# To exercise combinations of any of these features, remove from `always_include_features`
+always_include_features = ["anyhow", "pyo3-asyncio-attributes", "dev-tools", "bumble-tools"]
 
 [dependencies]
 pyo3 = { version = "0.18.3", features = ["macros"] }
@@ -26,6 +33,7 @@
 bytes = "1.5.0"
 pdl-derive = "0.2.0"
 pdl-runtime = "0.2.0"
+futures = "0.3.28"
 
 # Dev tools
 file-header = { version = "0.1.2", optional = true }
@@ -36,7 +44,6 @@
 clap = { version = "4.3.3", features = ["derive"], optional = true }
 directories = { version = "5.0.1", optional = true }
 env_logger = { version = "0.10.0", optional = true }
-futures = { version = "0.3.28", optional = true }
 log = { version = "0.4.19", optional = true }
 owo-colors = { version = "3.5.0", optional = true }
 reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
@@ -74,6 +81,11 @@
 path = "src/main.rs"
 required-features = ["bumble-tools"]
 
+[[example]]
+name = "broadcast"
+path = "examples/broadcast.rs"
+required-features = ["unstable_extended_adv"]
+
 # test entry point that uses pyo3_asyncio's test harness
 [[test]]
 name = "pytests"
@@ -85,5 +97,10 @@
 pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
 dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"]
 # separate feature for CLI so that dependencies don't spend time building these
-bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
+bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger"]
+
+# all the unstable features
+unstable = ["unstable_extended_adv"]
+unstable_extended_adv = []
+
 default = []
diff --git a/rust/examples/battery_client.rs b/rust/examples/battery_client.rs
index 007ccb6..613d9e8 100644
--- a/rust/examples/battery_client.rs
+++ b/rust/examples/battery_client.rs
@@ -33,6 +33,7 @@
 
 use bumble::wrapper::{
     device::{Device, Peer},
+    hci::{packets::AddressType, Address},
     profile::BatteryServiceProxy,
     transport::Transport,
     PyObjectExt,
@@ -52,12 +53,8 @@
 
     let transport = Transport::open(cli.transport).await?;
 
-    let device = Device::with_hci(
-        "Bumble",
-        "F0:F1:F2:F3:F4:F5",
-        transport.source()?,
-        transport.sink()?,
-    )?;
+    let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
+    let device = Device::with_hci("Bumble", address, transport.source()?, transport.sink()?)?;
 
     device.power_on().await?;
 
diff --git a/rust/examples/broadcast.rs b/rust/examples/broadcast.rs
index f87b644..affe21e 100644
--- a/rust/examples/broadcast.rs
+++ b/rust/examples/broadcast.rs
@@ -63,17 +63,28 @@
         )
         .map_err(|e| anyhow!(e))?;
 
-    device.set_advertising_data(adv_data)?;
     device.power_on().await?;
 
-    println!("Advertising...");
-    device.start_advertising(true).await?;
+    if cli.extended {
+        println!("Starting extended advertisement...");
+        device.start_advertising_extended(adv_data).await?;
+    } else {
+        device.set_advertising_data(adv_data)?;
+
+        println!("Starting legacy advertisement...");
+        device.start_advertising(true).await?;
+    }
 
     // wait until user kills the process
     tokio::signal::ctrl_c().await?;
 
-    println!("Stopping...");
-    device.stop_advertising().await?;
+    if cli.extended {
+        println!("Stopping extended advertisement...");
+        device.stop_advertising_extended().await?;
+    } else {
+        println!("Stopping legacy advertisement...");
+        device.stop_advertising().await?;
+    }
 
     Ok(())
 }
@@ -86,12 +97,17 @@
     /// See, for instance, `examples/device1.json` in the Python project.
     #[arg(long)]
     device_config: path::PathBuf,
+
     /// Bumble transport spec.
     ///
     /// <https://google.github.io/bumble/transports/index.html>
     #[arg(long)]
     transport: String,
 
+    /// Whether to perform an extended (BT 5.0) advertisement
+    #[arg(long)]
+    extended: bool,
+
     /// Log HCI commands
     #[arg(long)]
     log_hci: bool,
diff --git a/rust/examples/scanner.rs b/rust/examples/scanner.rs
index 3c328ed..0880e25 100644
--- a/rust/examples/scanner.rs
+++ b/rust/examples/scanner.rs
@@ -20,7 +20,9 @@
 use bumble::{
     adv::CommonDataType,
     wrapper::{
-        core::AdvertisementDataUnit, device::Device, hci::packets::AddressType,
+        core::AdvertisementDataUnit,
+        device::Device,
+        hci::{packets::AddressType, Address},
         transport::Transport,
     },
 };
@@ -44,12 +46,8 @@
 
     let transport = Transport::open(cli.transport).await?;
 
-    let mut device = Device::with_hci(
-        "Bumble",
-        "F0:F1:F2:F3:F4:F5",
-        transport.source()?,
-        transport.sink()?,
-    )?;
+    let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
+    let mut device = Device::with_hci("Bumble", address, transport.source()?, transport.sink()?)?;
 
     // in practice, devices can send multiple advertisements from the same address, so we keep
     // track of a timestamp for each set of data
diff --git a/rust/pytests/wrapper.rs b/rust/pytests/wrapper.rs
deleted file mode 100644
index 9fd65e7..0000000
--- a/rust/pytests/wrapper.rs
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright 2023 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-use bumble::wrapper::{
-    controller::Controller,
-    device::Device,
-    drivers::rtk::DriverInfo,
-    hci::{
-        packets::{
-            AddressType, ErrorCode, ReadLocalVersionInformationBuilder,
-            ReadLocalVersionInformationComplete,
-        },
-        Address, Error,
-    },
-    host::Host,
-    link::Link,
-    transport::Transport,
-};
-use nix::sys::stat::Mode;
-use pyo3::{
-    exceptions::PyException,
-    {PyErr, PyResult},
-};
-
-#[pyo3_asyncio::tokio::test]
-async fn fifo_transport_can_open() -> PyResult<()> {
-    let dir = tempfile::tempdir().unwrap();
-    let mut fifo = dir.path().to_path_buf();
-    fifo.push("bumble-transport-fifo");
-    nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
-
-    let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
-
-    t.close().await?;
-
-    Ok(())
-}
-
-#[pyo3_asyncio::tokio::test]
-async fn realtek_driver_info_all_drivers() -> PyResult<()> {
-    assert_eq!(12, DriverInfo::all_drivers()?.len());
-    Ok(())
-}
-
-#[pyo3_asyncio::tokio::test]
-async fn hci_command_wrapper_has_correct_methods() -> PyResult<()> {
-    let address = Address::new("F0:F1:F2:F3:F4:F5", &AddressType::RandomDeviceAddress)?;
-    let link = Link::new_local_link()?;
-    let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?;
-    let host = Host::new(controller.clone().into(), controller.into()).await?;
-    let device = Device::new(None, Some(address), None, Some(host), None)?;
-
-    device.power_on().await?;
-
-    // Send some simple command. A successful response means [HciCommandWrapper] has the minimum
-    // required interface for the Python code to think its an [HCI_Command] object.
-    let command = ReadLocalVersionInformationBuilder {};
-    let event: ReadLocalVersionInformationComplete = device
-        .send_command(&command.into(), true)
-        .await?
-        .try_into()
-        .map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
-
-    assert_eq!(ErrorCode::Success, event.get_status());
-    Ok(())
-}
diff --git a/rust/pytests/wrapper/drivers.rs b/rust/pytests/wrapper/drivers.rs
new file mode 100644
index 0000000..d2517eb
--- /dev/null
+++ b/rust/pytests/wrapper/drivers.rs
@@ -0,0 +1,22 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use bumble::wrapper::drivers::rtk::DriverInfo;
+use pyo3::PyResult;
+
+#[pyo3_asyncio::tokio::test]
+async fn realtek_driver_info_all_drivers() -> PyResult<()> {
+    assert_eq!(12, DriverInfo::all_drivers()?.len());
+    Ok(())
+}
diff --git a/rust/pytests/wrapper/hci.rs b/rust/pytests/wrapper/hci.rs
new file mode 100644
index 0000000..c4ce20d
--- /dev/null
+++ b/rust/pytests/wrapper/hci.rs
@@ -0,0 +1,86 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use bumble::wrapper::{
+    controller::Controller,
+    device::Device,
+    hci::{
+        packets::{
+            AddressType, Enable, ErrorCode, LeScanType, LeScanningFilterPolicy,
+            LeSetScanEnableBuilder, LeSetScanEnableComplete, LeSetScanParametersBuilder,
+            LeSetScanParametersComplete, OwnAddressType,
+        },
+        Address, Error,
+    },
+    host::Host,
+    link::Link,
+};
+use pyo3::{
+    exceptions::PyException,
+    {PyErr, PyResult},
+};
+
+#[pyo3_asyncio::tokio::test]
+async fn test_hci_roundtrip_success_and_failure() -> PyResult<()> {
+    let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
+    let device = create_local_device(address).await?;
+
+    device.power_on().await?;
+
+    // BLE Spec Core v5.3
+    // 7.8.9 LE Set Scan Parameters command
+    // ...
+    // The Host shall not issue this command when scanning is enabled in the
+    // Controller; if it is the Command Disallowed error code shall be used.
+    // ...
+
+    let command = LeSetScanEnableBuilder {
+        filter_duplicates: Enable::Disabled,
+        // will cause failure later
+        le_scan_enable: Enable::Enabled,
+    };
+
+    let event: LeSetScanEnableComplete = device
+        .send_command(command.into(), false)
+        .await?
+        .try_into()
+        .map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
+
+    assert_eq!(ErrorCode::Success, event.get_status());
+
+    let command = LeSetScanParametersBuilder {
+        le_scan_type: LeScanType::Passive,
+        le_scan_interval: 0,
+        le_scan_window: 0,
+        own_address_type: OwnAddressType::RandomDeviceAddress,
+        scanning_filter_policy: LeScanningFilterPolicy::AcceptAll,
+    };
+
+    let event: LeSetScanParametersComplete = device
+        .send_command(command.into(), false)
+        .await?
+        .try_into()
+        .map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
+
+    assert_eq!(ErrorCode::CommandDisallowed, event.get_status());
+
+    Ok(())
+}
+
+async fn create_local_device(address: Address) -> PyResult<Device> {
+    let link = Link::new_local_link()?;
+    let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?;
+    let host = Host::new(controller.clone().into(), controller.into()).await?;
+    Device::new(None, Some(address), None, Some(host), None)
+}
diff --git a/rust/pytests/wrapper/mod.rs b/rust/pytests/wrapper/mod.rs
new file mode 100644
index 0000000..3bc9127
--- /dev/null
+++ b/rust/pytests/wrapper/mod.rs
@@ -0,0 +1,17 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+mod drivers;
+mod hci;
+mod transport;
diff --git a/rust/pytests/wrapper/transport.rs b/rust/pytests/wrapper/transport.rs
new file mode 100644
index 0000000..333005b
--- /dev/null
+++ b/rust/pytests/wrapper/transport.rs
@@ -0,0 +1,31 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use bumble::wrapper::transport::Transport;
+use nix::sys::stat::Mode;
+use pyo3::PyResult;
+
+#[pyo3_asyncio::tokio::test]
+async fn fifo_transport_can_open() -> PyResult<()> {
+    let dir = tempfile::tempdir().unwrap();
+    let mut fifo = dir.path().to_path_buf();
+    fifo.push("bumble-transport-fifo");
+    nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
+
+    let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
+
+    t.close().await?;
+
+    Ok(())
+}
diff --git a/rust/src/internal/hci/mod.rs b/rust/src/internal/hci/mod.rs
index 232c49f..7830e31 100644
--- a/rust/src/internal/hci/mod.rs
+++ b/rust/src/internal/hci/mod.rs
@@ -94,7 +94,7 @@
 
 impl WithPacketType<Self> for Command {
     fn to_vec_with_packet_type(self) -> Vec<u8> {
-        prepend_packet_type(PacketType::Command, self.to_vec())
+        prepend_packet_type(PacketType::Command, self)
     }
 
     fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -104,7 +104,7 @@
 
 impl WithPacketType<Self> for Acl {
     fn to_vec_with_packet_type(self) -> Vec<u8> {
-        prepend_packet_type(PacketType::Acl, self.to_vec())
+        prepend_packet_type(PacketType::Acl, self)
     }
 
     fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -114,7 +114,7 @@
 
 impl WithPacketType<Self> for Sco {
     fn to_vec_with_packet_type(self) -> Vec<u8> {
-        prepend_packet_type(PacketType::Sco, self.to_vec())
+        prepend_packet_type(PacketType::Sco, self)
     }
 
     fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -124,7 +124,7 @@
 
 impl WithPacketType<Self> for Event {
     fn to_vec_with_packet_type(self) -> Vec<u8> {
-        prepend_packet_type(PacketType::Event, self.to_vec())
+        prepend_packet_type(PacketType::Event, self)
     }
 
     fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -132,7 +132,9 @@
     }
 }
 
-fn prepend_packet_type(packet_type: PacketType, mut packet_bytes: Vec<u8>) -> Vec<u8> {
+fn prepend_packet_type<T: Packet>(packet_type: PacketType, packet: T) -> Vec<u8> {
+    // TODO: refactor if `pdl` crate adds API for writing into buffer (github.com/google/pdl/issues/74)
+    let mut packet_bytes = packet.to_vec();
     packet_bytes.insert(0, packet_type.into());
     packet_bytes
 }
diff --git a/rust/src/internal/hci/tests.rs b/rust/src/internal/hci/tests.rs
index 7962c88..ff9e72b 100644
--- a/rust/src/internal/hci/tests.rs
+++ b/rust/src/internal/hci/tests.rs
@@ -22,9 +22,8 @@
 #[test]
 fn prepends_packet_type() {
     let packet_type = PacketType::Event;
-    let packet_bytes = vec![0x00, 0x00, 0x00, 0x00];
-    let actual = prepend_packet_type(packet_type, packet_bytes);
-    assert_eq!(vec![0x04, 0x00, 0x00, 0x00, 0x00], actual);
+    let actual = prepend_packet_type(packet_type, FakePacket { bytes: vec![0xFF] });
+    assert_eq!(vec![0x04, 0xFF], actual);
 }
 
 #[test]
@@ -75,11 +74,15 @@
 }
 
 #[derive(Debug, PartialEq)]
-struct FakePacket;
+struct FakePacket {
+    bytes: Vec<u8>,
+}
 
 impl FakePacket {
-    fn parse(_bytes: &[u8]) -> Result<Self, Error> {
-        Ok(Self)
+    fn parse(bytes: &[u8]) -> Result<Self, Error> {
+        Ok(Self {
+            bytes: bytes.to_vec(),
+        })
     }
 }
 
@@ -89,6 +92,6 @@
     }
 
     fn to_vec(self) -> Vec<u8> {
-        Vec::new()
+        self.bytes
     }
 }
diff --git a/rust/src/wrapper/device.rs b/rust/src/wrapper/device/mod.rs
similarity index 62%
rename from rust/src/wrapper/device.rs
rename to rust/src/wrapper/device/mod.rs
index 6bf958a..82a274a 100644
--- a/rust/src/wrapper/device.rs
+++ b/rust/src/wrapper/device/mod.rs
@@ -14,7 +14,17 @@
 
 //! Devices and connections to them
 
-use crate::internal::hci::WithPacketType;
+#[cfg(feature = "unstable_extended_adv")]
+use crate::wrapper::{
+    hci::packets::{
+        self, AdvertisingEventProperties, AdvertisingFilterPolicy, Enable, EnabledSet,
+        FragmentPreference, LeSetAdvertisingSetRandomAddressBuilder,
+        LeSetExtendedAdvertisingDataBuilder, LeSetExtendedAdvertisingEnableBuilder,
+        LeSetExtendedAdvertisingParametersBuilder, Operation, OwnAddressType, PeerAddressType,
+        PrimaryPhyType, SecondaryPhyType,
+    },
+    ConversionError,
+};
 use crate::{
     adv::AdvertisementDataBuilder,
     wrapper::{
@@ -22,7 +32,7 @@
         gatt_client::{ProfileServiceProxy, ServiceProxy},
         hci::{
             packets::{Command, ErrorCode, Event},
-            Address, HciCommandWrapper,
+            Address, HciCommand, WithPacketType,
         },
         host::Host,
         l2cap::LeConnectionOrientedChannel,
@@ -39,6 +49,9 @@
 use pyo3_asyncio::tokio::into_future;
 use std::path;
 
+#[cfg(test)]
+mod tests;
+
 /// Represents the various properties of some device
 pub struct DeviceConfiguration(PyObject);
 
@@ -69,11 +82,24 @@
     }
 }
 
+/// Used for tracking what advertising state a device might be in
+#[derive(PartialEq)]
+enum AdvertisingStatus {
+    AdvertisingLegacy,
+    AdvertisingExtended,
+    NotAdvertising,
+}
+
 /// A device that can send/receive HCI frames.
-#[derive(Clone)]
-pub struct Device(PyObject);
+pub struct Device {
+    obj: PyObject,
+    advertising_status: AdvertisingStatus,
+}
 
 impl Device {
+    #[cfg(feature = "unstable_extended_adv")]
+    const ADVERTISING_HANDLE_EXTENDED: u8 = 0x00;
+
     /// Creates a Device. When optional arguments are not specified, the Python object specifies the
     /// defaults.
     pub fn new(
@@ -94,7 +120,10 @@
             PyModule::import(py, intern!(py, "bumble.device"))?
                 .getattr(intern!(py, "Device"))?
                 .call((), Some(kwargs))
-                .map(|any| Self(any.into()))
+                .map(|any| Self {
+                    obj: any.into(),
+                    advertising_status: AdvertisingStatus::NotAdvertising,
+                })
         })
     }
 
@@ -111,28 +140,38 @@
                     intern!(py, "from_config_file_with_hci"),
                     (device_config, source.0, sink.0),
                 )
-                .map(|any| Self(any.into()))
+                .map(|any| Self {
+                    obj: any.into(),
+                    advertising_status: AdvertisingStatus::NotAdvertising,
+                })
         })
     }
 
     /// Create a Device configured to communicate with a controller through an HCI source/sink
-    pub fn with_hci(name: &str, address: &str, source: Source, sink: Sink) -> PyResult<Self> {
+    pub fn with_hci(name: &str, address: Address, source: Source, sink: Sink) -> PyResult<Self> {
         Python::with_gil(|py| {
             PyModule::import(py, intern!(py, "bumble.device"))?
                 .getattr(intern!(py, "Device"))?
-                .call_method1(intern!(py, "with_hci"), (name, address, source.0, sink.0))
-                .map(|any| Self(any.into()))
+                .call_method1(intern!(py, "with_hci"), (name, address.0, source.0, sink.0))
+                .map(|any| Self {
+                    obj: any.into(),
+                    advertising_status: AdvertisingStatus::NotAdvertising,
+                })
         })
     }
 
     /// Sends an HCI command on this Device, returning the command's event result.
-    pub async fn send_command(&self, command: &Command, check_result: bool) -> PyResult<Event> {
+    ///
+    /// When `check_result` is `true`, then an `Err` will be returned if the controller's response
+    /// did not have an event code of "success".
+    pub async fn send_command(&self, command: Command, check_result: bool) -> PyResult<Event> {
+        let bumble_hci_command = HciCommand::try_from(command)?;
         Python::with_gil(|py| {
-            self.0
+            self.obj
                 .call_method1(
                     py,
                     intern!(py, "send_command"),
-                    (HciCommandWrapper(command.clone()), check_result),
+                    (bumble_hci_command, check_result),
                 )
                 .and_then(|coroutine| into_future(coroutine.as_ref(py)))
         })?
@@ -151,7 +190,7 @@
     /// Turn the device on
     pub async fn power_on(&self) -> PyResult<()> {
         Python::with_gil(|py| {
-            self.0
+            self.obj
                 .call_method0(py, intern!(py, "power_on"))
                 .and_then(|coroutine| into_future(coroutine.as_ref(py)))
         })?
@@ -162,7 +201,7 @@
     /// Connect to a peer
     pub async fn connect(&self, peer_addr: &str) -> PyResult<Connection> {
         Python::with_gil(|py| {
-            self.0
+            self.obj
                 .call_method1(py, intern!(py, "connect"), (peer_addr,))
                 .and_then(|coroutine| into_future(coroutine.as_ref(py)))
         })?
@@ -180,7 +219,7 @@
         });
 
         Python::with_gil(|py| {
-            self.0
+            self.obj
                 .call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
         })
         .map(|_| ())
@@ -191,7 +230,7 @@
         Python::with_gil(|py| {
             let kwargs = PyDict::new(py);
             kwargs.set_item("filter_duplicates", filter_duplicates)?;
-            self.0
+            self.obj
                 .call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
                 .and_then(|coroutine| into_future(coroutine.as_ref(py)))
         })?
@@ -209,7 +248,7 @@
         });
 
         Python::with_gil(|py| {
-            self.0
+            self.obj
                 .call_method1(py, intern!(py, "add_listener"), ("advertisement", boxed))
         })
         .map(|_| ())
@@ -218,7 +257,7 @@
     /// Set the advertisement data to be used when [Device::start_advertising] is called.
     pub fn set_advertising_data(&mut self, adv_data: AdvertisementDataBuilder) -> PyResult<()> {
         Python::with_gil(|py| {
-            self.0.setattr(
+            self.obj.setattr(
                 py,
                 intern!(py, "advertising_data"),
                 adv_data.into_bytes().as_slice(),
@@ -230,35 +269,162 @@
     /// Returns the host used by the device, if any
     pub fn host(&mut self) -> PyResult<Option<Host>> {
         Python::with_gil(|py| {
-            self.0
+            self.obj
                 .getattr(py, intern!(py, "host"))
                 .map(|obj| obj.into_option(Host::from))
         })
     }
 
     /// Start advertising the data set with [Device.set_advertisement].
+    ///
+    /// When `auto_restart` is set to `true`, then the device will automatically restart advertising
+    /// when a connected device is disconnected.
     pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
+        if self.advertising_status == AdvertisingStatus::AdvertisingExtended {
+            return Err(PyErr::new::<PyException, _>("Already advertising in extended mode. Stop the existing extended advertisement to start a legacy advertisement."));
+        }
+        // Bumble allows (and currently ignores) calling `start_advertising` when already
+        // advertising. Because that behavior may change in the future, we continue to delegate the
+        // handling to bumble.
+
         Python::with_gil(|py| {
             let kwargs = PyDict::new(py);
             kwargs.set_item("auto_restart", auto_restart)?;
 
-            self.0
+            self.obj
                 .call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
                 .and_then(|coroutine| into_future(coroutine.as_ref(py)))
         })?
         .await
-        .map(|_| ())
+        .map(|_| ())?;
+
+        self.advertising_status = AdvertisingStatus::AdvertisingLegacy;
+        Ok(())
+    }
+
+    /// Start advertising the data set in extended mode, replacing any existing extended adv. The
+    /// advertisement will be non-connectable.
+    ///
+    /// Fails if the device is already advertising in legacy mode.
+    #[cfg(feature = "unstable_extended_adv")]
+    pub async fn start_advertising_extended(
+        &mut self,
+        adv_data: AdvertisementDataBuilder,
+    ) -> PyResult<()> {
+        // TODO: add tests when local controller object supports extended advertisement commands (github.com/google/bumble/pull/238)
+        match self.advertising_status {
+            AdvertisingStatus::AdvertisingLegacy => return Err(PyErr::new::<PyException, _>("Already advertising in legacy mode. Stop the existing legacy advertisement to start an extended advertisement.")),
+            // Stop the current extended advertisement before advertising with new data.
+            // We could just issue an LeSetExtendedAdvertisingData command, but this approach
+            // allows better future flexibility if `start_advertising_extended` were to change.
+            AdvertisingStatus::AdvertisingExtended => self.stop_advertising_extended().await?,
+            _ => {}
+        }
+
+        // set extended params
+        let properties = AdvertisingEventProperties {
+            connectable: 0,
+            scannable: 0,
+            directed: 0,
+            high_duty_cycle: 0,
+            legacy: 0,
+            anonymous: 0,
+            tx_power: 0,
+        };
+        let extended_advertising_params_cmd = LeSetExtendedAdvertisingParametersBuilder {
+            advertising_event_properties: properties,
+            advertising_filter_policy: AdvertisingFilterPolicy::AllDevices,
+            advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+            advertising_sid: 0,
+            advertising_tx_power: 0,
+            own_address_type: OwnAddressType::RandomDeviceAddress,
+            peer_address: default_ignored_peer_address(),
+            peer_address_type: PeerAddressType::PublicDeviceOrIdentityAddress,
+            primary_advertising_channel_map: 7,
+            primary_advertising_interval_max: 200,
+            primary_advertising_interval_min: 100,
+            primary_advertising_phy: PrimaryPhyType::Le1m,
+            scan_request_notification_enable: Enable::Disabled,
+            secondary_advertising_max_skip: 0,
+            secondary_advertising_phy: SecondaryPhyType::Le1m,
+        };
+        self.send_command(extended_advertising_params_cmd.into(), true)
+            .await?;
+
+        // set random address
+        let random_address: packets::Address =
+            self.random_address()?.try_into().map_err(|e| match e {
+                ConversionError::Python(pyerr) => pyerr,
+                ConversionError::Native(e) => PyErr::new::<PyException, _>(format!("{e:?}")),
+            })?;
+        let random_address_cmd = LeSetAdvertisingSetRandomAddressBuilder {
+            advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+            random_address,
+        };
+        self.send_command(random_address_cmd.into(), true).await?;
+
+        // set adv data
+        let advertising_data_cmd = LeSetExtendedAdvertisingDataBuilder {
+            advertising_data: adv_data.into_bytes(),
+            advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+            fragment_preference: FragmentPreference::ControllerMayFragment,
+            operation: Operation::CompleteAdvertisement,
+        };
+        self.send_command(advertising_data_cmd.into(), true).await?;
+
+        // enable adv
+        let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder {
+            enable: Enable::Enabled,
+            enabled_sets: vec![EnabledSet {
+                advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+                duration: 0,
+                max_extended_advertising_events: 0,
+            }],
+        };
+        self.send_command(extended_advertising_enable_cmd.into(), true)
+            .await?;
+
+        self.advertising_status = AdvertisingStatus::AdvertisingExtended;
+        Ok(())
     }
 
     /// Stop advertising.
     pub async fn stop_advertising(&mut self) -> PyResult<()> {
         Python::with_gil(|py| {
-            self.0
+            self.obj
                 .call_method0(py, intern!(py, "stop_advertising"))
                 .and_then(|coroutine| into_future(coroutine.as_ref(py)))
         })?
         .await
-        .map(|_| ())
+        .map(|_| ())?;
+
+        if self.advertising_status == AdvertisingStatus::AdvertisingLegacy {
+            self.advertising_status = AdvertisingStatus::NotAdvertising;
+        }
+        Ok(())
+    }
+
+    /// Stop advertising extended.
+    #[cfg(feature = "unstable_extended_adv")]
+    pub async fn stop_advertising_extended(&mut self) -> PyResult<()> {
+        if AdvertisingStatus::AdvertisingExtended != self.advertising_status {
+            return Ok(());
+        }
+
+        // disable adv
+        let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder {
+            enable: Enable::Disabled,
+            enabled_sets: vec![EnabledSet {
+                advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+                duration: 0,
+                max_extended_advertising_events: 0,
+            }],
+        };
+        self.send_command(extended_advertising_enable_cmd.into(), true)
+            .await?;
+
+        self.advertising_status = AdvertisingStatus::NotAdvertising;
+        Ok(())
     }
 
     /// Registers an L2CAP connection oriented channel server. When a client connects to the server,
@@ -286,7 +452,7 @@
             kwargs.set_opt_item("max_credits", max_credits)?;
             kwargs.set_opt_item("mtu", mtu)?;
             kwargs.set_opt_item("mps", mps)?;
-            self.0.call_method(
+            self.obj.call_method(
                 py,
                 intern!(py, "register_l2cap_channel_server"),
                 (),
@@ -295,6 +461,15 @@
         })?;
         Ok(())
     }
+
+    /// Gets the Device's `random_address` property
+    pub fn random_address(&self) -> PyResult<Address> {
+        Python::with_gil(|py| {
+            self.obj
+                .getattr(py, intern!(py, "random_address"))
+                .map(Address)
+        })
+    }
 }
 
 /// A connection to a remote device.
@@ -451,3 +626,13 @@
         Python::with_gil(|py| self.0.getattr(py, intern!(py, "data")).map(AdvertisingData))
     }
 }
+
+/// Use this address when sending an HCI command that requires providing a peer address, but the
+/// command is such that the peer address will be ignored.
+///
+/// Internal to bumble, this address might mean "any", but a packets::Address typically gets sent
+/// directly to a controller, so we don't have to worry about it.
+#[cfg(feature = "unstable_extended_adv")]
+fn default_ignored_peer_address() -> packets::Address {
+    packets::Address::try_from(0x0000_0000_0000_u64).unwrap()
+}
diff --git a/rust/src/wrapper/device/tests.rs b/rust/src/wrapper/device/tests.rs
new file mode 100644
index 0000000..648b919
--- /dev/null
+++ b/rust/src/wrapper/device/tests.rs
@@ -0,0 +1,23 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#[cfg(feature = "unstable_extended_adv")]
+use crate::wrapper::device::default_ignored_peer_address;
+
+#[test]
+#[cfg(feature = "unstable_extended_adv")]
+fn default_peer_address_does_not_panic() {
+    let result = std::panic::catch_unwind(default_ignored_peer_address);
+    assert!(result.is_ok())
+}
diff --git a/rust/src/wrapper/hci.rs b/rust/src/wrapper/hci.rs
index b029a65..533fe21 100644
--- a/rust/src/wrapper/hci.rs
+++ b/rust/src/wrapper/hci.rs
@@ -14,18 +14,19 @@
 
 //! HCI
 
+// re-export here, and internal usages of these imports should refer to this mod, not the internal
+// mod
+pub(crate) use crate::internal::hci::WithPacketType;
 pub use crate::internal::hci::{packets, Error, Packet};
 
-use crate::{
-    internal::hci::WithPacketType,
-    wrapper::hci::packets::{AddressType, Command, ErrorCode},
+use crate::wrapper::{
+    hci::packets::{AddressType, Command, ErrorCode},
+    ConversionError,
 };
 use itertools::Itertools as _;
 use pyo3::{
-    exceptions::PyException,
-    intern, pyclass, pymethods,
-    types::{PyBytes, PyModule},
-    FromPyObject, IntoPy, PyAny, PyErr, PyObject, PyResult, Python, ToPyObject,
+    exceptions::PyException, intern, types::PyModule, FromPyObject, IntoPy, PyAny, PyErr, PyObject,
+    PyResult, Python, ToPyObject,
 };
 
 /// Provides helpers for interacting with HCI
@@ -43,17 +44,45 @@
     }
 }
 
+/// Bumble's representation of an HCI command.
+pub(crate) struct HciCommand(pub(crate) PyObject);
+
+impl HciCommand {
+    fn from_bytes(bytes: &[u8]) -> PyResult<Self> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.hci"))?
+                .getattr(intern!(py, "HCI_Command"))?
+                .call_method1(intern!(py, "from_bytes"), (bytes,))
+                .map(|obj| Self(obj.to_object(py)))
+        })
+    }
+}
+
+impl TryFrom<Command> for HciCommand {
+    type Error = PyErr;
+
+    fn try_from(value: Command) -> Result<Self, Self::Error> {
+        HciCommand::from_bytes(&value.to_vec_with_packet_type())
+    }
+}
+
+impl IntoPy<PyObject> for HciCommand {
+    fn into_py(self, _py: Python<'_>) -> PyObject {
+        self.0
+    }
+}
+
 /// A Bluetooth address
 #[derive(Clone)]
 pub struct Address(pub(crate) PyObject);
 
 impl Address {
-    /// Creates a new [Address] object
-    pub fn new(address: &str, address_type: &AddressType) -> PyResult<Self> {
+    /// Creates a new [Address] object.
+    pub fn new(address: &str, address_type: AddressType) -> PyResult<Self> {
         Python::with_gil(|py| {
             PyModule::import(py, intern!(py, "bumble.device"))?
                 .getattr(intern!(py, "Address"))?
-                .call1((address, address_type.to_object(py)))
+                .call1((address, address_type))
                 .map(|any| Self(any.into()))
         })
     }
@@ -118,27 +147,31 @@
     }
 }
 
-/// Implements minimum necessary interface to be treated as bumble's [HCI_Command].
-/// While pyo3's macros do not support generics, this could probably be refactored to allow multiple
-/// implementations of the HCI_Command methods in the future, if needed.
-#[pyclass]
-pub(crate) struct HciCommandWrapper(pub(crate) Command);
+/// An error meaning that the u64 value did not represent a valid BT address.
+#[derive(Debug)]
+pub struct InvalidAddress(u64);
 
-#[pymethods]
-impl HciCommandWrapper {
-    fn __bytes__(&self, py: Python) -> PyResult<PyObject> {
-        let bytes = PyBytes::new(py, &self.0.clone().to_vec_with_packet_type());
-        Ok(bytes.into_py(py))
-    }
+impl TryInto<packets::Address> for Address {
+    type Error = ConversionError<InvalidAddress>;
 
-    #[getter]
-    fn op_code(&self) -> u16 {
-        self.0.get_op_code().into()
+    fn try_into(self) -> Result<packets::Address, Self::Error> {
+        let addr_le_bytes = self.as_le_bytes().map_err(ConversionError::Python)?;
+
+        // packets::Address only supports converting from a u64 (TODO: update if/when it supports converting from [u8; 6] -- https://github.com/google/pdl/issues/75)
+        // So first we take the python `Address` little-endian bytes (6 bytes), copy them into a
+        // [u8; 8] in little-endian format, and finally convert it into a u64.
+        let mut buf = [0_u8; 8];
+        buf[0..6].copy_from_slice(&addr_le_bytes);
+        let address_u64 = u64::from_le_bytes(buf);
+
+        packets::Address::try_from(address_u64)
+            .map_err(InvalidAddress)
+            .map_err(ConversionError::Native)
     }
 }
 
-impl ToPyObject for AddressType {
-    fn to_object(&self, py: Python<'_>) -> PyObject {
+impl IntoPy<PyObject> for AddressType {
+    fn into_py(self, py: Python<'_>) -> PyObject {
         u8::from(self).to_object(py)
     }
 }
diff --git a/rust/src/wrapper/mod.rs b/rust/src/wrapper/mod.rs
index 27b86d9..afe437d 100644
--- a/rust/src/wrapper/mod.rs
+++ b/rust/src/wrapper/mod.rs
@@ -132,3 +132,12 @@
         .getattr(intern!(py, "wrap_async"))?
         .call1((function,))
 }
+
+/// Represents the two major kinds of errors that can occur when converting between Rust and Python.
+pub enum ConversionError<T> {
+    /// Occurs across the Python/native boundary.
+    Python(PyErr),
+    /// Occurs within the native ecosystem, such as when performing more transformations before
+    /// finally converting to the native type.
+    Native(T),
+}
diff --git a/rust/src/wrapper/transport.rs b/rust/src/wrapper/transport.rs
index a7ec9e9..8c62687 100644
--- a/rust/src/wrapper/transport.rs
+++ b/rust/src/wrapper/transport.rs
@@ -15,6 +15,7 @@
 //! HCI packet transport
 
 use crate::wrapper::controller::Controller;
+use futures::executor::block_on;
 use pyo3::{intern, types::PyModule, PyObject, PyResult, Python};
 
 /// A source/sink pair for HCI packet I/O.
@@ -58,9 +59,9 @@
 
 impl Drop for Transport {
     fn drop(&mut self) {
-        // can't await in a Drop impl, but we can at least spawn a task to do it
-        let obj = self.0.clone();
-        tokio::spawn(async move { Self(obj).close().await });
+        // don't spawn a thread to handle closing, as it may get dropped at program termination,
+        // resulting in `RuntimeWarning: coroutine ... was never awaited` from Python
+        let _ = block_on(self.close());
     }
 }
 
diff --git a/setup.cfg b/setup.cfg
index 5cdf35a..e29288b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,4 +1,4 @@
-# Copyright 2021-2022 Google LLC
+# Copyright 2021-2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -24,7 +24,7 @@
 
 [options]
 python_requires = >=3.8
-packages = bumble, bumble.transport, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
+packages = bumble, bumble.transport, bumble.transport.grpc_protobuf, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
 package_dir =
     bumble = bumble
     bumble.apps = apps
@@ -52,12 +52,14 @@
     pyserial-asyncio >= 0.5; platform_system!='Emscripten'
     pyserial >= 3.5; platform_system!='Emscripten'
     pyusb >= 1.2; platform_system!='Emscripten'
-    websockets >= 8.1; platform_system!='Emscripten'
+    websockets >= 12.0; platform_system!='Emscripten'
 
 [options.entry_points]
 console_scripts =
+    bumble-ble-rpa-tool = bumble.apps.ble_rpa_tool:main
     bumble-console = bumble.apps.console:main
     bumble-controller-info = bumble.apps.controller_info:main
+    bumble-controller-loopback = bumble.apps.controller_loopback:main
     bumble-gatt-dump = bumble.apps.gatt_dump:main
     bumble-hci-bridge = bumble.apps.hci_bridge:main
     bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
@@ -80,15 +82,15 @@
 build =
     build >= 0.7
 test =
-    pytest >= 6.2
-    pytest-asyncio >= 0.17
+    pytest >= 8.0
+    pytest-asyncio == 0.21.1
     pytest-html >= 3.2.0
     coverage >= 6.4
 development =
     black == 22.10
     grpcio-tools >= 1.57.0
     invoke >= 1.7.3
-    mypy == 1.5.0
+    mypy == 1.8.0
     nox >= 2022
     pylint == 2.15.8
     pyyaml >= 6.0
@@ -96,8 +98,8 @@
     types-invoke >= 1.7.3
     types-protobuf >= 4.21.0
 avatar =
-    pandora-avatar == 0.0.5
-    rootcanal == 1.3.0 ; python_version>='3.10'
+    pandora-avatar == 0.0.8
+    rootcanal == 1.9.0 ; python_version>='3.10'
 documentation =
     mkdocs >= 1.4.0
     mkdocs-material >= 8.5.6
diff --git a/tasks.py b/tasks.py
index 6df5a8b..fab7cf1 100644
--- a/tasks.py
+++ b/tasks.py
@@ -125,7 +125,7 @@
     print(f">>> Running the linter{qualifier}...")
     try:
         ctx.run(f"pylint {' '.join(options)} bumble apps examples tasks.py")
-        print("The linter is happy. ✅ 😊 🐝'")
+        print("The linter is happy. ✅ 😊 🐝")
     except UnexpectedExit as exc:
         print("Please check your code against the linter messages. ❌")
         raise Exit(code=1) from exc
diff --git a/tests/avrcp_test.py b/tests/avrcp_test.py
new file mode 100644
index 0000000..103f360
--- /dev/null
+++ b/tests/avrcp_test.py
@@ -0,0 +1,246 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import struct
+
+import pytest
+
+from bumble import core
+from bumble import device
+from bumble import host
+from bumble import controller
+from bumble import link
+from bumble import avc
+from bumble import avrcp
+from bumble import avctp
+from bumble.transport import common
+
+
+# -----------------------------------------------------------------------------
+class TwoDevices:
+    def __init__(self):
+        self.connections = [None, None]
+
+        addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
+        self.link = link.LocalLink()
+        self.controllers = [
+            controller.Controller('C1', link=self.link, public_address=addresses[0]),
+            controller.Controller('C2', link=self.link, public_address=addresses[1]),
+        ]
+        self.devices = [
+            device.Device(
+                address=addresses[0],
+                host=host.Host(
+                    self.controllers[0], common.AsyncPipeSink(self.controllers[0])
+                ),
+            ),
+            device.Device(
+                address=addresses[1],
+                host=host.Host(
+                    self.controllers[1], common.AsyncPipeSink(self.controllers[1])
+                ),
+            ),
+        ]
+        self.devices[0].classic_enabled = True
+        self.devices[1].classic_enabled = True
+        self.connections = [None, None]
+        self.protocols = [None, None]
+
+    def on_connection(self, which, connection):
+        self.connections[which] = connection
+
+    async def setup_connections(self):
+        await self.devices[0].power_on()
+        await self.devices[1].power_on()
+
+        self.connections = await asyncio.gather(
+            self.devices[0].connect(
+                self.devices[1].public_address, core.BT_BR_EDR_TRANSPORT
+            ),
+            self.devices[1].accept(self.devices[0].public_address),
+        )
+
+        self.protocols = [avrcp.Protocol(), avrcp.Protocol()]
+        self.protocols[0].listen(self.devices[1])
+        await self.protocols[1].connect(self.connections[0])
+
+
+# -----------------------------------------------------------------------------
+def test_frame_parser():
+    with pytest.raises(ValueError) as error:
+        avc.Frame.from_bytes(bytes.fromhex("11480000"))
+
+    x = bytes.fromhex("014D0208")
+    frame = avc.Frame.from_bytes(x)
+    assert frame.subunit_type == avc.Frame.SubunitType.PANEL
+    assert frame.subunit_id == 7
+    assert frame.opcode == 8
+
+    x = bytes.fromhex("014DFF0108")
+    frame = avc.Frame.from_bytes(x)
+    assert frame.subunit_type == avc.Frame.SubunitType.PANEL
+    assert frame.subunit_id == 260
+    assert frame.opcode == 8
+
+    x = bytes.fromhex("0148000019581000000103")
+
+    frame = avc.Frame.from_bytes(x)
+
+    assert isinstance(frame, avc.CommandFrame)
+    assert frame.ctype == avc.CommandFrame.CommandType.STATUS
+    assert frame.subunit_type == avc.Frame.SubunitType.PANEL
+    assert frame.subunit_id == 0
+    assert frame.opcode == 0
+
+
+# -----------------------------------------------------------------------------
+def test_vendor_dependent_command():
+    x = bytes.fromhex("0148000019581000000103")
+    frame = avc.Frame.from_bytes(x)
+    assert isinstance(frame, avc.VendorDependentCommandFrame)
+    assert frame.company_id == 0x1958
+    assert frame.vendor_dependent_data == bytes.fromhex("1000000103")
+
+    frame = avc.VendorDependentCommandFrame(
+        avc.CommandFrame.CommandType.STATUS,
+        avc.Frame.SubunitType.PANEL,
+        0,
+        0x1958,
+        bytes.fromhex("1000000103"),
+    )
+    assert bytes(frame) == x
+
+
+# -----------------------------------------------------------------------------
+def test_avctp_message_assembler():
+    received_message = []
+
+    def on_message(transaction_label, is_response, ipid, pid, payload):
+        received_message.append((transaction_label, is_response, ipid, pid, payload))
+
+    assembler = avctp.MessageAssembler(on_message)
+
+    payload = bytes.fromhex("01")
+    assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
+    assert received_message
+    assert received_message[0] == (1, False, False, 0x1122, payload)
+
+    received_message = []
+    payload = bytes.fromhex("010203")
+    assembler.on_pdu(bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
+    assert len(received_message) == 0
+    assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
+    assert received_message
+    assert received_message[0] == (1, False, False, 0x1122, payload)
+
+    received_message = []
+    payload = bytes.fromhex("010203")
+    assembler.on_pdu(
+        bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 3, 0x11, 0x22]) + payload[0:1]
+    )
+    assembler.on_pdu(
+        bytes([1 << 4 | 0b10 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[1:2]
+    )
+    assembler.on_pdu(
+        bytes([1 << 4 | 0b11 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[2:3]
+    )
+    assert received_message
+    assert received_message[0] == (1, False, False, 0x1122, payload)
+
+    # received_message = []
+    # parameter = bytes.fromhex("010203")
+    # assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
+    # assert len(received_message) == 0
+
+
+# -----------------------------------------------------------------------------
+def test_avrcp_pdu_assembler():
+    received_pdus = []
+
+    def on_pdu(pdu_id, parameter):
+        received_pdus.append((pdu_id, parameter))
+
+    assembler = avrcp.PduAssembler(on_pdu)
+
+    parameter = bytes.fromhex("01")
+    assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
+    assert received_pdus
+    assert received_pdus[0] == (0x10, parameter)
+
+    received_pdus = []
+    parameter = bytes.fromhex("010203")
+    assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, len(parameter)) + parameter)
+    assert len(received_pdus) == 0
+    assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
+    assert received_pdus
+    assert received_pdus[0] == (0x10, parameter)
+
+    received_pdus = []
+    parameter = bytes.fromhex("010203")
+    assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, 1) + parameter[0:1])
+    assembler.on_pdu(struct.pack(">BBH", 0x10, 0b10, 1) + parameter[1:2])
+    assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, 1) + parameter[2:3])
+    assert received_pdus
+    assert received_pdus[0] == (0x10, parameter)
+
+    received_pdus = []
+    parameter = bytes.fromhex("010203")
+    assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
+    assert len(received_pdus) == 0
+
+
+def test_passthrough_commands():
+    play_pressed = avc.PassThroughCommandFrame(
+        avc.CommandFrame.CommandType.CONTROL,
+        avc.CommandFrame.SubunitType.PANEL,
+        0,
+        avc.PassThroughCommandFrame.StateFlag.PRESSED,
+        avc.PassThroughCommandFrame.OperationId.PLAY,
+        b'',
+    )
+
+    play_pressed_bytes = bytes(play_pressed)
+    parsed = avc.Frame.from_bytes(play_pressed_bytes)
+    assert isinstance(parsed, avc.PassThroughCommandFrame)
+    assert parsed.operation_id == avc.PassThroughCommandFrame.OperationId.PLAY
+    assert bytes(parsed) == play_pressed_bytes
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_get_supported_events():
+    two_devices = TwoDevices()
+    await two_devices.setup_connections()
+
+    supported_events = await two_devices.protocols[0].get_supported_events()
+    assert supported_events == []
+
+    delegate1 = avrcp.Delegate([avrcp.EventId.VOLUME_CHANGED])
+    two_devices.protocols[0].delegate = delegate1
+    supported_events = await two_devices.protocols[1].get_supported_events()
+    assert supported_events == [avrcp.EventId.VOLUME_CHANGED]
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    test_frame_parser()
+    test_vendor_dependent_command()
+    test_avctp_message_assembler()
+    test_avrcp_pdu_assembler()
+    test_passthrough_commands()
+    test_get_supported_events()
diff --git a/tests/bap_test.py b/tests/bap_test.py
new file mode 100644
index 0000000..bc223c1
--- /dev/null
+++ b/tests/bap_test.py
@@ -0,0 +1,403 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import os
+import functools
+import pytest
+import logging
+
+from bumble import device
+from bumble.hci import CodecID, CodingFormat
+from bumble.profiles.bap import (
+    AudioLocation,
+    AseStateMachine,
+    ASE_Operation,
+    ASE_Config_Codec,
+    ASE_Config_QOS,
+    ASE_Disable,
+    ASE_Enable,
+    ASE_Receiver_Start_Ready,
+    ASE_Receiver_Stop_Ready,
+    ASE_Release,
+    ASE_Update_Metadata,
+    SupportedFrameDuration,
+    SupportedSamplingFrequency,
+    SamplingFrequency,
+    FrameDuration,
+    CodecSpecificCapabilities,
+    CodecSpecificConfiguration,
+    ContextType,
+    PacRecord,
+    AudioStreamControlService,
+    AudioStreamControlServiceProxy,
+    PublishedAudioCapabilitiesService,
+    PublishedAudioCapabilitiesServiceProxy,
+)
+from tests.test_utils import TwoDevices
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+def basic_check(operation: ASE_Operation):
+    serialized = bytes(operation)
+    parsed = ASE_Operation.from_bytes(serialized)
+    assert bytes(parsed) == serialized
+
+
+# -----------------------------------------------------------------------------
+def test_codec_specific_capabilities() -> None:
+    SAMPLE_FREQUENCY = SupportedSamplingFrequency.FREQ_16000
+    FRAME_SURATION = SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+    AUDIO_CHANNEL_COUNTS = [1]
+    cap = CodecSpecificCapabilities(
+        supported_sampling_frequencies=SAMPLE_FREQUENCY,
+        supported_frame_durations=FRAME_SURATION,
+        supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS,
+        min_octets_per_codec_frame=40,
+        max_octets_per_codec_frame=40,
+        supported_max_codec_frames_per_sdu=1,
+    )
+    assert CodecSpecificCapabilities.from_bytes(bytes(cap)) == cap
+
+
+# -----------------------------------------------------------------------------
+def test_pac_record() -> None:
+    SAMPLE_FREQUENCY = SupportedSamplingFrequency.FREQ_16000
+    FRAME_SURATION = SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+    AUDIO_CHANNEL_COUNTS = [1]
+    cap = CodecSpecificCapabilities(
+        supported_sampling_frequencies=SAMPLE_FREQUENCY,
+        supported_frame_durations=FRAME_SURATION,
+        supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS,
+        min_octets_per_codec_frame=40,
+        max_octets_per_codec_frame=40,
+        supported_max_codec_frames_per_sdu=1,
+    )
+
+    pac_record = PacRecord(
+        coding_format=CodingFormat(CodecID.LC3),
+        codec_specific_capabilities=cap,
+        metadata=b'',
+    )
+    assert PacRecord.from_bytes(bytes(pac_record)) == pac_record
+
+
+# -----------------------------------------------------------------------------
+def test_vendor_specific_pac_record() -> None:
+    # Vendor-Specific codec, Google, ID=0xFFFF. No capabilities and metadata.
+    RAW_DATA = bytes.fromhex('ffe000ffff0000')
+    assert bytes(PacRecord.from_bytes(RAW_DATA)) == RAW_DATA
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Config_Codec() -> None:
+    operation = ASE_Config_Codec(
+        ase_id=[1, 2],
+        target_latency=[3, 4],
+        target_phy=[5, 6],
+        codec_id=[CodingFormat(CodecID.LC3), CodingFormat(CodecID.LC3)],
+        codec_specific_configuration=[b'foo', b'bar'],
+    )
+    basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Config_QOS() -> None:
+    operation = ASE_Config_QOS(
+        ase_id=[1, 2],
+        cig_id=[1, 2],
+        cis_id=[3, 4],
+        sdu_interval=[5, 6],
+        framing=[0, 1],
+        phy=[2, 3],
+        max_sdu=[4, 5],
+        retransmission_number=[6, 7],
+        max_transport_latency=[8, 9],
+        presentation_delay=[10, 11],
+    )
+    basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Enable() -> None:
+    operation = ASE_Enable(
+        ase_id=[1, 2],
+        metadata=[b'foo', b'bar'],
+    )
+    basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Update_Metadata() -> None:
+    operation = ASE_Update_Metadata(
+        ase_id=[1, 2],
+        metadata=[b'foo', b'bar'],
+    )
+    basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Disable() -> None:
+    operation = ASE_Disable(ase_id=[1, 2])
+    basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Release() -> None:
+    operation = ASE_Release(ase_id=[1, 2])
+    basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Receiver_Start_Ready() -> None:
+    operation = ASE_Receiver_Start_Ready(ase_id=[1, 2])
+    basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Receiver_Stop_Ready() -> None:
+    operation = ASE_Receiver_Stop_Ready(ase_id=[1, 2])
+    basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_codec_specific_configuration() -> None:
+    SAMPLE_FREQUENCY = SamplingFrequency.FREQ_16000
+    FRAME_SURATION = FrameDuration.DURATION_10000_US
+    AUDIO_LOCATION = AudioLocation.FRONT_LEFT
+    config = CodecSpecificConfiguration(
+        sampling_frequency=SAMPLE_FREQUENCY,
+        frame_duration=FRAME_SURATION,
+        audio_channel_allocation=AUDIO_LOCATION,
+        octets_per_codec_frame=60,
+        codec_frames_per_sdu=1,
+    )
+    assert CodecSpecificConfiguration.from_bytes(bytes(config)) == config
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_pacs():
+    devices = TwoDevices()
+    devices[0].add_service(
+        PublishedAudioCapabilitiesService(
+            supported_sink_context=ContextType.MEDIA,
+            available_sink_context=ContextType.MEDIA,
+            supported_source_context=0,
+            available_source_context=0,
+            sink_pac=[
+                # Codec Capability Setting 16_2
+                PacRecord(
+                    coding_format=CodingFormat(CodecID.LC3),
+                    codec_specific_capabilities=CodecSpecificCapabilities(
+                        supported_sampling_frequencies=(
+                            SupportedSamplingFrequency.FREQ_16000
+                        ),
+                        supported_frame_durations=(
+                            SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+                        ),
+                        supported_audio_channel_counts=[1],
+                        min_octets_per_codec_frame=40,
+                        max_octets_per_codec_frame=40,
+                        supported_max_codec_frames_per_sdu=1,
+                    ),
+                ),
+                # Codec Capability Setting 24_2
+                PacRecord(
+                    coding_format=CodingFormat(CodecID.LC3),
+                    codec_specific_capabilities=CodecSpecificCapabilities(
+                        supported_sampling_frequencies=(
+                            SupportedSamplingFrequency.FREQ_24000
+                        ),
+                        supported_frame_durations=(
+                            SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+                        ),
+                        supported_audio_channel_counts=[1],
+                        min_octets_per_codec_frame=60,
+                        max_octets_per_codec_frame=60,
+                        supported_max_codec_frames_per_sdu=1,
+                    ),
+                ),
+            ],
+            sink_audio_locations=AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT,
+        )
+    )
+
+    await devices.setup_connection()
+    peer = device.Peer(devices.connections[1])
+    pacs_client = await peer.discover_service_and_create_proxy(
+        PublishedAudioCapabilitiesServiceProxy
+    )
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_ascs():
+    devices = TwoDevices()
+    devices[0].add_service(
+        AudioStreamControlService(device=devices[0], sink_ase_id=[1, 2])
+    )
+
+    await devices.setup_connection()
+    peer = device.Peer(devices.connections[1])
+    ascs_client = await peer.discover_service_and_create_proxy(
+        AudioStreamControlServiceProxy
+    )
+
+    notifications = {1: asyncio.Queue(), 2: asyncio.Queue()}
+
+    def on_notification(data: bytes, ase_id: int):
+        notifications[ase_id].put_nowait(data)
+
+    # Should be idle
+    assert await ascs_client.sink_ase[0].read_value() == bytes(
+        [1, AseStateMachine.State.IDLE]
+    )
+    assert await ascs_client.sink_ase[1].read_value() == bytes(
+        [2, AseStateMachine.State.IDLE]
+    )
+
+    # Subscribe
+    await ascs_client.sink_ase[0].subscribe(
+        functools.partial(on_notification, ase_id=1)
+    )
+    await ascs_client.sink_ase[1].subscribe(
+        functools.partial(on_notification, ase_id=2)
+    )
+
+    # Config Codec
+    config = CodecSpecificConfiguration(
+        sampling_frequency=SamplingFrequency.FREQ_48000,
+        frame_duration=FrameDuration.DURATION_10000_US,
+        audio_channel_allocation=AudioLocation.FRONT_LEFT,
+        octets_per_codec_frame=120,
+        codec_frames_per_sdu=1,
+    )
+    await ascs_client.ase_control_point.write_value(
+        ASE_Config_Codec(
+            ase_id=[1, 2],
+            target_latency=[3, 4],
+            target_phy=[5, 6],
+            codec_id=[CodingFormat(CodecID.LC3), CodingFormat(CodecID.LC3)],
+            codec_specific_configuration=[config, config],
+        )
+    )
+    assert (await notifications[1].get())[:2] == bytes(
+        [1, AseStateMachine.State.CODEC_CONFIGURED]
+    )
+    assert (await notifications[2].get())[:2] == bytes(
+        [2, AseStateMachine.State.CODEC_CONFIGURED]
+    )
+
+    # Config QOS
+    await ascs_client.ase_control_point.write_value(
+        ASE_Config_QOS(
+            ase_id=[1, 2],
+            cig_id=[1, 2],
+            cis_id=[3, 4],
+            sdu_interval=[5, 6],
+            framing=[0, 1],
+            phy=[2, 3],
+            max_sdu=[4, 5],
+            retransmission_number=[6, 7],
+            max_transport_latency=[8, 9],
+            presentation_delay=[10, 11],
+        )
+    )
+    assert (await notifications[1].get())[:2] == bytes(
+        [1, AseStateMachine.State.QOS_CONFIGURED]
+    )
+    assert (await notifications[2].get())[:2] == bytes(
+        [2, AseStateMachine.State.QOS_CONFIGURED]
+    )
+
+    # Enable
+    await ascs_client.ase_control_point.write_value(
+        ASE_Enable(
+            ase_id=[1, 2],
+            metadata=[b'foo', b'bar'],
+        )
+    )
+    assert (await notifications[1].get())[:2] == bytes(
+        [1, AseStateMachine.State.ENABLING]
+    )
+    assert (await notifications[2].get())[:2] == bytes(
+        [2, AseStateMachine.State.ENABLING]
+    )
+
+    # CIS establishment
+    devices[0].emit(
+        'cis_establishment',
+        device.CisLink(
+            device=devices[0],
+            acl_connection=devices.connections[0],
+            handle=5,
+            cis_id=3,
+            cig_id=1,
+        ),
+    )
+    devices[0].emit(
+        'cis_establishment',
+        device.CisLink(
+            device=devices[0],
+            acl_connection=devices.connections[0],
+            handle=6,
+            cis_id=4,
+            cig_id=2,
+        ),
+    )
+    assert (await notifications[1].get())[:2] == bytes(
+        [1, AseStateMachine.State.STREAMING]
+    )
+    assert (await notifications[2].get())[:2] == bytes(
+        [2, AseStateMachine.State.STREAMING]
+    )
+
+    # Release
+    await ascs_client.ase_control_point.write_value(
+        ASE_Release(
+            ase_id=[1, 2],
+            metadata=[b'foo', b'bar'],
+        )
+    )
+    assert (await notifications[1].get())[:2] == bytes(
+        [1, AseStateMachine.State.RELEASING]
+    )
+    assert (await notifications[2].get())[:2] == bytes(
+        [2, AseStateMachine.State.RELEASING]
+    )
+    assert (await notifications[1].get())[:2] == bytes([1, AseStateMachine.State.IDLE])
+    assert (await notifications[2].get())[:2] == bytes([2, AseStateMachine.State.IDLE])
+
+    await asyncio.sleep(0.001)
+
+
+# -----------------------------------------------------------------------------
+async def run():
+    await test_pacs()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run())
diff --git a/tests/cap_test.py b/tests/cap_test.py
new file mode 100644
index 0000000..ab5ab81
--- /dev/null
+++ b/tests/cap_test.py
@@ -0,0 +1,71 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import os
+import pytest
+import logging
+
+from bumble import device
+from bumble import gatt
+from bumble.profiles import cap
+from bumble.profiles import csip
+from .test_utils import TwoDevices
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_cas():
+    SIRK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
+
+    devices = TwoDevices()
+    devices[0].add_service(
+        cap.CommonAudioServiceService(
+            csip.CoordinatedSetIdentificationService(
+                set_identity_resolving_key=SIRK,
+                set_identity_resolving_key_type=csip.SirkType.PLAINTEXT,
+            )
+        )
+    )
+
+    await devices.setup_connection()
+    peer = device.Peer(devices.connections[1])
+    cas_client = await peer.discover_service_and_create_proxy(
+        cap.CommonAudioServiceServiceProxy
+    )
+
+    included_services = await peer.discover_included_services(cas_client.service_proxy)
+    assert any(
+        service.uuid == gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
+        for service in included_services
+    )
+
+
+# -----------------------------------------------------------------------------
+async def run():
+    await test_cas()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run())
diff --git a/tests/csip_test.py b/tests/csip_test.py
new file mode 100644
index 0000000..b34c426
--- /dev/null
+++ b/tests/csip_test.py
@@ -0,0 +1,120 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import os
+import pytest
+import struct
+import logging
+from unittest import mock
+
+from bumble import device
+from bumble.profiles import csip
+from .test_utils import TwoDevices
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+def test_s1():
+    assert (
+        csip.s1(b'SIRKenc'[::-1])
+        == bytes.fromhex('6901983f 18149e82 3c7d133a 7d774572')[::-1]
+    )
+
+
+# -----------------------------------------------------------------------------
+def test_k1():
+    K = bytes.fromhex('676e1b9b d448696f 061ec622 3ce5ced9')[::-1]
+    SALT = csip.s1(b'SIRKenc'[::-1])
+    P = b'csis'[::-1]
+    assert (
+        csip.k1(K, SALT, P)
+        == bytes.fromhex('5277453c c094d982 b0e8ee53 2f2d1f8b')[::-1]
+    )
+
+
+# -----------------------------------------------------------------------------
+def test_sih():
+    SIRK = bytes.fromhex('457d7d09 21a1fd22 cecd8c86 dd72cccd')[::-1]
+    PRAND = bytes.fromhex('69f563')[::-1]
+    assert csip.sih(SIRK, PRAND) == bytes.fromhex('1948da')[::-1]
+
+
+# -----------------------------------------------------------------------------
+def test_sef():
+    SIRK = bytes.fromhex('457d7d09 21a1fd22 cecd8c86 dd72cccd')[::-1]
+    K = bytes.fromhex('676e1b9b d448696f 061ec622 3ce5ced9')[::-1]
+    assert (
+        csip.sef(K, SIRK) == bytes.fromhex('170a3835 e13524a0 7e2562d5 f25fd346')[::-1]
+    )
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    'sirk_type,', [(csip.SirkType.ENCRYPTED), (csip.SirkType.PLAINTEXT)]
+)
+async def test_csis(sirk_type):
+    SIRK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
+    LTK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
+
+    devices = TwoDevices()
+    devices[0].add_service(
+        csip.CoordinatedSetIdentificationService(
+            set_identity_resolving_key=SIRK,
+            set_identity_resolving_key_type=sirk_type,
+            coordinated_set_size=2,
+            set_member_lock=csip.MemberLock.UNLOCKED,
+            set_member_rank=0,
+        )
+    )
+
+    await devices.setup_connection()
+
+    # Mock encryption.
+    devices.connections[0].encryption = 1
+    devices.connections[1].encryption = 1
+    devices[0].get_long_term_key = mock.AsyncMock(return_value=LTK)
+    devices[1].get_long_term_key = mock.AsyncMock(return_value=LTK)
+
+    peer = device.Peer(devices.connections[1])
+    csis_client = await peer.discover_service_and_create_proxy(
+        csip.CoordinatedSetIdentificationProxy
+    )
+
+    assert await csis_client.read_set_identity_resolving_key() == (sirk_type, SIRK)
+    assert await csis_client.coordinated_set_size.read_value() == struct.pack('B', 2)
+    assert await csis_client.set_member_lock.read_value() == struct.pack(
+        'B', csip.MemberLock.UNLOCKED
+    )
+    assert await csis_client.set_member_rank.read_value() == struct.pack('B', 0)
+
+
+# -----------------------------------------------------------------------------
+async def run():
+    test_sih()
+    await test_csis()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run())
diff --git a/tests/device_test.py b/tests/device_test.py
index 1bcd0d0..5d87282 100644
--- a/tests/device_test.py
+++ b/tests/device_test.py
@@ -20,16 +20,23 @@
 import os
 from types import LambdaType
 import pytest
+from unittest import mock
 
-from bumble.core import BT_BR_EDR_TRANSPORT
-from bumble.device import Connection, Device
-from bumble.host import Host
+from bumble.core import (
+    BT_BR_EDR_TRANSPORT,
+    BT_LE_TRANSPORT,
+    BT_PERIPHERAL_ROLE,
+    ConnectionParameters,
+)
+from bumble.device import AdvertisingParameters, Connection, Device
+from bumble.host import AclPacketQueue, Host
 from bumble.hci import (
     HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
     HCI_COMMAND_STATUS_PENDING,
     HCI_CREATE_CONNECTION_COMMAND,
     HCI_SUCCESS,
     Address,
+    OwnAddressType,
     HCI_Command_Complete_Event,
     HCI_Command_Status_Event,
     HCI_Connection_Complete_Event,
@@ -43,6 +50,9 @@
     GATT_APPEARANCE_CHARACTERISTIC,
 )
 
+from .test_utils import TwoDevices, async_barrier
+
+
 # -----------------------------------------------------------------------------
 # Logging
 # -----------------------------------------------------------------------------
@@ -66,6 +76,13 @@
     d1 = Device(host=Host(None, None))
     d2 = Device(host=Host(None, None))
 
+    def _send(packet):
+        pass
+
+    d0.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
+    d1.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
+    d2.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
+
     # enable classic
     d0.classic_enabled = True
     d1.classic_enabled = True
@@ -233,6 +250,190 @@
 
 
 # -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_legacy_advertising():
+    device = Device(host=mock.AsyncMock(Host))
+
+    # Start advertising
+    await device.start_advertising()
+    assert device.is_advertising
+
+    # Stop advertising
+    await device.stop_advertising()
+    assert not device.is_advertising
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+    'own_address_type,',
+    (OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
+)
+@pytest.mark.asyncio
+async def test_legacy_advertising_connection(own_address_type):
+    device = Device(host=mock.AsyncMock(Host))
+    peer_address = Address('F0:F1:F2:F3:F4:F5')
+
+    # Start advertising
+    await device.start_advertising()
+    device.on_connection(
+        0x0001,
+        BT_LE_TRANSPORT,
+        peer_address,
+        BT_PERIPHERAL_ROLE,
+        ConnectionParameters(0, 0, 0),
+    )
+
+    if own_address_type == OwnAddressType.PUBLIC:
+        assert device.lookup_connection(0x0001).self_address == device.public_address
+    else:
+        assert device.lookup_connection(0x0001).self_address == device.random_address
+
+    # For unknown reason, read_phy() in on_connection() would be killed at the end of
+    # test, so we force scheduling here to avoid an warning.
+    await asyncio.sleep(0.0001)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+    'auto_restart,',
+    (True, False),
+)
+@pytest.mark.asyncio
+async def test_legacy_advertising_disconnection(auto_restart):
+    device = Device(host=mock.AsyncMock(spec=Host))
+    peer_address = Address('F0:F1:F2:F3:F4:F5')
+    await device.start_advertising(auto_restart=auto_restart)
+    device.on_connection(
+        0x0001,
+        BT_LE_TRANSPORT,
+        peer_address,
+        BT_PERIPHERAL_ROLE,
+        ConnectionParameters(0, 0, 0),
+    )
+
+    device.on_advertising_set_termination(
+        HCI_SUCCESS, device.legacy_advertising_set.advertising_handle, 0x0001, 0
+    )
+
+    device.on_disconnection(0x0001, 0)
+    await async_barrier()
+    await async_barrier()
+
+    if auto_restart:
+        assert device.is_advertising
+    else:
+        assert not device.is_advertising
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_extended_advertising():
+    device = Device(host=mock.AsyncMock(Host))
+
+    # Start advertising
+    advertising_set = await device.create_advertising_set()
+    assert device.extended_advertising_sets
+    assert advertising_set.enabled
+
+    # Stop advertising
+    await advertising_set.stop()
+    assert not advertising_set.enabled
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+    'own_address_type,',
+    (OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
+)
+@pytest.mark.asyncio
+async def test_extended_advertising_connection(own_address_type):
+    device = Device(host=mock.AsyncMock(spec=Host))
+    peer_address = Address('F0:F1:F2:F3:F4:F5')
+    advertising_set = await device.create_advertising_set(
+        advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
+    )
+    device.on_connection(
+        0x0001,
+        BT_LE_TRANSPORT,
+        peer_address,
+        BT_PERIPHERAL_ROLE,
+        ConnectionParameters(0, 0, 0),
+    )
+    device.on_advertising_set_termination(
+        HCI_SUCCESS,
+        advertising_set.advertising_handle,
+        0x0001,
+        0,
+    )
+
+    if own_address_type == OwnAddressType.PUBLIC:
+        assert device.lookup_connection(0x0001).self_address == device.public_address
+    else:
+        assert device.lookup_connection(0x0001).self_address == device.random_address
+
+    # For unknown reason, read_phy() in on_connection() would be killed at the end of
+    # test, so we force scheduling here to avoid an warning.
+    await asyncio.sleep(0.0001)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_get_remote_le_features():
+    devices = TwoDevices()
+    await devices.setup_connection()
+
+    assert (await devices.connections[0].get_remote_le_features()) is not None
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_cis():
+    devices = TwoDevices()
+    await devices.setup_connection()
+
+    peripheral_cis_futures = {}
+
+    def on_cis_request(
+        acl_connection: Connection,
+        cis_handle: int,
+        _cig_id: int,
+        _cis_id: int,
+    ):
+        acl_connection.abort_on(
+            'disconnection', devices[1].accept_cis_request(cis_handle)
+        )
+        peripheral_cis_futures[cis_handle] = asyncio.get_running_loop().create_future()
+
+    devices[1].on('cis_request', on_cis_request)
+    devices[1].on(
+        'cis_establishment',
+        lambda cis_link: peripheral_cis_futures[cis_link.handle].set_result(None),
+    )
+
+    cis_handles = await devices[0].setup_cig(
+        cig_id=1,
+        cis_id=[2, 3],
+        sdu_interval=(0, 0),
+        framing=0,
+        max_sdu=(0, 0),
+        retransmission_number=0,
+        max_transport_latency=(0, 0),
+    )
+    assert len(cis_handles) == 2
+    cis_links = await devices[0].create_cis(
+        [
+            (cis_handles[0], devices.connections[0].handle),
+            (cis_handles[1], devices.connections[0].handle),
+        ]
+    )
+    await asyncio.gather(*peripheral_cis_futures.values())
+    assert len(cis_links) == 2
+
+    await cis_links[0].disconnect()
+    await cis_links[1].disconnect()
+
+
+# -----------------------------------------------------------------------------
 def test_gatt_services_with_gas():
     device = Device(host=Host(None, None))
 
diff --git a/tests/gatt_test.py b/tests/gatt_test.py
index d9f6d60..e3c9209 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -20,10 +20,10 @@
 import os
 import struct
 import pytest
+from unittest.mock import AsyncMock, Mock, ANY
 
 from bumble.controller import Controller
 from bumble.gatt_client import CharacteristicProxy
-from bumble.gatt_server import Server
 from bumble.link import LocalLink
 from bumble.device import Device, Peer
 from bumble.host import Host
@@ -50,6 +50,7 @@
     ATT_Error_Response,
     ATT_Read_By_Group_Type_Request,
 )
+from .test_utils import async_barrier
 
 
 # -----------------------------------------------------------------------------
@@ -119,9 +120,9 @@
         Characteristic.READABLE,
         123,
     )
-    x = c.read_value(None)
+    x = await c.read_value(None)
     assert x == bytes([123])
-    c.write_value(None, bytes([122]))
+    await c.write_value(None, bytes([122]))
     assert c.value == 122
 
     class FooProxy(CharacteristicProxy):
@@ -151,7 +152,22 @@
         bytes([123]),
     )
 
-    service = Service('3A657F47-D34F-46B3-B1EC-698E29B6B829', [characteristic])
+    async def async_read(connection):
+        return 0x05060708
+
+    async_characteristic = PackedCharacteristicAdapter(
+        Characteristic(
+            '2AB7E91B-43E8-4F73-AC3B-80C1683B47F9',
+            Characteristic.Properties.READ,
+            Characteristic.READABLE,
+            CharacteristicValue(read=async_read),
+        ),
+        '>I',
+    )
+
+    service = Service(
+        '3A657F47-D34F-46B3-B1EC-698E29B6B829', [characteristic, async_characteristic]
+    )
     server.add_service(service)
 
     await client.power_on()
@@ -183,6 +199,13 @@
     await async_barrier()
     assert characteristic.value == bytes([50])
 
+    c2 = peer.get_characteristics_by_uuid(async_characteristic.uuid)
+    assert len(c2) == 1
+    c2 = c2[0]
+    cd2 = PackedCharacteristicAdapter(c2, ">I")
+    cd2v = await cd2.read_value()
+    assert cd2v == 0x05060708
+
     last_change = None
 
     def on_change(value):
@@ -284,7 +307,8 @@
 
 
 # -----------------------------------------------------------------------------
-def test_CharacteristicAdapter():
+@pytest.mark.asyncio
+async def test_CharacteristicAdapter():
     # Check that the CharacteristicAdapter base class is transparent
     v = bytes([1, 2, 3])
     c = Characteristic(
@@ -295,11 +319,11 @@
     )
     a = CharacteristicAdapter(c)
 
-    value = a.read_value(None)
+    value = await a.read_value(None)
     assert value == v
 
     v = bytes([3, 4, 5])
-    a.write_value(None, v)
+    await a.write_value(None, v)
     assert c.value == v
 
     # Simple delegated adapter
@@ -307,11 +331,11 @@
         c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x))
     )
 
-    value = a.read_value(None)
+    value = await a.read_value(None)
     assert value == bytes(reversed(v))
 
     v = bytes([3, 4, 5])
-    a.write_value(None, v)
+    await a.write_value(None, v)
     assert a.value == bytes(reversed(v))
 
     # Packed adapter with single element format
@@ -320,10 +344,10 @@
     c.value = v
     a = PackedCharacteristicAdapter(c, '>H')
 
-    value = a.read_value(None)
+    value = await a.read_value(None)
     assert value == pv
     c.value = None
-    a.write_value(None, pv)
+    await a.write_value(None, pv)
     assert a.value == v
 
     # Packed adapter with multi-element format
@@ -333,10 +357,10 @@
     c.value = (v1, v2)
     a = PackedCharacteristicAdapter(c, '>HH')
 
-    value = a.read_value(None)
+    value = await a.read_value(None)
     assert value == pv
     c.value = None
-    a.write_value(None, pv)
+    await a.write_value(None, pv)
     assert a.value == (v1, v2)
 
     # Mapped adapter
@@ -347,10 +371,10 @@
     c.value = mapped
     a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
 
-    value = a.read_value(None)
+    value = await a.read_value(None)
     assert value == pv
     c.value = None
-    a.write_value(None, pv)
+    await a.write_value(None, pv)
     assert a.value == mapped
 
     # UTF-8 adapter
@@ -359,27 +383,49 @@
     c.value = v
     a = UTF8CharacteristicAdapter(c)
 
-    value = a.read_value(None)
+    value = await a.read_value(None)
     assert value == ev
     c.value = None
-    a.write_value(None, ev)
+    await a.write_value(None, ev)
     assert a.value == v
 
 
 # -----------------------------------------------------------------------------
-def test_CharacteristicValue():
+@pytest.mark.asyncio
+async def test_CharacteristicValue():
     b = bytes([1, 2, 3])
-    c = CharacteristicValue(read=lambda _: b)
-    x = c.read(None)
+
+    async def read_value(connection):
+        return b
+
+    c = CharacteristicValue(read=read_value)
+    x = await c.read(None)
     assert x == b
 
-    result = []
-    c = CharacteristicValue(
-        write=lambda connection, value: result.append((connection, value))
-    )
+    m = Mock()
+    c = CharacteristicValue(write=m)
     z = object()
     c.write(z, b)
-    assert result == [(z, b)]
+    m.assert_called_once_with(z, b)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_CharacteristicValue_async():
+    b = bytes([1, 2, 3])
+
+    async def read_value(connection):
+        return b
+
+    c = CharacteristicValue(read=read_value)
+    x = await c.read(None)
+    assert x == b
+
+    m = AsyncMock()
+    c = CharacteristicValue(write=m)
+    z = object()
+    await c.write(z, b)
+    m.assert_called_once_with(z, b)
 
 
 # -----------------------------------------------------------------------------
@@ -412,13 +458,6 @@
 
 
 # -----------------------------------------------------------------------------
-async def async_barrier():
-    ready = asyncio.get_running_loop().create_future()
-    asyncio.get_running_loop().call_soon(ready.set_result, None)
-    await ready
-
-
-# -----------------------------------------------------------------------------
 @pytest.mark.asyncio
 async def test_read_write():
     [client, server] = LinkedDevices().devices[:2]
@@ -765,6 +804,83 @@
 
 # -----------------------------------------------------------------------------
 @pytest.mark.asyncio
+async def test_unsubscribe():
+    [client, server] = LinkedDevices().devices[:2]
+
+    characteristic1 = Characteristic(
+        'FDB159DB-036C-49E3-B3DB-6325AC750806',
+        Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
+        Characteristic.READABLE,
+        bytes([1, 2, 3]),
+    )
+    characteristic2 = Characteristic(
+        '3234C4F4-3F34-4616-8935-45A50EE05DEB',
+        Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
+        Characteristic.READABLE,
+        bytes([1, 2, 3]),
+    )
+
+    service1 = Service(
+        '3A657F47-D34F-46B3-B1EC-698E29B6B829',
+        [characteristic1, characteristic2],
+    )
+    server.add_services([service1])
+
+    mock1 = Mock()
+    characteristic1.on('subscription', mock1)
+    mock2 = Mock()
+    characteristic2.on('subscription', mock2)
+
+    await client.power_on()
+    await server.power_on()
+    connection = await client.connect(server.random_address)
+    peer = Peer(connection)
+
+    await peer.discover_services()
+    await peer.discover_characteristics()
+    c = peer.get_characteristics_by_uuid(characteristic1.uuid)
+    assert len(c) == 1
+    c1 = c[0]
+    c = peer.get_characteristics_by_uuid(characteristic2.uuid)
+    assert len(c) == 1
+    c2 = c[0]
+
+    await c1.subscribe()
+    await async_barrier()
+    mock1.assert_called_once_with(ANY, True, False)
+
+    await c2.subscribe()
+    await async_barrier()
+    mock2.assert_called_once_with(ANY, True, False)
+
+    mock1.reset_mock()
+    await c1.unsubscribe()
+    await async_barrier()
+    mock1.assert_called_once_with(ANY, False, False)
+
+    mock2.reset_mock()
+    await c2.unsubscribe()
+    await async_barrier()
+    mock2.assert_called_once_with(ANY, False, False)
+
+    mock1.reset_mock()
+    await c1.unsubscribe()
+    await async_barrier()
+    mock1.assert_not_called()
+
+    mock2.reset_mock()
+    await c2.unsubscribe()
+    await async_barrier()
+    mock2.assert_not_called()
+
+    mock1.reset_mock()
+    await c1.unsubscribe(force=True)
+    await async_barrier()
+    mock1.assert_called_once_with(ANY, False, False)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
 async def test_mtu_exchange():
     [d1, d2, d3] = LinkedDevices().devices[:3]
 
@@ -883,11 +999,18 @@
 
 # -----------------------------------------------------------------------------
 async def async_main():
+    test_UUID()
+    test_ATT_Error_Response()
+    test_ATT_Read_By_Group_Type_Request()
     await test_read_write()
     await test_read_write2()
     await test_subscribe_notify()
+    await test_unsubscribe()
     await test_characteristic_encoding()
     await test_mtu_exchange()
+    await test_CharacteristicValue()
+    await test_CharacteristicValue_async()
+    await test_CharacteristicAdapter()
 
 
 # -----------------------------------------------------------------------------
@@ -1026,9 +1149,4 @@
 # -----------------------------------------------------------------------------
 if __name__ == '__main__':
     logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
-    test_UUID()
-    test_ATT_Error_Response()
-    test_ATT_Read_By_Group_Type_Request()
-    test_CharacteristicValue()
-    test_CharacteristicAdapter()
     asyncio.run(async_main())
diff --git a/tests/hci_test.py b/tests/hci_test.py
index c648592..72f4022 100644
--- a/tests/hci_test.py
+++ b/tests/hci_test.py
@@ -23,13 +23,18 @@
     HCI_LE_READ_BUFFER_SIZE_COMMAND,
     HCI_RESET_COMMAND,
     HCI_SUCCESS,
+    HCI_LE_CONNECTION_COMPLETE_EVENT,
+    HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT,
     Address,
+    CodingFormat,
+    CodecID,
     HCI_Command,
     HCI_Command_Complete_Event,
     HCI_Command_Status_Event,
     HCI_CustomPacket,
     HCI_Disconnect_Command,
     HCI_Event,
+    HCI_IsoDataPacket,
     HCI_LE_Add_Device_To_Filter_Accept_List_Command,
     HCI_LE_Advertising_Report_Event,
     HCI_LE_Channel_Selection_Algorithm_Event,
@@ -51,6 +56,7 @@
     HCI_LE_Set_Random_Address_Command,
     HCI_LE_Set_Scan_Enable_Command,
     HCI_LE_Set_Scan_Parameters_Command,
+    HCI_LE_Setup_ISO_Data_Path_Command,
     HCI_Number_Of_Completed_Packets_Event,
     HCI_Packet,
     HCI_PIN_Code_Request_Reply_Command,
@@ -270,8 +276,14 @@
 # -----------------------------------------------------------------------------
 def test_HCI_LE_Set_Event_Mask_Command():
     command = HCI_LE_Set_Event_Mask_Command(
-        le_event_mask=bytes.fromhex('0011223344556677')
+        le_event_mask=HCI_LE_Set_Event_Mask_Command.mask(
+            [
+                HCI_LE_CONNECTION_COMPLETE_EVENT,
+                HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT,
+            ]
+        )
     )
+    assert command.le_event_mask == bytes.fromhex('0100000000010000')
     basic_check(command)
 
 
@@ -443,6 +455,28 @@
 
 
 # -----------------------------------------------------------------------------
+def test_HCI_LE_Setup_ISO_Data_Path_Command():
+    command = HCI_Packet.from_bytes(bytes.fromhex('016e200d60000001030000000000000000'))
+
+    assert command.connection_handle == 0x0060
+    assert command.data_path_direction == 0x00
+    assert command.data_path_id == 0x01
+    assert command.codec_id == CodingFormat(CodecID.TRANSPARENT)
+    assert command.controller_delay == 0
+    assert command.codec_configuration == b''
+
+    command = HCI_LE_Setup_ISO_Data_Path_Command(
+        connection_handle=0x0060,
+        data_path_direction=0x00,
+        data_path_id=0x01,
+        codec_id=CodingFormat(CodecID.TRANSPARENT),
+        controller_delay=0x00,
+        codec_configuration=b'',
+    )
+    basic_check(command)
+
+
+# -----------------------------------------------------------------------------
 def test_address():
     a = Address('C4:F2:17:1A:1D:BB')
     assert not a.is_public
@@ -462,6 +496,29 @@
 
 
 # -----------------------------------------------------------------------------
+def test_iso_data_packet():
+    data = bytes.fromhex(
+        '05616044002ac9f0a193003c00e83b477b00eba8d41dc018bf1a980f0290afe1e7c37652096697'
+        '52b6a535a8df61e22931ef5a36281bc77ed6a3206d984bcdabee6be831c699cb50e2'
+    )
+    packet = HCI_IsoDataPacket.from_bytes(data)
+    assert packet.connection_handle == 0x0061
+    assert packet.packet_status_flag == 0
+    assert packet.pb_flag == 0x02
+    assert packet.ts_flag == 0x01
+    assert packet.data_total_length == 68
+    assert packet.time_stamp == 2716911914
+    assert packet.packet_sequence_number == 147
+    assert packet.iso_sdu_length == 60
+    assert packet.iso_sdu_fragment == bytes.fromhex(
+        'e83b477b00eba8d41dc018bf1a980f0290afe1e7c3765209669752b6a535a8df61e22931ef5a3'
+        '6281bc77ed6a3206d984bcdabee6be831c699cb50e2'
+    )
+
+    assert packet.to_bytes() == data
+
+
+# -----------------------------------------------------------------------------
 def run_test_events():
     test_HCI_Event()
     test_HCI_LE_Connection_Complete_Event()
@@ -499,6 +556,7 @@
     test_HCI_LE_Set_Default_PHY_Command()
     test_HCI_LE_Set_Extended_Scan_Parameters_Command()
     test_HCI_LE_Set_Extended_Advertising_Enable_Command()
+    test_HCI_LE_Setup_ISO_Data_Path_Command()
 
 
 # -----------------------------------------------------------------------------
@@ -507,3 +565,4 @@
     run_test_commands()
     test_address()
     test_custom()
+    test_iso_data_packet()
diff --git a/tests/hfp_test.py b/tests/hfp_test.py
index 481d0b7..dc28180 100644
--- a/tests/hfp_test.py
+++ b/tests/hfp_test.py
@@ -23,8 +23,10 @@
 from typing import Tuple
 
 from .test_utils import TwoDevices
+from bumble import core
 from bumble import hfp
 from bumble import rfcomm
+from bumble import hci
 
 
 # -----------------------------------------------------------------------------
@@ -43,12 +45,10 @@
 
     # Setup RFCOMM channel
     wait_dlc = asyncio.get_running_loop().create_future()
-    rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(
-        lambda dlc: wait_dlc.set_result(dlc)
-    )
+    rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(wait_dlc.set_result)
     assert devices.connections[0]
     assert devices.connections[1]
-    client_mux = await rfcomm.Client(devices.devices[1], devices.connections[1]).start()
+    client_mux = await rfcomm.Client(devices.connections[1]).start()
 
     client_dlc = await client_mux.open_dlc(rfcomm_channel)
     server_dlc = await wait_dlc
@@ -90,6 +90,68 @@
 
 
 # -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_sco_setup():
+    devices = TwoDevices()
+
+    # Enable Classic connections
+    devices[0].classic_enabled = True
+    devices[1].classic_enabled = True
+
+    # Start
+    await devices[0].power_on()
+    await devices[1].power_on()
+
+    connections = await asyncio.gather(
+        devices[0].connect(
+            devices[1].public_address, transport=core.BT_BR_EDR_TRANSPORT
+        ),
+        devices[1].accept(devices[0].public_address),
+    )
+
+    def on_sco_request(_connection, _link_type: int):
+        connections[1].abort_on(
+            'disconnection',
+            devices[1].send_command(
+                hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(
+                    bd_addr=connections[1].peer_address,
+                    **hfp.ESCO_PARAMETERS[
+                        hfp.DefaultCodecParameters.ESCO_CVSD_S1
+                    ].asdict(),
+                )
+            ),
+        )
+
+    devices[1].on('sco_request', on_sco_request)
+
+    sco_connection_futures = [
+        asyncio.get_running_loop().create_future(),
+        asyncio.get_running_loop().create_future(),
+    ]
+
+    for device, future in zip(devices, sco_connection_futures):
+        device.on('sco_connection', future.set_result)
+
+    await devices[0].send_command(
+        hci.HCI_Enhanced_Setup_Synchronous_Connection_Command(
+            connection_handle=connections[0].handle,
+            **hfp.ESCO_PARAMETERS[hfp.DefaultCodecParameters.ESCO_CVSD_S1].asdict(),
+        )
+    )
+    sco_connections = await asyncio.gather(*sco_connection_futures)
+
+    sco_disconnection_futures = [
+        asyncio.get_running_loop().create_future(),
+        asyncio.get_running_loop().create_future(),
+    ]
+    for future, sco_connection in zip(sco_disconnection_futures, sco_connections):
+        sco_connection.on('disconnection', future.set_result)
+
+    await sco_connections[0].disconnect()
+    await asyncio.gather(*sco_disconnection_futures)
+
+
+# -----------------------------------------------------------------------------
 async def run():
     await test_slc()
 
diff --git a/tests/host_test.py b/tests/host_test.py
new file mode 100644
index 0000000..5170497
--- /dev/null
+++ b/tests/host_test.py
@@ -0,0 +1,62 @@
+# Copyright 2021-2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import logging
+import pytest
+
+from bumble.controller import Controller
+from bumble.host import Host
+from bumble.transport import AsyncPipeSink
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    'supported_commands, lmp_features',
+    [
+        (
+            # Default commands
+            '2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
+            '30f0f9ff01008004000000000000000000000000000000000000000000000000',
+            # Only LE LMP feature
+            '0000000060000000',
+        ),
+        (
+            # All commands
+            'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
+            'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
+            # 3 pages of LMP features
+            '000102030405060708090A0B0C0D0E0F011112131415161718191A1B1C1D1E1F',
+        ),
+    ],
+)
+async def test_reset(supported_commands: str, lmp_features: str):
+    controller = Controller('C')
+    controller.supported_commands = bytes.fromhex(supported_commands)
+    controller.lmp_features = bytes.fromhex(lmp_features)
+    host = Host(controller, AsyncPipeSink(controller))
+
+    await host.reset()
+
+    assert host.local_lmp_features == int.from_bytes(
+        bytes.fromhex(lmp_features), 'little'
+    )
diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py
index 5cb285c..6323ddf 100644
--- a/tests/l2cap_test.py
+++ b/tests/l2cap_test.py
@@ -228,11 +228,33 @@
 
 
 # -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_mtu():
+    devices = TwoDevices()
+    await devices.setup_connection()
+
+    def on_channel_open(channel):
+        assert channel.peer_mtu == 456
+
+    def on_channel(channel):
+        channel.on('open', lambda: on_channel_open(channel))
+
+    server = devices.devices[1].create_l2cap_server(
+        spec=ClassicChannelSpec(mtu=345), handler=on_channel
+    )
+    client_channel = await devices.connections[0].create_l2cap_channel(
+        spec=ClassicChannelSpec(server.psm, mtu=456)
+    )
+    assert client_channel.peer_mtu == 345
+
+
+# -----------------------------------------------------------------------------
 async def run():
     test_helpers()
     await test_basic_connection()
     await test_transfer()
     await test_bidirectional_transfer()
+    await test_mtu()
 
 
 # -----------------------------------------------------------------------------
diff --git a/tests/rfcomm_test.py b/tests/rfcomm_test.py
index 9465462..4ce4d11 100644
--- a/tests/rfcomm_test.py
+++ b/tests/rfcomm_test.py
@@ -15,7 +15,22 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
-from bumble.rfcomm import RFCOMM_Frame
+import asyncio
+import pytest
+from typing import List
+
+from . import test_utils
+from bumble import core
+from bumble.rfcomm import (
+    RFCOMM_Frame,
+    Server,
+    Client,
+    DLC,
+    make_service_sdp_records,
+    find_rfcomm_channels,
+    find_rfcomm_channel_with_uuid,
+    RFCOMM_PSM,
+)
 
 
 # -----------------------------------------------------------------------------
@@ -44,5 +59,68 @@
 
 
 # -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_basic_connection() -> None:
+    devices = test_utils.TwoDevices()
+    await devices.setup_connection()
+
+    accept_future: asyncio.Future[DLC] = asyncio.get_running_loop().create_future()
+    channel = Server(devices[0]).listen(acceptor=accept_future.set_result)
+
+    assert devices.connections[1]
+    multiplexer = await Client(devices.connections[1]).start()
+    dlcs = await asyncio.gather(accept_future, multiplexer.open_dlc(channel))
+
+    queues: List[asyncio.Queue] = [asyncio.Queue(), asyncio.Queue()]
+    for dlc, queue in zip(dlcs, queues):
+        dlc.sink = queue.put_nowait
+
+    dlcs[0].write(b'The quick brown fox jumps over the lazy dog')
+    assert await queues[1].get() == b'The quick brown fox jumps over the lazy dog'
+
+    dlcs[1].write(b'Lorem ipsum dolor sit amet')
+    assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_service_record():
+    HANDLE = 2
+    CHANNEL = 1
+    SERVICE_UUID = core.UUID('00000000-0000-0000-0000-000000000001')
+
+    devices = test_utils.TwoDevices()
+    await devices.setup_connection()
+
+    devices[0].sdp_service_records[HANDLE] = make_service_sdp_records(
+        HANDLE, CHANNEL, SERVICE_UUID
+    )
+
+    assert SERVICE_UUID in (await find_rfcomm_channels(devices.connections[1]))[CHANNEL]
+    assert (
+        await find_rfcomm_channel_with_uuid(devices.connections[1], SERVICE_UUID)
+        == CHANNEL
+    )
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_context():
+    devices = test_utils.TwoDevices()
+    await devices.setup_connection()
+
+    server = Server(devices[0])
+    with server:
+        assert server.l2cap_server is not None
+
+        client = Client(devices.connections[1])
+        async with client:
+            assert client.l2cap_channel is not None
+
+        assert client.l2cap_channel is None
+    assert RFCOMM_PSM not in devices[0].l2cap_channel_manager.servers
+
+
+# -----------------------------------------------------------------------------
 if __name__ == '__main__':
     test_frames()
diff --git a/tests/sdp_test.py b/tests/sdp_test.py
index 29db875..91835e7 100644
--- a/tests/sdp_test.py
+++ b/tests/sdp_test.py
@@ -38,6 +38,7 @@
 # pylint: disable=invalid-name
 # -----------------------------------------------------------------------------
 
+
 # -----------------------------------------------------------------------------
 def basic_check(x: DataElement) -> None:
     serialized = bytes(x)
@@ -215,8 +216,8 @@
     devices.devices[0].sdp_server.service_records.update(sdp_records())
 
     # Search for service
-    client = Client(devices.devices[1])
-    await client.connect(devices.connections[1])
+    client = Client(devices.connections[1])
+    await client.connect()
     services = await client.search_services(
         [UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
     )
@@ -236,8 +237,8 @@
     devices.devices[0].sdp_server.service_records.update(sdp_records())
 
     # Search for service
-    client = Client(devices.devices[1])
-    await client.connect(devices.connections[1])
+    client = Client(devices.connections[1])
+    await client.connect()
     attributes = await client.get_attributes(
         0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
     )
@@ -257,8 +258,8 @@
     devices.devices[0].sdp_server.service_records.update(sdp_records())
 
     # Search for service
-    client = Client(devices.devices[1])
-    await client.connect(devices.connections[1])
+    client = Client(devices.connections[1])
+    await client.connect()
     attributes = await client.search_attributes(
         [UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
     )
@@ -270,6 +271,20 @@
 
 
 # -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_client_async_context():
+    devices = TwoDevices()
+    await devices.setup_connection()
+
+    client = Client(devices.connections[1])
+
+    async with client:
+        assert client.channel is not None
+
+    assert client.channel is None
+
+
+# -----------------------------------------------------------------------------
 async def run():
     test_data_elements()
     await test_service_attribute()
diff --git a/tests/self_test.py b/tests/self_test.py
index 98ce5e8..259de02 100644
--- a/tests/self_test.py
+++ b/tests/self_test.py
@@ -21,7 +21,7 @@
 import os
 import pytest
 
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
 
 from bumble.controller import Controller
 from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
@@ -34,9 +34,10 @@
 from bumble.smp import (
     SMP_PAIRING_NOT_SUPPORTED_ERROR,
     SMP_CONFIRM_VALUE_FAILED_ERROR,
+    OobContext,
+    OobLegacyContext,
 )
 from bumble.core import ProtocolError
-from bumble.hci import HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
 from bumble.keys import PairingKeys
 
 
@@ -517,16 +518,8 @@
     # Mock connection
     # TODO: Implement Classic SSP and encryption in link relayer
     LINK_KEY = bytes.fromhex('287ad379dca402530a39f1f43047b835')
-    two_devices.devices[0].on_link_key(
-        two_devices.devices[1].public_address,
-        LINK_KEY,
-        HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
-    )
-    two_devices.devices[1].on_link_key(
-        two_devices.devices[0].public_address,
-        LINK_KEY,
-        HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
-    )
+    two_devices.devices[0].get_link_key = AsyncMock(return_value=LINK_KEY)
+    two_devices.devices[1].get_link_key = AsyncMock(return_value=LINK_KEY)
     two_devices.connections[0].encryption = 1
     two_devices.connections[1].encryption = 1
 
@@ -554,6 +547,13 @@
         MockSmpSession.send_public_key_command.assert_not_called()
         MockSmpSession.send_pairing_random_command.assert_not_called()
 
+    for i in range(2):
+        assert (
+            await two_devices.devices[i].keystore.get(
+                str(two_devices.connections[i].peer_address)
+            )
+        ).link_key
+
 
 # -----------------------------------------------------------------------------
 @pytest.mark.asyncio
@@ -576,6 +576,77 @@
 
 
 # -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_self_smp_oob_sc():
+    oob_context_1 = OobContext()
+    oob_context_2 = OobContext()
+
+    pairing_config_1 = PairingConfig(
+        mitm=True,
+        sc=True,
+        bonding=True,
+        oob=PairingConfig.OobConfig(oob_context_1, oob_context_2.share(), None),
+    )
+
+    pairing_config_2 = PairingConfig(
+        mitm=True,
+        sc=True,
+        bonding=True,
+        oob=PairingConfig.OobConfig(oob_context_2, oob_context_1.share(), None),
+    )
+
+    await _test_self_smp_with_configs(pairing_config_1, pairing_config_2)
+
+    pairing_config_3 = PairingConfig(
+        mitm=True,
+        sc=True,
+        bonding=True,
+        oob=PairingConfig.OobConfig(oob_context_2, None, None),
+    )
+
+    await _test_self_smp_with_configs(pairing_config_1, pairing_config_3)
+    await _test_self_smp_with_configs(pairing_config_3, pairing_config_1)
+
+    pairing_config_4 = PairingConfig(
+        mitm=True,
+        sc=True,
+        bonding=True,
+        oob=PairingConfig.OobConfig(oob_context_2, oob_context_2.share(), None),
+    )
+
+    with pytest.raises(ProtocolError) as error:
+        await _test_self_smp_with_configs(pairing_config_1, pairing_config_4)
+    assert error.value.error_code == SMP_CONFIRM_VALUE_FAILED_ERROR
+
+    with pytest.raises(ProtocolError):
+        await _test_self_smp_with_configs(pairing_config_4, pairing_config_1)
+    assert error.value.error_code == SMP_CONFIRM_VALUE_FAILED_ERROR
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_self_smp_oob_legacy():
+    legacy_context = OobLegacyContext()
+
+    pairing_config_1 = PairingConfig(
+        mitm=True,
+        sc=False,
+        bonding=True,
+        oob=PairingConfig.OobConfig(None, None, legacy_context),
+    )
+
+    pairing_config_2 = PairingConfig(
+        mitm=True,
+        sc=True,
+        bonding=True,
+        oob=PairingConfig.OobConfig(OobContext(), None, legacy_context),
+    )
+
+    await _test_self_smp_with_configs(pairing_config_1, pairing_config_2)
+    await _test_self_smp_with_configs(pairing_config_2, pairing_config_1)
+
+
+# -----------------------------------------------------------------------------
 async def run_test_self():
     await test_self_connection()
     await test_self_gatt()
@@ -585,6 +656,8 @@
     await test_self_smp_wrong_pin()
     await test_self_smp_over_classic()
     await test_self_smp_public_address()
+    await test_self_smp_oob_sc()
+    await test_self_smp_oob_legacy()
 
 
 # -----------------------------------------------------------------------------
diff --git a/tests/smp_test.py b/tests/smp_test.py
index bdfa021..7a32b23 100644
--- a/tests/smp_test.py
+++ b/tests/smp_test.py
@@ -16,15 +16,23 @@
 # Imports
 # -----------------------------------------------------------------------------
 
+import pytest
+
+from bumble import smp
 from bumble.crypto import EccKey, aes_cmac, ah, c1, f4, f5, f6, g2, h6, h7, s1
+from bumble.pairing import OobData, OobSharedData, LeRole
+from bumble.hci import Address
+from bumble.core import AdvertisingData
+
 
 # -----------------------------------------------------------------------------
 # pylint: disable=invalid-name
 # -----------------------------------------------------------------------------
 
+
 # -----------------------------------------------------------------------------
-def reversed_hex(hex_str):
-    return bytes(reversed(bytes.fromhex(hex_str)))
+def reversed_hex(hex_str: str) -> bytes:
+    return bytes.fromhex(hex_str)[::-1]
 
 
 # -----------------------------------------------------------------------------
@@ -124,116 +132,126 @@
 
 # -----------------------------------------------------------------------------
 def test_f4():
-    u = bytes(
-        reversed(
-            bytes.fromhex(
-                '20b003d2 f297be2c 5e2c83a7 e9f9a5b9'
-                + 'eff49111 acf4fddb cc030148 0e359de6'
-            )
-        )
+    u = reversed_hex(
+        '20b003d2 f297be2c 5e2c83a7 e9f9a5b9 eff49111 acf4fddb cc030148 0e359de6'
     )
-    v = bytes(
-        reversed(
-            bytes.fromhex(
-                '55188b3d 32f6bb9a 900afcfb eed4e72a'
-                + '59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
-            )
-        )
+    v = reversed_hex(
+        '55188b3d 32f6bb9a 900afcfb eed4e72a 59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
     )
-    x = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
-    z = bytes([0])
+    x = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
+    z = b'\0'
     value = f4(u, v, x, z)
-    assert bytes(reversed(value)) == bytes.fromhex(
-        'f2c916f1 07a9bd1c f1eda1be a974872d'
-    )
+    assert value == reversed_hex('f2c916f1 07a9bd1c f1eda1be a974872d')
 
 
 # -----------------------------------------------------------------------------
 def test_f5():
-    w = bytes(
-        reversed(
-            bytes.fromhex(
-                'ec0234a3 57c8ad05 341010a6 0a397d9b'
-                + '99796b13 b4f866f1 868d34f3 73bfa698'
-            )
-        )
+    w = reversed_hex(
+        'ec0234a3 57c8ad05 341010a6 0a397d9b 99796b13 b4f866f1 868d34f3 73bfa698'
     )
-    n1 = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
-    n2 = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
-    a1 = bytes(reversed(bytes.fromhex('00561237 37bfce')))
-    a2 = bytes(reversed(bytes.fromhex('00a71370 2dcfc1')))
+    n1 = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
+    n2 = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
+    a1 = reversed_hex('00561237 37bfce')
+    a2 = reversed_hex('00a71370 2dcfc1')
     value = f5(w, n1, n2, a1, a2)
-    assert bytes(reversed(value[0])) == bytes.fromhex(
-        '2965f176 a1084a02 fd3f6a20 ce636e20'
-    )
-    assert bytes(reversed(value[1])) == bytes.fromhex(
-        '69867911 69d7cd23 980522b5 94750a38'
-    )
+    assert value[0] == reversed_hex('2965f176 a1084a02 fd3f6a20 ce636e20')
+    assert value[1] == reversed_hex('69867911 69d7cd23 980522b5 94750a38')
 
 
 # -----------------------------------------------------------------------------
 def test_f6():
-    n1 = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
-    n2 = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
-    mac_key = bytes(reversed(bytes.fromhex('2965f176 a1084a02 fd3f6a20 ce636e20')))
-    r = bytes(reversed(bytes.fromhex('12a3343b b453bb54 08da42d2 0c2d0fc8')))
-    io_cap = bytes(reversed(bytes.fromhex('010102')))
-    a1 = bytes(reversed(bytes.fromhex('00561237 37bfce')))
-    a2 = bytes(reversed(bytes.fromhex('00a71370 2dcfc1')))
+    n1 = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
+    n2 = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
+    mac_key = reversed_hex('2965f176 a1084a02 fd3f6a20 ce636e20')
+    r = reversed_hex('12a3343b b453bb54 08da42d2 0c2d0fc8')
+    io_cap = reversed_hex('010102')
+    a1 = reversed_hex('00561237 37bfce')
+    a2 = reversed_hex('00a71370 2dcfc1')
     value = f6(mac_key, n1, n2, r, io_cap, a1, a2)
-    assert bytes(reversed(value)) == bytes.fromhex(
-        'e3c47398 9cd0e8c5 d26c0b09 da958f61'
-    )
+    assert value == reversed_hex('e3c47398 9cd0e8c5 d26c0b09 da958f61')
 
 
 # -----------------------------------------------------------------------------
 def test_g2():
-    u = bytes(
-        reversed(
-            bytes.fromhex(
-                '20b003d2 f297be2c 5e2c83a7 e9f9a5b9'
-                + 'eff49111 acf4fddb cc030148 0e359de6'
-            )
-        )
+    u = reversed_hex(
+        '20b003d2 f297be2c 5e2c83a7 e9f9a5b9 eff49111 acf4fddb cc030148 0e359de6'
     )
-    v = bytes(
-        reversed(
-            bytes.fromhex(
-                '55188b3d 32f6bb9a 900afcfb eed4e72a'
-                + '59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
-            )
-        )
+    v = reversed_hex(
+        '55188b3d 32f6bb9a 900afcfb eed4e72a 59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
     )
-    x = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
-    y = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
+    x = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
+    y = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
     value = g2(u, v, x, y)
     assert value == 0x2F9ED5BA
 
 
 # -----------------------------------------------------------------------------
 def test_h6():
-    KEY = bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')
+    KEY = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
     KEY_ID = bytes.fromhex('6c656272')
-    assert h6(KEY, KEY_ID) == bytes.fromhex('2d9ae102 e76dc91c e8d3a9e2 80b16399')
+    assert h6(KEY, KEY_ID) == reversed_hex('2d9ae102 e76dc91c e8d3a9e2 80b16399')
 
 
 # -----------------------------------------------------------------------------
 def test_h7():
-    KEY = bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')
+    KEY = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
     SALT = bytes.fromhex('00000000 00000000 00000000 746D7031')
-    assert h7(SALT, KEY) == bytes.fromhex('fb173597 c6a3c0ec d2998c2a 75a57011')
+    assert h7(SALT, KEY) == reversed_hex('fb173597 c6a3c0ec d2998c2a 75a57011')
 
 
 # -----------------------------------------------------------------------------
 def test_ah():
-    irk = bytes(reversed(bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')))
-    prand = bytes(reversed(bytes.fromhex('708194')))
+    irk = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
+    prand = reversed_hex('708194')
     value = ah(irk, prand)
-    expected = bytes(reversed(bytes.fromhex('0dfbaa')))
+    expected = reversed_hex('0dfbaa')
     assert value == expected
 
 
 # -----------------------------------------------------------------------------
+def test_oob_data():
+    oob_data = OobData(
+        address=Address("F0:F1:F2:F3:F4:F5"),
+        role=LeRole.BOTH_PERIPHERAL_PREFERRED,
+        shared_data=OobSharedData(c=b'12', r=b'34'),
+    )
+    oob_data_ad = oob_data.to_ad()
+    oob_data_bytes = bytes(oob_data_ad)
+    oob_data_ad_parsed = AdvertisingData.from_bytes(oob_data_bytes)
+    oob_data_parsed = OobData.from_ad(oob_data_ad_parsed)
+    assert oob_data_parsed.address == oob_data.address
+    assert oob_data_parsed.role == oob_data.role
+    assert oob_data_parsed.shared_data.c == oob_data.shared_data.c
+    assert oob_data_parsed.shared_data.r == oob_data.shared_data.r
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+    'ct2, expected',
+    [
+        (False, 'bc1ca4ef 633fc1bd 0d8230af ee388fb0'),
+        (True, '287ad379 dca40253 0a39f1f4 3047b835'),
+    ],
+)
+def test_ltk_to_link_key(ct2: bool, expected: str):
+    LTK = reversed_hex('368df9bc e3264b58 bd066c33 334fbf64')
+    assert smp.Session.derive_link_key(LTK, ct2) == reversed_hex(expected)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+    'ct2, expected',
+    [
+        (False, 'a813fb72 f1a3dfa1 8a2c9a43 f10d0a30'),
+        (True, 'e85e09eb 5eccb3e2 69418a13 3211bc79'),
+    ],
+)
+def test_link_key_to_ltk(ct2: bool, expected: str):
+    LINK_KEY = reversed_hex('05040302 01000908 07060504 03020100')
+    assert smp.Session.derive_ltk(LINK_KEY, ct2) == reversed_hex(expected)
+
+
+# -----------------------------------------------------------------------------
 if __name__ == '__main__':
     test_ecc()
     test_c1()
@@ -246,3 +264,4 @@
     test_h6()
     test_h7()
     test_ah()
+    test_oob_data()
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f19f18c..d193d6e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -12,6 +12,10 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
 from typing import List, Optional
 
 from bumble.controller import Controller
@@ -22,6 +26,7 @@
 from bumble.hci import Address
 
 
+# -----------------------------------------------------------------------------
 class TwoDevices:
     connections: List[Optional[Connection]]
 
@@ -29,17 +34,18 @@
         self.connections = [None, None]
 
         self.link = LocalLink()
+        addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
         self.controllers = [
-            Controller('C1', link=self.link),
-            Controller('C2', link=self.link),
+            Controller('C1', link=self.link, public_address=addresses[0]),
+            Controller('C2', link=self.link, public_address=addresses[1]),
         ]
         self.devices = [
             Device(
-                address=Address('F0:F1:F2:F3:F4:F5'),
+                address=Address(addresses[0]),
                 host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
             ),
             Device(
-                address=Address('F5:F4:F3:F2:F1:F0'),
+                address=Address(addresses[1]),
                 host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
             ),
         ]
@@ -71,3 +77,13 @@
         # Check the post conditions
         assert self.connections[0] is not None
         assert self.connections[1] is not None
+
+    def __getitem__(self, index: int) -> Device:
+        return self.devices[index]
+
+
+# -----------------------------------------------------------------------------
+async def async_barrier():
+    ready = asyncio.get_running_loop().create_future()
+    asyncio.get_running_loop().call_soon(ready.set_result, None)
+    await ready
diff --git a/tests/utils_test.py b/tests/utils_test.py
index d6f5780..6266f9e 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -12,15 +12,20 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
 import contextlib
 import logging
 import os
-
-from bumble import utils
-from pyee import EventEmitter
 from unittest.mock import MagicMock
 
+from pyee import EventEmitter
 
+from bumble import utils
+
+
+# -----------------------------------------------------------------------------
 def test_on() -> None:
     emitter = EventEmitter()
     with contextlib.closing(utils.EventWatcher()) as context:
@@ -33,6 +38,7 @@
     assert mock.call_count == 1
 
 
+# -----------------------------------------------------------------------------
 def test_on_decorator() -> None:
     emitter = EventEmitter()
     with contextlib.closing(utils.EventWatcher()) as context:
@@ -48,6 +54,7 @@
     assert mock.call_count == 1
 
 
+# -----------------------------------------------------------------------------
 def test_multiple_handlers() -> None:
     emitter = EventEmitter()
     with contextlib.closing(utils.EventWatcher()) as context:
@@ -65,6 +72,30 @@
 
 
 # -----------------------------------------------------------------------------
+def test_open_int_enums():
+    class Foo(utils.OpenIntEnum):
+        FOO = 1
+        BAR = 2
+        BLA = 3
+
+    x = Foo(1)
+    assert x.name == "FOO"
+    assert x.value == 1
+    assert int(x) == 1
+    assert x == 1
+    assert x + 1 == 2
+
+    x = Foo(4)
+    assert x.name == "Foo[4]"
+    assert x.value == 4
+    assert int(x) == 4
+    assert x == 4
+    assert x + 1 == 5
+
+    print(list(Foo))
+
+
+# -----------------------------------------------------------------------------
 def run_tests():
     test_on()
     test_on_decorator()
@@ -75,3 +106,4 @@
 if __name__ == '__main__':
     logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
     run_tests()
+    test_open_int_enums()
diff --git a/tests/vcp_test.py b/tests/vcp_test.py
new file mode 100644
index 0000000..d45a5f5
--- /dev/null
+++ b/tests/vcp_test.py
@@ -0,0 +1,120 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import pytest
+import pytest_asyncio
+import logging
+
+from bumble import device
+from bumble.profiles import vcp
+from .test_utils import TwoDevices
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+@pytest_asyncio.fixture
+async def vcp_client():
+    devices = TwoDevices()
+    devices[0].add_service(
+        vcp.VolumeControlService(volume_setting=32, muted=1, volume_flags=1)
+    )
+
+    await devices.setup_connection()
+
+    # Mock encryption.
+    devices.connections[0].encryption = 1
+    devices.connections[1].encryption = 1
+
+    peer = device.Peer(devices.connections[1])
+    vcp_client = await peer.discover_service_and_create_proxy(
+        vcp.VolumeControlServiceProxy
+    )
+    yield vcp_client
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_init_service(vcp_client: vcp.VolumeControlServiceProxy):
+    assert (await vcp_client.volume_flags.read_value()) == 1
+    assert (await vcp_client.volume_state.read_value()) == (32, 1, 0)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy):
+    await vcp_client.volume_control_point.write_value(
+        bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_DOWN, 0])
+    )
+    assert (await vcp_client.volume_state.read_value()) == (16, 1, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy):
+    await vcp_client.volume_control_point.write_value(
+        bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_UP, 0])
+    )
+    assert (await vcp_client.volume_state.read_value()) == (48, 1, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_unmute_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy):
+    await vcp_client.volume_control_point.write_value(
+        bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_DOWN, 0])
+    )
+    assert (await vcp_client.volume_state.read_value()) == (16, 0, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_unmute_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy):
+    await vcp_client.volume_control_point.write_value(
+        bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_UP, 0])
+    )
+    assert (await vcp_client.volume_state.read_value()) == (48, 0, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_set_absolute_volume(vcp_client: vcp.VolumeControlServiceProxy):
+    await vcp_client.volume_control_point.write_value(
+        bytes([vcp.VolumeControlPointOpcode.SET_ABSOLUTE_VOLUME, 0, 255])
+    )
+    assert (await vcp_client.volume_state.read_value()) == (255, 1, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_mute(vcp_client: vcp.VolumeControlServiceProxy):
+    await vcp_client.volume_control_point.write_value(
+        bytes([vcp.VolumeControlPointOpcode.MUTE, 0])
+    )
+    assert (await vcp_client.volume_state.read_value()) == (32, 1, 0)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_unmute(vcp_client: vcp.VolumeControlServiceProxy):
+    await vcp_client.volume_control_point.write_value(
+        bytes([vcp.VolumeControlPointOpcode.UNMUTE, 0])
+    )
+    assert (await vcp_client.volume_state.read_value()) == (32, 0, 1)
diff --git a/web/bumble.js b/web/bumble.js
index b1243a5..cb807eb 100644
--- a/web/bumble.js
+++ b/web/bumble.js
@@ -5,11 +5,11 @@
 class PacketSource {
     constructor(pyodide) {
         this.parser = pyodide.runPython(`
-        from bumble.transport.common import PacketParser
-        class ProxiedPacketParser(PacketParser):
-            def feed_data(self, js_data):
-                super().feed_data(bytes(js_data.to_py()))
-        ProxiedPacketParser()
+            from bumble.transport.common import PacketParser
+            class ProxiedPacketParser(PacketParser):
+                def feed_data(self, js_data):
+                    super().feed_data(bytes(js_data.to_py()))
+            ProxiedPacketParser()
       `);
     }
 
@@ -18,74 +18,171 @@
     }
 
     data_received(data) {
-        console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
+        //console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
         this.parser.feed_data(data);
     }
 }
 
 class PacketSink {
-    constructor(writer) {
-        this.writer = writer;
-    }
-
     on_packet(packet) {
+        if (!this.writer) {
+            return;
+        }
         const buffer = packet.toJs({create_proxies : false});
         packet.destroy();
-        console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
+        //console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
         // TODO: create an async queue here instead of blindly calling write without awaiting
         this.writer(buffer);
     }
 }
 
-export async function connectWebSocketTransport(pyodide, hciWsUrl) {
-    return new Promise((resolve, reject) => {
-        let resolved = false;
-
-        let ws = new WebSocket(hciWsUrl);
-        ws.binaryType = "arraybuffer";
-
-        ws.onopen = () => {
-            console.log("WebSocket open");
-            resolve({
-                packet_source,
-                packet_sink
-            });
-            resolved = true;
-        }
-
-        ws.onclose = () => {
-            console.log("WebSocket close");
-            if (!resolved) {
-                reject(`Failed to connect to ${hciWsUrl}`)
-            }
-        }
-
-        ws.onmessage = (event) => {
-            packet_source.data_received(event.data);
-        }
-
-        const packet_source = new PacketSource(pyodide);
-        const packet_sink = new PacketSink((packet) => ws.send(packet));
-    })
+class LogEvent extends Event {
+    constructor(message) {
+        super('log');
+        this.message = message;
+    }
 }
 
-export async function loadBumble(pyodide, bumblePackage) {
-    // Load the Bumble module
-    await pyodide.loadPackage("micropip");
-    await pyodide.runPythonAsync(`
-        import micropip
-        await micropip.install("${bumblePackage}")
-        package_list = micropip.list()
-        print(package_list)
-    `)
+export class Bumble extends EventTarget {
+    constructor(pyodide) {
+        super();
+        this.pyodide = pyodide;
+    }
 
-    // Mount a filesystem so that we can persist data like the Key Store
-    let mountDir = "/bumble";
-    pyodide.FS.mkdir(mountDir);
-    pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir);
+    async loadRuntime(bumblePackage) {
+        // Load pyodide if it isn't provided.
+        if (this.pyodide === undefined) {
+            this.log('Loading Pyodide');
+            this.pyodide = await loadPyodide();
+        }
 
-    // Sync previously persisted filesystem data into memory
-    pyodide.FS.syncfs(true, () => {
-        console.log("FS synced in")
-    });
+        // Load the Bumble module
+        bumblePackage ||= 'bumble';
+        console.log('Installing micropip');
+        this.log(`Installing ${bumblePackage}`)
+        await this.pyodide.loadPackage('micropip');
+        await this.pyodide.runPythonAsync(`
+            import micropip
+            await micropip.install('${bumblePackage}')
+            package_list = micropip.list()
+            print(package_list)
+        `)
+
+        // Mount a filesystem so that we can persist data like the Key Store
+        let mountDir = '/bumble';
+        this.pyodide.FS.mkdir(mountDir);
+        this.pyodide.FS.mount(this.pyodide.FS.filesystems.IDBFS, { root: '.' }, mountDir);
+
+        // Sync previously persisted filesystem data into memory
+        await new Promise(resolve => {
+            this.pyodide.FS.syncfs(true, () => {
+                console.log('FS synced in');
+                resolve();
+            });
+        })
+
+        // Setup the HCI source and sink
+        this.packetSource = new PacketSource(this.pyodide);
+        this.packetSink = new PacketSink();
+    }
+
+    log(message) {
+        this.dispatchEvent(new LogEvent(message));
+    }
+
+    async connectWebSocketTransport(hciWsUrl) {
+        return new Promise((resolve, reject) => {
+            let resolved = false;
+
+            let ws = new WebSocket(hciWsUrl);
+            ws.binaryType = 'arraybuffer';
+
+            ws.onopen = () => {
+                this.log('WebSocket open');
+                resolve();
+                resolved = true;
+            }
+
+            ws.onclose = () => {
+                this.log('WebSocket close');
+                if (!resolved) {
+                    reject(`Failed to connect to ${hciWsUrl}`);
+                }
+            }
+
+            ws.onmessage = (event) => {
+                this.packetSource.data_received(event.data);
+            }
+
+            this.packetSink.writer = (packet) => {
+                if (ws.readyState === WebSocket.OPEN) {
+                    ws.send(packet);
+                }
+            }
+            this.closeTransport = async () => {
+                if (ws.readyState === WebSocket.OPEN) {
+                    ws.close();
+                }
+            }
+        })
+    }
+
+    async loadApp(appUrl) {
+        this.log('Loading app');
+        const script = await (await fetch(appUrl)).text();
+        await this.pyodide.runPythonAsync(script);
+        const pythonMain = this.pyodide.globals.get('main');
+        const app = await pythonMain(this.packetSource, this.packetSink);
+        if (app.on) {
+            app.on('key_store_update', this.onKeystoreUpdate.bind(this));
+        }
+        this.log('App is ready!');
+        return app;
+    }
+
+    onKeystoreUpdate() {
+        // Sync the FS
+        this.pyodide.FS.syncfs(() => {
+            console.log('FS synced out');
+        });
+    }
+}
+
+export async function setupSimpleApp(appUrl, bumbleControls, log) {
+    // Load Bumble
+    log('Loading Bumble');
+    const bumble = new Bumble();
+    bumble.addEventListener('log', (event) => {
+        log(event.message);
+    })
+    const params = (new URL(document.location)).searchParams;
+    await bumble.loadRuntime(params.get('package'));
+
+    log('Bumble is ready!')
+    const app = await bumble.loadApp(appUrl);
+
+    bumbleControls.connector = async (hciWsUrl) => {
+        try {
+            // Connect the WebSocket HCI transport
+            await bumble.connectWebSocketTransport(hciWsUrl);
+
+            // Start the app
+            await app.start();
+
+            return true;
+        } catch (err) {
+            log(err);
+            return false;
+        }
+    }
+    bumbleControls.stopper = async () => {
+        // Stop the app
+        await app.stop();
+
+        // Close the HCI transport
+        await bumble.closeTransport();
+    }
+    bumbleControls.onBumbleLoaded();
+
+    return app;
 }
\ No newline at end of file
diff --git a/web/heart_rate_monitor/heart_rate_monitor.html b/web/heart_rate_monitor/heart_rate_monitor.html
new file mode 100644
index 0000000..f44470f
--- /dev/null
+++ b/web/heart_rate_monitor/heart_rate_monitor.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
+  <script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
+  <script type="module" src="../ui.js"></script>
+  <script type="module" src="heart_rate_monitor.js"></script>
+  <style>
+    #hr-value {
+      font-family: sans-serif;
+      font-size: xx-large;
+    }
+
+  </style>
+</head>
+<body>
+    <bumble-controls id="bumble-controls"></bumble-controls><hr>
+    <span class="material-symbols-outlined">
+      cardiology
+    </span>
+    <span id="hr-value">60</span>
+    <br>
+    <button id="hr-up-button" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>arrow_upward</button>
+    <button id="hr-down-button" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>arrow_downward</button>
+    <hr>
+    <textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
+</body>
+</html>
diff --git a/web/heart_rate_monitor/heart_rate_monitor.js b/web/heart_rate_monitor/heart_rate_monitor.js
new file mode 100644
index 0000000..468e728
--- /dev/null
+++ b/web/heart_rate_monitor/heart_rate_monitor.js
@@ -0,0 +1,30 @@
+import {setupSimpleApp} from '../bumble.js';
+
+const logOutput = document.querySelector('#log-output');
+function logToOutput(message) {
+    console.log(message);
+    logOutput.value += message + '\n';
+}
+
+let heartRate = 60;
+const heartRateText = document.querySelector('#hr-value')
+
+function setHeartRate(newHeartRate) {
+    heartRate = newHeartRate;
+    heartRateText.innerHTML = heartRate;
+    app.set_heart_rate(heartRate);
+}
+
+// Setup the UI
+const bumbleControls = document.querySelector('#bumble-controls');
+document.querySelector('#hr-up-button').addEventListener('click', () => {
+    setHeartRate(heartRate + 1);
+})
+document.querySelector('#hr-down-button').addEventListener('click', () => {
+    setHeartRate(heartRate - 1);
+})
+
+// Setup the app
+const app = await setupSimpleApp('heart_rate_monitor.py', bumbleControls, logToOutput);
+logToOutput('Click the Bluetooth button to start');
+
diff --git a/web/heart_rate_monitor/heart_rate_monitor.py b/web/heart_rate_monitor/heart_rate_monitor.py
new file mode 100644
index 0000000..4a843b4
--- /dev/null
+++ b/web/heart_rate_monitor/heart_rate_monitor.py
@@ -0,0 +1,119 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import struct
+
+from bumble.core import AdvertisingData
+from bumble.device import Device
+from bumble.hci import HCI_Reset_Command
+from bumble.profiles.device_information_service import DeviceInformationService
+from bumble.profiles.heart_rate_service import HeartRateService
+from bumble.utils import AsyncRunner
+
+
+# -----------------------------------------------------------------------------
+class HeartRateMonitor:
+    def __init__(self, hci_source, hci_sink):
+        self.heart_rate = 60
+
+        self.device = Device.with_hci(
+            'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
+        )
+
+        device_information_service = DeviceInformationService(
+            manufacturer_name='ACME',
+            model_number='HR-102',
+            serial_number='7654321',
+            hardware_revision='1.1.3',
+            software_revision='2.5.6',
+            system_id=(0x123456, 0x8877665544),
+        )
+
+        self.heart_rate_service = HeartRateService(
+            read_heart_rate_measurement=lambda _: HeartRateService.HeartRateMeasurement(
+                heart_rate=self.heart_rate,
+                sensor_contact_detected=True,
+            ),
+            body_sensor_location=HeartRateService.BodySensorLocation.WRIST,
+            reset_energy_expended=self.reset_energy_expended,
+        )
+
+        # Notify subscribers of the current value as soon as they subscribe
+        @self.heart_rate_service.heart_rate_measurement_characteristic.on(
+            'subscription'
+        )
+        def on_subscription(_, notify_enabled, indicate_enabled):
+            if notify_enabled or indicate_enabled:
+                self.notify_heart_rate()
+
+        self.device.add_services([device_information_service, self.heart_rate_service])
+
+        self.device.advertising_data = bytes(
+            AdvertisingData(
+                [
+                    (
+                        AdvertisingData.FLAGS,
+                        bytes(
+                            [
+                                AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
+                                | AdvertisingData.BR_EDR_NOT_SUPPORTED_FLAG
+                            ]
+                        ),
+                    ),
+                    (
+                        AdvertisingData.COMPLETE_LOCAL_NAME,
+                        bytes('Bumble Heart', 'utf-8'),
+                    ),
+                    (
+                        AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+                        bytes(self.heart_rate_service.uuid),
+                    ),
+                    (AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340)),
+                ]
+            )
+        )
+
+    async def start(self):
+        print('### Starting Monitor')
+        await self.device.power_on()
+        await self.device.start_advertising(auto_restart=True)
+        print('### Monitor started')
+
+    async def stop(self):
+        # TODO: replace this once a proper reset is implemented in the lib.
+        await self.device.host.send_command(HCI_Reset_Command())
+        await self.device.power_off()
+        print('### Monitor stopped')
+
+    def notify_heart_rate(self):
+        AsyncRunner.spawn(
+            self.device.notify_subscribers(
+                self.heart_rate_service.heart_rate_measurement_characteristic
+            )
+        )
+
+    def set_heart_rate(self, heart_rate):
+        self.heart_rate = heart_rate
+        self.notify_heart_rate()
+
+    def reset_energy_expended(self, _):
+        print('<<< Reset Energy Expended')
+
+
+# -----------------------------------------------------------------------------
+def main(hci_source, hci_sink):
+    return HeartRateMonitor(hci_source, hci_sink)
diff --git a/web/scanner/scanner.css b/web/scanner/scanner.css
new file mode 100644
index 0000000..99380ad
--- /dev/null
+++ b/web/scanner/scanner.css
@@ -0,0 +1,3 @@
+body {
+    font-family: monospace;
+}
diff --git a/web/scanner/scanner.html b/web/scanner/scanner.html
index 12c65dd..f698b01 100644
--- a/web/scanner/scanner.html
+++ b/web/scanner/scanner.html
@@ -1,129 +1,21 @@
+<!DOCTYPE html>
 <html>
-
 <head>
-  <script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
-  <style>
-    body {
-      font-family: monospace;
-    }
-
-    table, th, td {
-      padding: 2px;
-      white-space: pre;
-      border: 1px solid black;
-      border-collapse: collapse;
-    }
+  <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+  <link rel="stylesheet" href="scanner.css">
+  <script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
+  <script type="module" src="../ui.js"></script>
+  <script type="module" src="scanner.js"></script>
+</style>
   </style>
 </head>
-
 <body>
-  <button id="connectButton" disabled>Connect</button>
-  <br />
-  <br />
-  <div>Log Output</div><br>
-  <textarea id="output" style="width: 100%;" rows="10" disabled></textarea>
-  <div id="scanTableContainer"><table></table></div>
+    <script type="module">
+        import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
+    </script>
 
-  <script type="module">
-    import { loadBumble, connectWebSocketTransport } from "../bumble.js"
-    let pyodide;
-    let output;
-
-    function logToOutput(s) {
-      output.value += s + "\n";
-      console.log(s);
-    }
-
-    async function run() {
-      const params = (new URL(document.location)).searchParams;
-      const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
-
-      try {
-        // Create a WebSocket HCI transport
-        let transport
-        try {
-          transport = await connectWebSocketTransport(pyodide, hciWsUrl);
-        } catch (error) {
-          logToOutput(error);
-          return;
-        }
-
-        // Run the scanner example
-        const script = await (await fetch("scanner.py")).text();
-        await pyodide.runPythonAsync(script);
-        const pythonMain = pyodide.globals.get("main");
-        logToOutput("Starting scanner...");
-        await pythonMain(transport.packet_source, transport.packet_sink, onScanUpdate);
-        logToOutput("Scanner running");
-      } catch (err) {
-        logToOutput(err);
-      }
-    }
-
-    function onScanUpdate(scanEntries) {
-      scanEntries = scanEntries.toJs();
-
-      const scanTable = document.createElement("table");
-
-      const tableHeader = document.createElement("tr");
-      for (const name of ["Address", "Address Type", "RSSI", "Data"]) {
-        const header = document.createElement("th");
-        header.appendChild(document.createTextNode(name));
-        tableHeader.appendChild(header);
-      }
-      scanTable.appendChild(tableHeader);
-
-      scanEntries.forEach(entry => {
-        const row = document.createElement("tr");
-
-        const addressCell = document.createElement("td");
-        addressCell.appendChild(document.createTextNode(entry.address));
-        row.appendChild(addressCell);
-
-        const addressTypeCell = document.createElement("td");
-        addressTypeCell.appendChild(document.createTextNode(entry.address_type));
-        row.appendChild(addressTypeCell);
-
-        const rssiCell = document.createElement("td");
-        rssiCell.appendChild(document.createTextNode(entry.rssi));
-        row.appendChild(rssiCell);
-
-        const dataCell = document.createElement("td");
-        dataCell.appendChild(document.createTextNode(entry.data));
-        row.appendChild(dataCell);
-
-        scanTable.appendChild(row);
-      });
-
-      const scanTableContainer = document.getElementById("scanTableContainer");
-      scanTableContainer.replaceChild(scanTable, scanTableContainer.firstChild);
-
-      return true;
-    }
-
-    async function main() {
-      output = document.getElementById("output");
-
-      // Load pyodide
-      logToOutput("Loading Pyodide");
-      pyodide = await loadPyodide();
-
-      // Load Bumble
-      logToOutput("Loading Bumble");
-      const params = (new URL(document.location)).searchParams;
-      const bumblePackage = params.get("package") || "bumble";
-      await loadBumble(pyodide, bumblePackage);
-
-      logToOutput("Ready!")
-
-      // Enable the Connect button
-      const connectButton = document.getElementById("connectButton");
-      connectButton.disabled = false
-      connectButton.addEventListener("click", run)
-    }
-
-    main();
-  </script>
+    <bumble-controls id="bumble-controls"></bumble-controls><hr>
+    <textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
+    <scan-list id="scan-list"></scan-list>
 </body>
-
-</html>
\ No newline at end of file
+</html>
diff --git a/web/scanner/scanner.js b/web/scanner/scanner.js
new file mode 100644
index 0000000..34d5784
--- /dev/null
+++ b/web/scanner/scanner.js
@@ -0,0 +1,68 @@
+import {LitElement, html, css} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
+import {setupSimpleApp} from '../bumble.js';
+
+ class ScanList extends LitElement {
+    static properties = {
+        listItems: {state: true},
+    };
+
+    static styles = css`
+        table, th, td {
+            padding: 2px;
+            white-space: pre;
+            border: 1px solid black;
+            border-collapse: collapse;
+        }
+    `;
+
+    constructor() {
+        super();
+        this.listItems = [];
+    }
+
+    render() {
+        if (this.listItems.length === 0) {
+            return '';
+        }
+        return html`
+            <table>
+                <thead>
+                    <tr>
+                        ${Object.keys(this.listItems[0]).map(i => html`<th>${i}</th>`)}
+                    </tr>
+                </thead>
+                <tbody>
+                    ${this.listItems.map(i => html`
+                    <tr>
+                        ${Object.keys(i).map(key => html`<td>${i[key]}</td>`)}
+                    </tr>
+                    `)}
+                </tbody>
+            </table>
+        `;
+    }
+}
+customElements.define('scan-list', ScanList);
+
+const logOutput = document.querySelector('#log-output');
+function logToOutput(message) {
+    console.log(message);
+    logOutput.value += message + '\n';
+}
+
+function onUpdate(scanResults) {
+    const items = scanResults.toJs({create_proxies : false}).map(entry => (
+        { address: entry.address, address_type: entry.address_type, rssi: entry.rssi, data: entry.data }
+    ));
+    scanResults.destroy();
+    scanList.listItems = items;
+}
+
+// Setup the UI
+const scanList = document.querySelector('#scan-list');
+const bumbleControls = document.querySelector('#bumble-controls');
+
+// Setup the app
+const app = await setupSimpleApp('scanner.py', bumbleControls, logToOutput);
+app.on('update', onUpdate);
+logToOutput('Click the Bluetooth button to start');
diff --git a/web/scanner/scanner.py b/web/scanner/scanner.py
index c0fc456..9ff6aba 100644
--- a/web/scanner/scanner.py
+++ b/web/scanner/scanner.py
@@ -15,39 +15,59 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
-import time
-
 from bumble.device import Device
+from bumble.hci import HCI_Reset_Command
 
 
 # -----------------------------------------------------------------------------
-class ScanEntry:
-    def __init__(self, advertisement):
-        self.address = advertisement.address.to_string(False)
-        self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
-            advertisement.address.address_type
-        ]
-        self.rssi = advertisement.rssi
-        self.data = advertisement.data.to_string("\n")
+class Scanner:
+    class ScanEntry:
+        def __init__(self, advertisement):
+            self.address = advertisement.address.to_string(False)
+            self.address_type = (
+                'Public',
+                'Random',
+                'Public Identity',
+                'Random Identity',
+            )[advertisement.address.address_type]
+            self.rssi = advertisement.rssi
+            self.data = advertisement.data.to_string('\n')
 
+    def __init__(self, hci_source, hci_sink):
+        super().__init__()
+        self.device = Device.with_hci(
+            'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
+        )
+        self.scan_entries = {}
+        self.listeners = {}
+        self.device.on('advertisement', self.on_advertisement)
 
-# -----------------------------------------------------------------------------
-class ScannerListener(Device.Listener):
-    def __init__(self, callback):
-        self.callback = callback
-        self.entries = {}
+    async def start(self):
+        print('### Starting Scanner')
+        self.scan_entries = {}
+        self.emit_update()
+        await self.device.power_on()
+        await self.device.start_scanning()
+        print('### Scanner started')
+
+    async def stop(self):
+        # TODO: replace this once a proper reset is implemented in the lib.
+        await self.device.host.send_command(HCI_Reset_Command())
+        await self.device.power_off()
+        print('### Scanner stopped')
+
+    def emit_update(self):
+        if listener := self.listeners.get('update'):
+            listener(list(self.scan_entries.values()))
+
+    def on(self, event_name, listener):
+        self.listeners[event_name] = listener
 
     def on_advertisement(self, advertisement):
-        self.entries[advertisement.address] = ScanEntry(advertisement)
-        self.callback(list(self.entries.values()))
+        self.scan_entries[advertisement.address] = self.ScanEntry(advertisement)
+        self.emit_update()
 
 
 # -----------------------------------------------------------------------------
-async def main(hci_source, hci_sink, callback):
-    print('### Starting Scanner')
-    device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
-    device.listener = ScannerListener(callback)
-    await device.power_on()
-    await device.start_scanning()
-
-    print('### Scanner started')
+def main(hci_source, hci_sink):
+    return Scanner(hci_source, hci_sink)
diff --git a/web/speaker/speaker.css b/web/speaker/speaker.css
index 988392a..9586054 100644
--- a/web/speaker/speaker.css
+++ b/web/speaker/speaker.css
@@ -11,7 +11,16 @@
     border: none;
     border-radius: 4px;
     padding: 8px;
-    display: inline-block;
+    display: none;
+    margin: 4px;
+}
+
+#progressText {
+    background-color: rgb(179, 208, 146);
+    border: none;
+    border-radius: 4px;
+    padding: 8px;
+    display: none;
     margin: 4px;
 }
 
diff --git a/web/speaker/speaker.html b/web/speaker/speaker.html
index a20f084..1a9183d 100644
--- a/web/speaker/speaker.html
+++ b/web/speaker/speaker.html
@@ -2,13 +2,14 @@
 <html>
 <head>
   <title>Bumble Speaker</title>
-  <script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
-  <script type="module" src="speaker.js"></script>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
   <link rel="stylesheet" href="speaker.css">
+  <script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
+  <script type="module" src="speaker.js"></script>
+  <script type="module" src="../ui.js"></script>
 </head>
 <body>
   <h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
-  <div id="errorText"></div>
   <div id="speaker">
     <table><tr>
       <td>
@@ -25,7 +26,8 @@
     <span id="streamStateText">IDLE</span>
     <span id="connectionStateText">NOT CONNECTED</span>
     <div id="controlsDiv">
-        <button id="audioOnButton">Audio On</button>
+      <bumble-controls id="bumble-controls"></bumble-controls>
+      <button id="audioOnButton" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>volume_up</button>
     </div>
     <canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
     <audio id="audio"></audio>
diff --git a/web/speaker/speaker.js b/web/speaker/speaker.js
index b94180f..12189a4 100644
--- a/web/speaker/speaker.js
+++ b/web/speaker/speaker.js
@@ -1,4 +1,4 @@
-import { loadBumble, connectWebSocketTransport } from "../bumble.js";
+import {setupSimpleApp} from '../bumble.js';
 
 (function () {
     'use strict';
@@ -8,7 +8,6 @@
     let bytesReceivedText;
     let streamStateText;
     let connectionStateText;
-    let errorText;
     let audioOnButton;
     let mediaSource;
     let sourceBuffer;
@@ -19,15 +18,14 @@
     let audioFrequencyData;
     let packetsReceived = 0;
     let bytesReceived = 0;
-    let audioState = "stopped";
-    let streamState = "IDLE";
+    let audioState = 'stopped';
+    let streamState = 'IDLE';
     let fftCanvas;
     let fftCanvasContext;
     let bandwidthCanvas;
     let bandwidthCanvasContext;
     let bandwidthBinCount;
     let bandwidthBins = [];
-    let pyodide;
 
     const FFT_WIDTH = 800;
     const FFT_HEIGHT = 256;
@@ -44,18 +42,16 @@
     }
 
     function initUI() {
-        audioOnButton = document.getElementById("audioOnButton");
-        codecText = document.getElementById("codecText");
-        packetsReceivedText = document.getElementById("packetsReceivedText");
-        bytesReceivedText = document.getElementById("bytesReceivedText");
-        streamStateText = document.getElementById("streamStateText");
-        errorText = document.getElementById("errorText");
-        connectionStateText = document.getElementById("connectionStateText");
+        audioOnButton = document.getElementById('audioOnButton');
+        codecText = document.getElementById('codecText');
+        packetsReceivedText = document.getElementById('packetsReceivedText');
+        bytesReceivedText = document.getElementById('bytesReceivedText');
+        streamStateText = document.getElementById('streamStateText');
+        connectionStateText = document.getElementById('connectionStateText');
 
-        audioOnButton.onclick = () => startAudio();
+        audioOnButton.onclick = startAudio;
 
-        codecText.innerText = "AAC";
-        setErrorText("");
+        codecText.innerText = 'AAC';
 
         requestAnimationFrame(onAnimationFrame);
     }
@@ -68,62 +64,36 @@
     }
 
     function initAudioElement() {
-        audioElement = document.getElementById("audio");
+        audioElement = document.getElementById('audio');
         audioElement.src = URL.createObjectURL(mediaSource);
         // audioElement.controls = true;
     }
 
     function initAnalyzer() {
-        fftCanvas = document.getElementById("fftCanvas");
+        fftCanvas = document.getElementById('fftCanvas');
         fftCanvas.width = FFT_WIDTH
         fftCanvas.height = FFT_HEIGHT
         fftCanvasContext = fftCanvas.getContext('2d');
-        fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
+        fftCanvasContext.fillStyle = 'rgb(0, 0, 0)';
         fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
 
-        bandwidthCanvas = document.getElementById("bandwidthCanvas");
+        bandwidthCanvas = document.getElementById('bandwidthCanvas');
         bandwidthCanvas.width = BANDWIDTH_WIDTH
         bandwidthCanvas.height = BANDWIDTH_HEIGHT
         bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
-        bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
+        bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)';
         bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
     }
 
     async function initBumble() {
-        // Load pyodide
-        console.log("Loading Pyodide");
-        pyodide = await loadPyodide();
-
-        // Load Bumble
-        console.log("Loading Bumble");
-        const params = (new URL(document.location)).searchParams;
-        const bumblePackage = params.get("package") || "bumble";
-        await loadBumble(pyodide, bumblePackage);
-
-        console.log("Ready!")
-
-        const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
-        try {
-            // Create a WebSocket HCI transport
-            let transport
-            try {
-                transport = await connectWebSocketTransport(pyodide, hciWsUrl);
-            } catch (error) {
-                console.error(error);
-                setErrorText(error);
-                return;
-            }
-
-            // Run the scanner example
-            const script = await (await fetch("speaker.py")).text();
-            await pyodide.runPythonAsync(script);
-            const pythonMain = pyodide.globals.get("main");
-            console.log("Starting speaker...");
-            await pythonMain(transport.packet_source, transport.packet_sink, onEvent);
-            console.log("Speaker running");
-        } catch (err) {
-            console.log(err);
-        }
+        const bumbleControls = document.querySelector('#bumble-controls');
+        const app = await setupSimpleApp('speaker.py', bumbleControls, console.log);
+        app.on('start', onStart);
+        app.on('stop', onStop);
+        app.on('suspend', onSuspend);
+        app.on('connection', onConnection);
+        app.on('disconnection', onDisconnection);
+        app.on('audio', onAudio);
     }
 
     function startAnalyzer() {
@@ -144,15 +114,6 @@
         bandwidthBins = [];
     }
 
-    function setErrorText(message) {
-        errorText.innerText = message;
-        if (message.length == 0) {
-            errorText.style.display = "none";
-        } else {
-            errorText.style.display = "inline-block";
-        }
-    }
-
     function setStreamState(state) {
         streamState = state;
         streamStateText.innerText = streamState;
@@ -162,7 +123,7 @@
         // FFT
         if (audioAnalyzer !== undefined) {
             audioAnalyzer.getByteFrequencyData(audioFrequencyData);
-            fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
+            fftCanvasContext.fillStyle = 'rgb(0, 0, 0)';
             fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
             const barCount = audioFrequencyBinCount;
             const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
@@ -174,7 +135,7 @@
         }
 
         // Bandwidth
-        bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
+        bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)';
         bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
         bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
         for (let t = 0; t < bandwidthBins.length; t++) {
@@ -188,7 +149,7 @@
 
     function onMediaSourceOpen() {
         console.log(this.readyState);
-        sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
+        sourceBuffer = mediaSource.addSourceBuffer('audio/aac');
     }
 
     function onMediaSourceClose() {
@@ -201,41 +162,30 @@
 
     async function startAudio() {
         try {
-            console.log("starting audio...");
+            console.log('starting audio...');
             audioOnButton.disabled = true;
-            audioState = "starting";
+            audioState = 'starting';
             await audioElement.play();
-            console.log("audio started");
-            audioState = "playing";
+            console.log('audio started');
+            audioState = 'playing';
             startAnalyzer();
         } catch (error) {
             console.error(`play failed: ${error}`);
-            audioState = "stopped";
+            audioState = 'stopped';
             audioOnButton.disabled = false;
         }
     }
 
-    async function onEvent(name, params) {
-        // Dispatch the message.
-        const handlerName = `on${name.charAt(0).toUpperCase()}${name.slice(1)}`
-        const handler = eventHandlers[handlerName];
-        if (handler !== undefined) {
-            handler(params);
-        } else {
-            console.warn(`unhandled event: ${name}`)
-        }
-    }
-
     function onStart() {
-        setStreamState("STARTED");
+        setStreamState('STARTED');
     }
 
     function onStop() {
-        setStreamState("STOPPED");
+        setStreamState('STOPPED');
     }
 
     function onSuspend() {
-        setStreamState("SUSPENDED");
+        setStreamState('SUSPENDED');
     }
 
     function onConnection(params) {
@@ -243,13 +193,13 @@
     }
 
     function onDisconnection(params) {
-        connectionStateText.innerText = "DISCONNECTED";
+        connectionStateText.innerText = 'DISCONNECTED';
     }
 
     function onAudio(python_packet) {
         const packet = python_packet.toJs({create_proxies : false});
         python_packet.destroy();
-        if (audioState != "stopped") {
+        if (audioState != 'stopped') {
             // Queue the audio packet.
             sourceBuffer.appendBuffer(packet);
         }
@@ -265,25 +215,7 @@
         }
     }
 
-    function onKeystoreupdate() {
-        // Sync the FS
-        pyodide.FS.syncfs(() => {
-            console.log("FS synced out")
-        });
-    }
-
-    const eventHandlers = {
-        onStart,
-        onStop,
-        onSuspend,
-        onConnection,
-        onDisconnection,
-        onAudio,
-        onKeystoreupdate
-    }
-
     window.onload = (event) => {
         init();
     }
-
 }());
\ No newline at end of file
diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py
index d9488ce..2b8ce00 100644
--- a/web/speaker/speaker.py
+++ b/web/speaker/speaker.py
@@ -47,6 +47,7 @@
 )
 from bumble.utils import AsyncRunner
 from bumble.codecs import AacAudioRtpPacket
+from bumble.hci import HCI_Reset_Command
 
 
 # -----------------------------------------------------------------------------
@@ -95,15 +96,14 @@
         STARTED = 2
         SUSPENDED = 3
 
-    def __init__(self, hci_source, hci_sink, emit_event, codec, discover):
+    def __init__(self, hci_source, hci_sink, codec):
         self.hci_source = hci_source
         self.hci_sink = hci_sink
-        self.emit_event = emit_event
+        self.js_listeners = {}
         self.codec = codec
-        self.discover = discover
         self.device = None
         self.connection = None
-        self.listener = None
+        self.avdtp_listener = None
         self.packets_received = 0
         self.bytes_received = 0
         self.stream_state = Speaker.StreamState.IDLE
@@ -164,7 +164,7 @@
 
     def on_key_store_update(self):
         print("Key Store updated")
-        self.emit_event('keystoreupdate', None)
+        self.emit('key_store_update')
 
     def on_bluetooth_connection(self, connection):
         print(f'Connection: {connection}')
@@ -172,15 +172,12 @@
         connection.on('disconnection', self.on_bluetooth_disconnection)
         peer_name = '' if connection.peer_name is None else connection.peer_name
         peer_address = connection.peer_address.to_string(False)
-        self.emit_event(
-            'connection', {'peer_name': peer_name, 'peer_address': peer_address}
-        )
+        self.emit('connection', {'peer_name': peer_name, 'peer_address': peer_address})
 
     def on_bluetooth_disconnection(self, reason):
         print(f'Disconnection ({reason})')
         self.connection = None
-        AsyncRunner.spawn(self.advertise())
-        self.emit_event('disconnection', None)
+        self.emit('disconnection', None)
 
     def on_avdtp_connection(self, protocol):
         print('Audio Stream Open')
@@ -198,27 +195,23 @@
         # Listen for close events
         protocol.on('close', self.on_avdtp_close)
 
-        # Discover all endpoints on the remote device is requested
-        if self.discover:
-            AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
-
     def on_avdtp_close(self):
         print("Audio Stream Closed")
 
     def on_sink_start(self):
         print("Sink Started")
         self.stream_state = self.StreamState.STARTED
-        self.emit_event('start', None)
+        self.emit('start', None)
 
     def on_sink_stop(self):
         print("Sink Stopped")
         self.stream_state = self.StreamState.STOPPED
-        self.emit_event('stop', None)
+        self.emit('stop', None)
 
     def on_sink_suspend(self):
         print("Sink Suspended")
         self.stream_state = self.StreamState.SUSPENDED
-        self.emit_event('suspend', None)
+        self.emit('suspend', None)
 
     def on_sink_configuration(self, config):
         print("Sink Configuration:")
@@ -234,11 +227,7 @@
     def on_rtp_packet(self, packet):
         self.packets_received += 1
         self.bytes_received += len(packet.payload)
-        self.emit_event("audio", self.audio_extractor.extract_audio(packet))
-
-    async def advertise(self):
-        await self.device.set_discoverable(True)
-        await self.device.set_connectable(True)
+        self.emit("audio", self.audio_extractor.extract_audio(packet))
 
     async def connect(self, address):
         # Connect to the source
@@ -257,7 +246,7 @@
         print('*** Encryption on')
 
         protocol = await Protocol.connect(connection)
-        self.listener.set_server(connection, protocol)
+        self.avdtp_listener.set_server(connection, protocol)
         self.on_avdtp_connection(protocol)
 
     async def discover_remote_endpoints(self, protocol):
@@ -266,6 +255,13 @@
         for endpoint in endpoints:
             print('@@@', endpoint)
 
+    def on(self, event_name, listener):
+        self.js_listeners[event_name] = listener
+
+    def emit(self, event_name, event=None):
+        if listener := self.js_listeners.get(event_name):
+            listener(event)
+
     async def run(self, connect_address):
         # Create a device
         device_config = DeviceConfiguration()
@@ -296,8 +292,8 @@
         self.device.on('key_store_update', self.on_key_store_update)
 
         # Create a listener to wait for AVDTP connections
-        self.listener = Listener.for_device(self.device)
-        self.listener.on('connection', self.on_avdtp_connection)
+        self.avdtp_listener = Listener.for_device(self.device)
+        self.avdtp_listener.on('connection', self.on_avdtp_connection)
 
         print(f'Speaker ready to play, codec={self.codec}')
 
@@ -309,13 +305,19 @@
                 print("Connection timed out")
                 return
         else:
-            # Start being discoverable and connectable
+            # We'll wait for a connection
             print("Waiting for connection...")
-            await self.advertise()
+
+    async def start(self):
+        await self.run(None)
+
+    async def stop(self):
+        # TODO: replace this once a proper reset is implemented in the lib.
+        await self.device.host.send_command(HCI_Reset_Command())
+        await self.device.power_off()
+        print('Speaker stopped')
 
 
 # -----------------------------------------------------------------------------
-async def main(hci_source, hci_sink, emit_event):
-    # logging.basicConfig(level='DEBUG')
-    speaker = Speaker(hci_source, hci_sink, emit_event, "aac", False)
-    await speaker.run(None)
+def main(hci_source, hci_sink):
+    return Speaker(hci_source, hci_sink, "aac")
diff --git a/web/ui.js b/web/ui.js
new file mode 100644
index 0000000..6e8d877
--- /dev/null
+++ b/web/ui.js
@@ -0,0 +1,102 @@
+import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
+
+class BumbleControls extends LitElement {
+    constructor() {
+        super();
+        this.bumbleLoaded = false;
+        this.connected = false;
+    }
+
+    render() {
+        return html`
+            <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+            <dialog id="settings-dialog" @close=${this.onSettingsDialogClose} style="font-family:sans-serif">
+                <p>WebSocket URL for HCI transport</p>
+                <form>
+                    <input id="settings-hci-url-input" type="text" size="50"></input>
+                    <button value="cancel" formmethod="dialog">Cancel</button>
+                    <button @click=${this.saveSettings}>Save</button>
+                </form>
+            </dialog>
+            <button @click=${this.openSettingsDialog} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>settings</button>
+            <button @click=${this.connectBluetooth} ?disabled=${!this.canConnect()} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>bluetooth</button>
+            <button @click=${this.stop} ?disabled=${!this.connected} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>stop</button>
+        `
+    }
+
+    get settingsHciUrlInput() {
+        return this.renderRoot.querySelector('#settings-hci-url-input');
+    }
+
+    get settingsDialog() {
+        return this.renderRoot.querySelector('#settings-dialog');
+    }
+
+    canConnect() {
+        return this.bumbleLoaded && !this.connected && this.getHciUrl();
+    }
+
+    getHciUrl() {
+        // Look for a URL parameter setting first.
+        const params = (new URL(document.location)).searchParams;
+        let hciWsUrl = params.get("hci");
+        if (hciWsUrl) {
+          return hciWsUrl;
+        }
+
+        // Try to load the setting from storage.
+        hciWsUrl = localStorage.getItem("hciWsUrl");
+        if (hciWsUrl) {
+          return hciWsUrl;
+        }
+
+        // Finally, default to nothing.
+        return null;
+    }
+
+    openSettingsDialog() {
+        const hciUrl = this.getHciUrl();
+        if (hciUrl) {
+            this.settingsHciUrlInput.value = hciUrl;
+        } else {
+          // Start with default, assuming port 7681.
+          this.settingsHciUrlInput.value = "ws://localhost:7681/v1/websocket/bt"
+        }
+        this.settingsDialog.showModal();
+    }
+
+    onSettingsDialogClose() {
+        if (this.settingsDialog.returnValue === "cancel") {
+            return;
+        }
+        if (this.settingsHciUrlInput.value) {
+            localStorage.setItem("hciWsUrl", this.settingsHciUrlInput.value);
+        } else {
+            localStorage.removeItem("hciWsUrl");
+        }
+
+        this.requestUpdate();
+    }
+
+    saveSettings(event) {
+        event.preventDefault();
+        this.settingsDialog.close(this.settingsHciUrlInput.value);
+    }
+
+    async connectBluetooth() {
+        this.connected = await this.connector(this.getHciUrl());
+        this.requestUpdate();
+    }
+
+    async stop() {
+        await this.stopper();
+        this.connected = false;
+        this.requestUpdate();
+    }
+
+    onBumbleLoaded() {
+        this.bumbleLoaded = true;
+        this.requestUpdate();
+    }
+}
+customElements.define('bumble-controls', BumbleControls);