Merge remote-tracking branch 'aosp/upstream-main' am: 9263e0cd4a

Original change: https://android-review.googlesource.com/c/platform/external/python/bumble/+/3238691

Change-Id: Ide463a63dd7389d074b70fe6da5c30dc7252e51c
Signed-off-by: Automerger Merge Worker <[email protected]>
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..92ebdab
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,30 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
+// README at: https://github.com/devcontainers/templates/tree/main/src/python
+{
+	// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
+    "image": "mcr.microsoft.com/devcontainers/universal:2",
+
+	// Features to add to the dev container. More info: https://containers.dev/features.
+	// "features": {},
+
+	// Use 'forwardPorts' to make a list of ports inside the container available locally.
+	// "forwardPorts": [],
+
+	// Use 'postCreateCommand' to run commands after the container is created.
+    "postCreateCommand": 
+        "python -m pip install '.[build,test,development,documentation]'",
+
+    // Configure tool-specific properties.
+    "customizations": {
+        // Configure properties specific to VS Code.
+        "vscode": {
+            // Add the IDs of extensions you want installed when the container is created.
+            "extensions": [
+                "ms-python.python"
+            ]
+        }
+    }
+
+	// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
+	// "remoteUser": "root"
+}
diff --git a/METADATA b/METADATA
index 48086d7..7c7ee93 100644
--- a/METADATA
+++ b/METADATA
@@ -11,7 +11,7 @@
     type: GIT
     value: "https://github.com/google/bumble"
   }
-  version: "783b2d70a517a4c5fd828a0f6b8b2a46fe8750c5"
-  last_upgrade_date { year: 2023 month: 9 day: 12 }
+  version: "4a691c11d4e1f336121e83637f68dcf868416ece"
+  last_upgrade_date { year: 2024 month: 8 day: 23 }
   license_type: NOTICE
 }
diff --git a/apps/auracast.py b/apps/auracast.py
new file mode 100644
index 0000000..96f2a23
--- /dev/null
+++ b/apps/auracast.py
@@ -0,0 +1,692 @@
+# 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
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import asyncio
+import contextlib
+import dataclasses
+import logging
+import os
+from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
+
+import click
+import pyee
+
+from bumble.colors import color
+import bumble.company_ids
+import bumble.core
+import bumble.device
+import bumble.gatt
+import bumble.hci
+import bumble.profiles.bap
+import bumble.profiles.bass
+import bumble.profiles.pbp
+import bumble.transport
+import bumble.utils
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast'
+AURACAST_DEFAULT_DEVICE_ADDRESS = bumble.hci.Address('F0:F1:F2:F3:F4:F5')
+AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
+AURACAST_DEFAULT_ATT_MTU = 256
+
+
+# -----------------------------------------------------------------------------
+# Scan For Broadcasts
+# -----------------------------------------------------------------------------
+class BroadcastScanner(pyee.EventEmitter):
+    @dataclasses.dataclass
+    class Broadcast(pyee.EventEmitter):
+        name: str
+        sync: bumble.device.PeriodicAdvertisingSync
+        rssi: int = 0
+        public_broadcast_announcement: Optional[
+            bumble.profiles.pbp.PublicBroadcastAnnouncement
+        ] = None
+        broadcast_audio_announcement: Optional[
+            bumble.profiles.bap.BroadcastAudioAnnouncement
+        ] = None
+        basic_audio_announcement: Optional[
+            bumble.profiles.bap.BasicAudioAnnouncement
+        ] = None
+        appearance: Optional[bumble.core.Appearance] = None
+        biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
+        manufacturer_data: Optional[Tuple[str, bytes]] = None
+
+        def __post_init__(self) -> None:
+            super().__init__()
+            self.sync.on('establishment', self.on_sync_establishment)
+            self.sync.on('loss', self.on_sync_loss)
+            self.sync.on('periodic_advertisement', self.on_periodic_advertisement)
+            self.sync.on('biginfo_advertisement', self.on_biginfo_advertisement)
+
+        def update(self, advertisement: bumble.device.Advertisement) -> None:
+            self.rssi = advertisement.rssi
+            for service_data in advertisement.data.get_all(
+                bumble.core.AdvertisingData.SERVICE_DATA
+            ):
+                assert isinstance(service_data, tuple)
+                service_uuid, data = service_data
+                assert isinstance(data, bytes)
+
+                if (
+                    service_uuid
+                    == bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
+                ):
+                    self.public_broadcast_announcement = (
+                        bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
+                    )
+                    continue
+
+                if (
+                    service_uuid
+                    == bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
+                ):
+                    self.broadcast_audio_announcement = (
+                        bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
+                    )
+                    continue
+
+            self.appearance = advertisement.data.get(  # type: ignore[assignment]
+                bumble.core.AdvertisingData.APPEARANCE
+            )
+
+            if manufacturer_data := advertisement.data.get(
+                bumble.core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
+            ):
+                assert isinstance(manufacturer_data, tuple)
+                company_id = cast(int, manufacturer_data[0])
+                data = cast(bytes, manufacturer_data[1])
+                self.manufacturer_data = (
+                    bumble.company_ids.COMPANY_IDENTIFIERS.get(
+                        company_id, f'0x{company_id:04X}'
+                    ),
+                    data,
+                )
+
+            self.emit('update')
+
+        def print(self) -> None:
+            print(
+                color('Broadcast:', 'yellow'),
+                self.sync.advertiser_address,
+                color(self.sync.state.name, 'green'),
+            )
+            print(f'  {color("Name", "cyan")}:         {self.name}')
+            if self.appearance:
+                print(f'  {color("Appearance", "cyan")}:   {str(self.appearance)}')
+            print(f'  {color("RSSI", "cyan")}:         {self.rssi}')
+            print(f'  {color("SID", "cyan")}:          {self.sync.sid}')
+
+            if self.manufacturer_data:
+                print(
+                    f'  {color("Manufacturer Data", "cyan")}: '
+                    f'{self.manufacturer_data[0]} -> {self.manufacturer_data[1].hex()}'
+                )
+
+            if self.broadcast_audio_announcement:
+                print(
+                    f'  {color("Broadcast ID", "cyan")}: '
+                    f'{self.broadcast_audio_announcement.broadcast_id}'
+                )
+
+            if self.public_broadcast_announcement:
+                print(
+                    f'  {color("Features", "cyan")}:     '
+                    f'{self.public_broadcast_announcement.features}'
+                )
+                print(
+                    f'  {color("Metadata", "cyan")}:     '
+                    f'{self.public_broadcast_announcement.metadata}'
+                )
+
+            if self.basic_audio_announcement:
+                print(color('  Audio:', 'cyan'))
+                print(
+                    color('    Presentation Delay:', 'magenta'),
+                    self.basic_audio_announcement.presentation_delay,
+                )
+                for subgroup in self.basic_audio_announcement.subgroups:
+                    print(color('    Subgroup:', 'magenta'))
+                    print(color('      Codec ID:', 'yellow'))
+                    print(
+                        color('        Coding Format:           ', 'green'),
+                        subgroup.codec_id.coding_format.name,
+                    )
+                    print(
+                        color('        Company ID:              ', 'green'),
+                        subgroup.codec_id.company_id,
+                    )
+                    print(
+                        color('        Vendor Specific Codec ID:', 'green'),
+                        subgroup.codec_id.vendor_specific_codec_id,
+                    )
+                    print(
+                        color('      Codec Config:', 'yellow'),
+                        subgroup.codec_specific_configuration,
+                    )
+                    print(color('      Metadata:    ', 'yellow'), subgroup.metadata)
+
+                    for bis in subgroup.bis:
+                        print(color(f'      BIS [{bis.index}]:', 'yellow'))
+                        print(
+                            color('       Codec Config:', 'green'),
+                            bis.codec_specific_configuration,
+                        )
+
+            if self.biginfo:
+                print(color('  BIG:', 'cyan'))
+                print(
+                    color('    Number of BIS:', 'magenta'),
+                    self.biginfo.num_bis,
+                )
+                print(
+                    color('    PHY:          ', 'magenta'),
+                    self.biginfo.phy.name,
+                )
+                print(
+                    color('    Framed:       ', 'magenta'),
+                    self.biginfo.framed,
+                )
+                print(
+                    color('    Encrypted:    ', 'magenta'),
+                    self.biginfo.encrypted,
+                )
+
+        def on_sync_establishment(self) -> None:
+            self.emit('sync_establishment')
+
+        def on_sync_loss(self) -> None:
+            self.basic_audio_announcement = None
+            self.biginfo = None
+            self.emit('sync_loss')
+
+        def on_periodic_advertisement(
+            self, advertisement: bumble.device.PeriodicAdvertisement
+        ) -> None:
+            if advertisement.data is None:
+                return
+
+            for service_data in advertisement.data.get_all(
+                bumble.core.AdvertisingData.SERVICE_DATA
+            ):
+                assert isinstance(service_data, tuple)
+                service_uuid, data = service_data
+                assert isinstance(data, bytes)
+
+                if service_uuid == bumble.gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
+                    self.basic_audio_announcement = (
+                        bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
+                    )
+                    break
+
+            self.emit('change')
+
+        def on_biginfo_advertisement(
+            self, advertisement: bumble.device.BIGInfoAdvertisement
+        ) -> None:
+            self.biginfo = advertisement
+            self.emit('change')
+
+    def __init__(
+        self,
+        device: bumble.device.Device,
+        filter_duplicates: bool,
+        sync_timeout: float,
+    ):
+        super().__init__()
+        self.device = device
+        self.filter_duplicates = filter_duplicates
+        self.sync_timeout = sync_timeout
+        self.broadcasts: Dict[bumble.hci.Address, BroadcastScanner.Broadcast] = {}
+        device.on('advertisement', self.on_advertisement)
+
+    async def start(self) -> None:
+        await self.device.start_scanning(
+            active=False,
+            filter_duplicates=False,
+        )
+
+    async def stop(self) -> None:
+        await self.device.stop_scanning()
+
+    def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
+        if (
+            broadcast_name := advertisement.data.get(
+                bumble.core.AdvertisingData.BROADCAST_NAME
+            )
+        ) is None:
+            return
+        assert isinstance(broadcast_name, str)
+
+        if broadcast := self.broadcasts.get(advertisement.address):
+            broadcast.update(advertisement)
+            return
+
+        bumble.utils.AsyncRunner.spawn(
+            self.on_new_broadcast(broadcast_name, advertisement)
+        )
+
+    async def on_new_broadcast(
+        self, name: str, advertisement: bumble.device.Advertisement
+    ) -> None:
+        periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
+            advertiser_address=advertisement.address,
+            sid=advertisement.sid,
+            sync_timeout=self.sync_timeout,
+            filter_duplicates=self.filter_duplicates,
+        )
+        broadcast = self.Broadcast(
+            name,
+            periodic_advertising_sync,
+        )
+        broadcast.update(advertisement)
+        self.broadcasts[advertisement.address] = broadcast
+        periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
+        self.emit('new_broadcast', broadcast)
+
+    def on_broadcast_loss(self, broadcast: Broadcast) -> None:
+        del self.broadcasts[broadcast.sync.advertiser_address]
+        bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
+        self.emit('broadcast_loss', broadcast)
+
+
+class PrintingBroadcastScanner:
+    def __init__(
+        self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
+    ) -> None:
+        self.scanner = BroadcastScanner(device, filter_duplicates, sync_timeout)
+        self.scanner.on('new_broadcast', self.on_new_broadcast)
+        self.scanner.on('broadcast_loss', self.on_broadcast_loss)
+        self.scanner.on('update', self.refresh)
+        self.status_message = ''
+
+    async def start(self) -> None:
+        self.status_message = color('Scanning...', 'green')
+        await self.scanner.start()
+
+    def on_new_broadcast(self, broadcast: BroadcastScanner.Broadcast) -> None:
+        self.status_message = color(
+            f'+Found {len(self.scanner.broadcasts)} broadcasts', 'green'
+        )
+        broadcast.on('change', self.refresh)
+        broadcast.on('update', self.refresh)
+        self.refresh()
+
+    def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
+        self.status_message = color(
+            f'-Found {len(self.scanner.broadcasts)} broadcasts', 'green'
+        )
+        self.refresh()
+
+    def refresh(self) -> None:
+        # Clear the screen from the top
+        print('\033[H')
+        print('\033[0J')
+        print('\033[H')
+
+        # Print the status message
+        print(self.status_message)
+        print("==========================================")
+
+        # Print all broadcasts
+        for broadcast in self.scanner.broadcasts.values():
+            broadcast.print()
+            print('------------------------------------------')
+
+        # Clear the screen to the bottom
+        print('\033[0J')
+
+
[email protected]
+async def create_device(transport: str) -> AsyncGenerator[bumble.device.Device, Any]:
+    async with await bumble.transport.open_transport(transport) as (
+        hci_source,
+        hci_sink,
+    ):
+        device_config = bumble.device.DeviceConfiguration(
+            name=AURACAST_DEFAULT_DEVICE_NAME,
+            address=AURACAST_DEFAULT_DEVICE_ADDRESS,
+            keystore='JsonKeyStore',
+        )
+
+        device = bumble.device.Device.from_config_with_hci(
+            device_config,
+            hci_source,
+            hci_sink,
+        )
+        await device.power_on()
+
+        yield device
+
+
+async def find_broadcast_by_name(
+    device: bumble.device.Device, name: Optional[str]
+) -> BroadcastScanner.Broadcast:
+    result = asyncio.get_running_loop().create_future()
+
+    def on_broadcast_change(broadcast: BroadcastScanner.Broadcast) -> None:
+        if broadcast.basic_audio_announcement and not result.done():
+            print(color('Broadcast basic audio announcement received', 'green'))
+            result.set_result(broadcast)
+
+    def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
+        if name is None or broadcast.name == name:
+            print(color('Broadcast found:', 'green'), broadcast.name)
+            broadcast.on('change', lambda: on_broadcast_change(broadcast))
+            return
+
+        print(color(f'Skipping broadcast {broadcast.name}'))
+
+    scanner = BroadcastScanner(device, False, AURACAST_DEFAULT_SYNC_TIMEOUT)
+    scanner.on('new_broadcast', on_new_broadcast)
+    await scanner.start()
+
+    broadcast = await result
+    await scanner.stop()
+
+    return broadcast
+
+
+async def run_scan(
+    filter_duplicates: bool, sync_timeout: float, transport: str
+) -> None:
+    async with create_device(transport) as device:
+        if not device.supports_le_periodic_advertising:
+            print(color('Periodic advertising not supported', 'red'))
+            return
+
+        scanner = PrintingBroadcastScanner(device, filter_duplicates, sync_timeout)
+        await scanner.start()
+        await asyncio.get_running_loop().create_future()
+
+
+async def run_assist(
+    broadcast_name: Optional[str],
+    source_id: Optional[int],
+    command: str,
+    transport: str,
+    address: str,
+) -> None:
+    async with create_device(transport) as device:
+        if not device.supports_le_periodic_advertising:
+            print(color('Periodic advertising not supported', 'red'))
+            return
+
+        # Connect to the server
+        print(f'=== Connecting to {address}...')
+        connection = await device.connect(address)
+        peer = bumble.device.Peer(connection)
+        print(f'=== Connected to {peer}')
+
+        print("+++ Encrypting connection...")
+        await peer.connection.encrypt()
+        print("+++ Connection encrypted")
+
+        # Request a larger MTU
+        mtu = AURACAST_DEFAULT_ATT_MTU
+        print(color(f'$$$ Requesting MTU={mtu}', 'yellow'))
+        await peer.request_mtu(mtu)
+
+        # Get the BASS service
+        bass = await peer.discover_service_and_create_proxy(
+            bumble.profiles.bass.BroadcastAudioScanServiceProxy
+        )
+
+        # Check that the service was found
+        if not bass:
+            print(color('!!! Broadcast Audio Scan Service not found', 'red'))
+            return
+
+        # Subscribe to and read the broadcast receive state characteristics
+        for i, broadcast_receive_state in enumerate(bass.broadcast_receive_states):
+            try:
+                await broadcast_receive_state.subscribe(
+                    lambda value, i=i: print(
+                        f"{color(f'Broadcast Receive State Update [{i}]:', 'green')} {value}"
+                    )
+                )
+            except bumble.core.ProtocolError as error:
+                print(
+                    color(
+                        f'!!! Failed to subscribe to Broadcast Receive State characteristic:',
+                        'red',
+                    ),
+                    error,
+                )
+            value = await broadcast_receive_state.read_value()
+            print(
+                f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}'
+            )
+
+        if command == 'monitor-state':
+            await peer.sustain()
+            return
+
+        if command == 'add-source':
+            # Find the requested broadcast
+            await bass.remote_scan_started()
+            if broadcast_name:
+                print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
+            else:
+                print(color('Scanning for any broadcast', 'cyan'))
+            broadcast = await find_broadcast_by_name(device, broadcast_name)
+
+            if broadcast.broadcast_audio_announcement is None:
+                print(color('No broadcast audio announcement found', 'red'))
+                return
+
+            if (
+                broadcast.basic_audio_announcement is None
+                or not broadcast.basic_audio_announcement.subgroups
+            ):
+                print(color('No subgroups found', 'red'))
+                return
+
+            # Add the source
+            print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
+            await bass.add_source(
+                broadcast.sync.advertiser_address,
+                broadcast.sync.sid,
+                broadcast.broadcast_audio_announcement.broadcast_id,
+                bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
+                0xFFFF,
+                [
+                    bumble.profiles.bass.SubgroupInfo(
+                        bumble.profiles.bass.SubgroupInfo.ANY_BIS,
+                        bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
+                    )
+                ],
+            )
+
+            # Initiate a PA Sync Transfer
+            await broadcast.sync.transfer(peer.connection)
+
+            # Notify the sink that we're done scanning.
+            await bass.remote_scan_stopped()
+
+            await peer.sustain()
+            return
+
+        if command == 'modify-source':
+            if source_id is None:
+                print(color('!!! modify-source requires --source-id'))
+                return
+
+            # Find the requested broadcast
+            await bass.remote_scan_started()
+            if broadcast_name:
+                print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
+            else:
+                print(color('Scanning for any broadcast', 'cyan'))
+            broadcast = await find_broadcast_by_name(device, broadcast_name)
+
+            if broadcast.broadcast_audio_announcement is None:
+                print(color('No broadcast audio announcement found', 'red'))
+                return
+
+            if (
+                broadcast.basic_audio_announcement is None
+                or not broadcast.basic_audio_announcement.subgroups
+            ):
+                print(color('No subgroups found', 'red'))
+                return
+
+            # Modify the source
+            print(
+                color('Modifying source:', 'blue'),
+                source_id,
+            )
+            await bass.modify_source(
+                source_id,
+                bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
+                0xFFFF,
+                [
+                    bumble.profiles.bass.SubgroupInfo(
+                        bumble.profiles.bass.SubgroupInfo.ANY_BIS,
+                        bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
+                    )
+                ],
+            )
+            await peer.sustain()
+            return
+
+        if command == 'remove-source':
+            if source_id is None:
+                print(color('!!! remove-source requires --source-id'))
+                return
+
+            # Remove the source
+            print(color('Removing source:', 'blue'), source_id)
+            await bass.remove_source(source_id)
+            await peer.sustain()
+            return
+
+        print(color(f'!!! invalid command {command}'))
+
+
+async def run_pair(transport: str, address: str) -> None:
+    async with create_device(transport) as device:
+
+        # Connect to the server
+        print(f'=== Connecting to {address}...')
+        async with device.connect_as_gatt(address) as peer:
+            print(f'=== Connected to {peer}')
+
+            print("+++ Initiating pairing...")
+            await peer.connection.pair()
+            print("+++ Paired")
+
+
+def run_async(async_command: Coroutine) -> None:
+    try:
+        asyncio.run(async_command)
+    except bumble.core.ProtocolError as error:
+        if error.error_namespace == 'att' and error.error_code in list(
+            bumble.profiles.bass.ApplicationError
+        ):
+            message = bumble.profiles.bass.ApplicationError(error.error_code).name
+        else:
+            message = str(error)
+
+        print(
+            color('!!! An error occurred while executing the command:', 'red'), message
+        )
+
+
+# -----------------------------------------------------------------------------
+# Main
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]_context
+def auracast(
+    ctx,
+):
+    ctx.ensure_object(dict)
+
+
[email protected]('scan')
[email protected](
+    '--filter-duplicates', is_flag=True, default=False, help='Filter duplicates'
+)
[email protected](
+    '--sync-timeout',
+    metavar='SYNC_TIMEOUT',
+    type=float,
+    default=AURACAST_DEFAULT_SYNC_TIMEOUT,
+    help='Sync timeout (in seconds)',
+)
[email protected]('transport')
[email protected]_context
+def scan(ctx, filter_duplicates, sync_timeout, transport):
+    """Scan for public broadcasts"""
+    run_async(run_scan(filter_duplicates, sync_timeout, transport))
+
+
[email protected]('assist')
[email protected](
+    '--broadcast-name',
+    metavar='BROADCAST_NAME',
+    help='Broadcast Name to tune to',
+)
[email protected](
+    '--source-id',
+    metavar='SOURCE_ID',
+    type=int,
+    help='Source ID (for remove-source command)',
+)
[email protected](
+    '--command',
+    type=click.Choice(
+        ['monitor-state', 'add-source', 'modify-source', 'remove-source']
+    ),
+    required=True,
+)
[email protected]('transport')
[email protected]('address')
[email protected]_context
+def assist(ctx, broadcast_name, source_id, command, transport, address):
+    """Scan for broadcasts on behalf of a audio server"""
+    run_async(run_assist(broadcast_name, source_id, command, transport, address))
+
+
[email protected]('pair')
[email protected]('transport')
[email protected]('address')
[email protected]_context
+def pair(ctx, transport, address):
+    """Pair with an audio server"""
+    run_async(run_pair(transport, address))
+
+
+def main():
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    auracast()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == "__main__":
+    main()  # pylint: disable=no-value-for-parameter
diff --git a/apps/bench.py b/apps/bench.py
index f0e8b58..0e5addb 100644
--- a/apps/bench.py
+++ b/apps/bench.py
@@ -40,6 +40,8 @@
     HCI_LE_1M_PHY,
     HCI_LE_2M_PHY,
     HCI_LE_CODED_PHY,
+    HCI_CENTRAL_ROLE,
+    HCI_PERIPHERAL_ROLE,
     HCI_Constant,
     HCI_Error,
     HCI_StatusError,
@@ -57,6 +59,7 @@
 import bumble.rfcomm
 import bumble.core
 from bumble.utils import AsyncRunner
+from bumble.pairing import PairingConfig
 
 
 # -----------------------------------------------------------------------------
@@ -128,40 +131,34 @@
 
 
 def print_connection(connection):
+    params = []
     if connection.transport == BT_LE_TRANSPORT:
-        phy_state = (
+        params.append(
             'PHY='
             f'TX:{le_phy_name(connection.phy.tx_phy)}/'
             f'RX:{le_phy_name(connection.phy.rx_phy)}'
         )
 
-        data_length = (
+        params.append(
             'DL=('
             f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
             f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
             ')'
         )
-        connection_parameters = (
+
+        params.append(
             'Parameters='
             f'{connection.parameters.connection_interval * 1.25:.2f}/'
             f'{connection.parameters.peripheral_latency}/'
             f'{connection.parameters.supervision_timeout * 10} '
         )
 
+        params.append(f'MTU={connection.att_mtu}')
+
     else:
-        phy_state = ''
-        data_length = ''
-        connection_parameters = ''
+        params.append(f'Role={HCI_Constant.role_name(connection.role)}')
 
-    mtu = connection.att_mtu
-
-    logging.info(
-        f'{color("@@@ Connection:", "yellow")} '
-        f'{connection_parameters} '
-        f'{data_length} '
-        f'{phy_state} '
-        f'MTU={mtu}'
-    )
+    logging.info(color('@@@ Connection: ', 'yellow') + ' '.join(params))
 
 
 def make_sdp_records(channel):
@@ -214,6 +211,17 @@
     )
 
 
+async def switch_roles(connection, role):
+    target_role = HCI_CENTRAL_ROLE if role == "central" else HCI_PERIPHERAL_ROLE
+    if connection.role != target_role:
+        logging.info(f'{color("### Switching roles to:", "cyan")} {role}')
+        try:
+            await connection.switch_role(target_role)
+            logging.info(color('### Role switch complete', 'cyan'))
+        except HCI_Error as error:
+            logging.info(f'{color("### Role switch failed:", "red")} {error}')
+
+
 class PacketType(enum.IntEnum):
     RESET = 0
     SEQUENCE = 1
@@ -1034,6 +1042,10 @@
 
     def on_dlc(self, dlc):
         logging.info(color(f'*** DLC connected: {dlc}', 'blue'))
+        if self.credits_threshold is not None:
+            dlc.rx_threshold = self.credits_threshold
+        if self.max_credits is not None:
+            dlc.rx_max_credits = self.max_credits
         dlc.sink = self.on_packet
         self.io_sink = dlc.write
         self.dlc = dlc
@@ -1063,6 +1075,7 @@
         authenticate,
         encrypt,
         extended_data_length,
+        role_switch,
     ):
         super().__init__()
         self.transport = transport
@@ -1073,6 +1086,7 @@
         self.authenticate = authenticate
         self.encrypt = encrypt or authenticate
         self.extended_data_length = extended_data_length
+        self.role_switch = role_switch
         self.device = None
         self.connection = None
 
@@ -1123,6 +1137,11 @@
             role = self.role_factory(mode)
             self.device.classic_enabled = self.classic
 
+            # Set up a pairing config factory with minimal requirements.
+            self.device.pairing_config_factory = lambda _: PairingConfig(
+                sc=False, mitm=False, bonding=False
+            )
+
             await self.device.power_on()
 
             if self.classic:
@@ -1151,6 +1170,10 @@
             self.connection.listener = self
             print_connection(self.connection)
 
+            # Switch roles if needed.
+            if self.role_switch:
+                await switch_roles(self.connection, self.role_switch)
+
             # 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
@@ -1212,20 +1235,30 @@
     def on_connection_data_length_change(self):
         print_connection(self.connection)
 
+    def on_role_change(self):
+        print_connection(self.connection)
+
 
 # -----------------------------------------------------------------------------
 # Peripheral
 # -----------------------------------------------------------------------------
 class Peripheral(Device.Listener, Connection.Listener):
     def __init__(
-        self, transport, classic, extended_data_length, role_factory, mode_factory
+        self,
+        transport,
+        role_factory,
+        mode_factory,
+        classic,
+        extended_data_length,
+        role_switch,
     ):
         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
+        self.extended_data_length = extended_data_length
+        self.role_switch = role_switch
+        self.role = None
         self.mode = None
         self.device = None
         self.connection = None
@@ -1248,6 +1281,11 @@
             self.role = self.role_factory(self.mode)
             self.device.classic_enabled = self.classic
 
+            # Set up a pairing config factory with minimal requirements.
+            self.device.pairing_config_factory = lambda _: PairingConfig(
+                sc=False, mitm=False, bonding=False
+            )
+
             await self.device.power_on()
 
             if self.classic:
@@ -1274,6 +1312,7 @@
 
             await self.connected.wait()
             logging.info(color('### Connected', 'cyan'))
+            print_connection(self.connection)
 
             await self.mode.on_connection(self.connection)
             await self.role.run()
@@ -1290,7 +1329,7 @@
             AsyncRunner.spawn(self.device.set_connectable(False))
 
         # Request a new data length if needed
-        if self.extended_data_length:
+        if not self.classic and self.extended_data_length:
             logging.info("+++ Requesting extended data length")
             AsyncRunner.spawn(
                 connection.set_data_length(
@@ -1298,6 +1337,10 @@
                 )
             )
 
+        # Switch roles if needed.
+        if self.role_switch:
+            AsyncRunner.spawn(switch_roles(connection, self.role_switch))
+
     def on_disconnection(self, reason):
         logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
         self.connection = None
@@ -1319,6 +1362,9 @@
     def on_connection_data_length_change(self):
         print_connection(self.connection)
 
+    def on_role_change(self):
+        print_connection(self.connection)
+
 
 # -----------------------------------------------------------------------------
 def create_mode_factory(ctx, default_mode):
@@ -1449,6 +1495,11 @@
     help='Request a data length upon connection, specified as tx_octets/tx_time',
 )
 @click.option(
+    '--role-switch',
+    type=click.Choice(['central', 'peripheral']),
+    help='Request role switch upon connection (central or peripheral)',
+)
[email protected](
     '--rfcomm-channel',
     type=int,
     default=DEFAULT_RFCOMM_CHANNEL,
@@ -1512,7 +1563,7 @@
     '--packet-size',
     '-s',
     metavar='SIZE',
-    type=click.IntRange(8, 4096),
+    type=click.IntRange(8, 8192),
     default=500,
     help='Packet size (client or ping role)',
 )
@@ -1572,6 +1623,7 @@
     mode,
     att_mtu,
     extended_data_length,
+    role_switch,
     packet_size,
     packet_count,
     start_delay,
@@ -1614,12 +1666,12 @@
     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['role_switch'] = role_switch
     ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
 
 
@@ -1663,6 +1715,7 @@
             authenticate,
             encrypt or authenticate,
             ctx.obj['extended_data_length'],
+            ctx.obj['role_switch'],
         ).run()
 
     asyncio.run(run_central())
@@ -1679,10 +1732,11 @@
     async def run_peripheral():
         await Peripheral(
             transport,
-            ctx.obj['classic'],
-            ctx.obj['extended_data_length'],
             role_factory,
             mode_factory,
+            ctx.obj['classic'],
+            ctx.obj['extended_data_length'],
+            ctx.obj['role_switch'],
         ).run()
 
     asyncio.run(run_peripheral())
diff --git a/apps/console.py b/apps/console.py
index 5d04636..e942321 100644
--- a/apps/console.py
+++ b/apps/console.py
@@ -63,6 +63,7 @@
 from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
 from bumble.gatt_client import CharacteristicProxy
 from bumble.hci import (
+    Address,
     HCI_Constant,
     HCI_LE_1M_PHY,
     HCI_LE_2M_PHY,
@@ -289,11 +290,7 @@
                     device_config, hci_source, hci_sink
                 )
             else:
-                random_address = (
-                    f"{random.randint(192,255):02X}"  # address is static random
-                )
-                for random_byte in random.sample(range(255), 5):
-                    random_address += f":{random_byte:02X}"
+                random_address = Address.generate_static_address()
                 self.append_to_log(f"Setting random address: {random_address}")
                 self.device = Device.with_hci(
                     'Bumble', random_address, hci_source, hci_sink
@@ -503,21 +500,9 @@
             self.show_error('not connected')
             return
 
-        # Discover all services, characteristics and descriptors
-        self.append_to_output('discovering services...')
-        await self.connected_peer.discover_services()
-        self.append_to_output(
-            f'found {len(self.connected_peer.services)} services,'
-            ' discovering characteristics...'
-        )
-        await self.connected_peer.discover_characteristics()
-        self.append_to_output('found characteristics, discovering descriptors...')
-        for service in self.connected_peer.services:
-            for characteristic in service.characteristics:
-                await self.connected_peer.discover_descriptors(characteristic)
-        self.append_to_output('discovery completed')
-
-        self.show_remote_services(self.connected_peer.services)
+        self.append_to_output('Service Discovery starting...')
+        await self.connected_peer.discover_all()
+        self.append_to_output('Service Discovery done!')
 
     async def discover_attributes(self):
         if not self.connected_peer:
diff --git a/apps/controller_info.py b/apps/controller_info.py
index 83ac3bb..7cf3332 100644
--- a/apps/controller_info.py
+++ b/apps/controller_info.py
@@ -27,7 +27,7 @@
 from bumble.core import name_or_number
 from bumble.hci import (
     map_null_terminated_utf8_string,
-    LeFeatureMask,
+    LeFeature,
     HCI_SUCCESS,
     HCI_VERSION_NAMES,
     LMP_VERSION_NAMES,
@@ -140,7 +140,7 @@
 
     print(color('LE Features:', 'yellow'))
     for feature in host.supported_le_features:
-        print(LeFeatureMask(feature).name)
+        print(f'  {LeFeature(feature).name}')
 
 
 # -----------------------------------------------------------------------------
@@ -224,7 +224,7 @@
         print()
         print(color('Supported Commands:', 'yellow'))
         for command in host.supported_commands:
-            print('  ', HCI_Command.command_name(command))
+            print(f'  {HCI_Command.command_name(command)}')
 
 
 # -----------------------------------------------------------------------------
diff --git a/apps/device_info.py b/apps/device_info.py
new file mode 100644
index 0000000..df18c65
--- /dev/null
+++ b/apps/device_info.py
@@ -0,0 +1,230 @@
+# 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 os
+import logging
+from typing import Callable, Iterable, Optional
+
+import click
+
+from bumble.core import ProtocolError
+from bumble.colors import color
+from bumble.device import Device, Peer
+from bumble.gatt import Service
+from bumble.profiles.device_information_service import DeviceInformationServiceProxy
+from bumble.profiles.battery_service import BatteryServiceProxy
+from bumble.profiles.gap import GenericAccessServiceProxy
+from bumble.profiles.tmap import TelephonyAndMediaAudioServiceProxy
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def try_show(function: Callable, *args, **kwargs) -> None:
+    try:
+        await function(*args, **kwargs)
+    except ProtocolError as error:
+        print(color('ERROR:', 'red'), error)
+
+
+# -----------------------------------------------------------------------------
+def show_services(services: Iterable[Service]) -> None:
+    for service in services:
+        print(color(str(service), 'cyan'))
+
+        for characteristic in service.characteristics:
+            print(color('  ' + str(characteristic), 'magenta'))
+
+
+# -----------------------------------------------------------------------------
+async def show_gap_information(
+    gap_service: GenericAccessServiceProxy,
+):
+    print(color('### Generic Access Profile', 'yellow'))
+
+    if gap_service.device_name:
+        print(
+            color(' Device Name:', 'green'),
+            await gap_service.device_name.read_value(),
+        )
+
+    if gap_service.appearance:
+        print(
+            color(' Appearance: ', 'green'),
+            await gap_service.appearance.read_value(),
+        )
+
+    print()
+
+
+# -----------------------------------------------------------------------------
+async def show_device_information(
+    device_information_service: DeviceInformationServiceProxy,
+):
+    print(color('### Device Information', 'yellow'))
+
+    if device_information_service.manufacturer_name:
+        print(
+            color('  Manufacturer Name:', 'green'),
+            await device_information_service.manufacturer_name.read_value(),
+        )
+
+    if device_information_service.model_number:
+        print(
+            color('  Model Number:     ', 'green'),
+            await device_information_service.model_number.read_value(),
+        )
+
+    if device_information_service.serial_number:
+        print(
+            color('  Serial Number:    ', 'green'),
+            await device_information_service.serial_number.read_value(),
+        )
+
+    if device_information_service.firmware_revision:
+        print(
+            color('  Firmware Revision:', 'green'),
+            await device_information_service.firmware_revision.read_value(),
+        )
+
+    print()
+
+
+# -----------------------------------------------------------------------------
+async def show_battery_level(
+    battery_service: BatteryServiceProxy,
+):
+    print(color('### Battery Information', 'yellow'))
+
+    if battery_service.battery_level:
+        print(
+            color('  Battery Level:', 'green'),
+            await battery_service.battery_level.read_value(),
+        )
+
+    print()
+
+
+# -----------------------------------------------------------------------------
+async def show_tmas(
+    tmas: TelephonyAndMediaAudioServiceProxy,
+):
+    print(color('### Telephony And Media Audio Service', 'yellow'))
+
+    if tmas.role:
+        print(
+            color('  Role:', 'green'),
+            await tmas.role.read_value(),
+        )
+
+    print()
+
+
+# -----------------------------------------------------------------------------
+async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
+    try:
+        # Discover all services
+        print(color('### Discovering Services and Characteristics', 'magenta'))
+        await peer.discover_services()
+        for service in peer.services:
+            await service.discover_characteristics()
+
+        print(color('=== Services ===', 'yellow'))
+        show_services(peer.services)
+        print()
+
+        if gap_service := peer.create_service_proxy(GenericAccessServiceProxy):
+            await try_show(show_gap_information, gap_service)
+
+        if device_information_service := peer.create_service_proxy(
+            DeviceInformationServiceProxy
+        ):
+            await try_show(show_device_information, device_information_service)
+
+        if battery_service := peer.create_service_proxy(BatteryServiceProxy):
+            await try_show(show_battery_level, battery_service)
+
+        if tmas := peer.create_service_proxy(TelephonyAndMediaAudioServiceProxy):
+            await try_show(show_tmas, tmas)
+
+        if done is not None:
+            done.set_result(None)
+    except asyncio.CancelledError:
+        print(color('!!! Operation canceled', 'red'))
+
+
+# -----------------------------------------------------------------------------
+async def async_main(device_config, encrypt, transport, address_or_name):
+    async with await open_transport_or_link(transport) as (hci_source, hci_sink):
+
+        # Create a device
+        if device_config:
+            device = Device.from_config_file_with_hci(
+                device_config, hci_source, hci_sink
+            )
+        else:
+            device = Device.with_hci(
+                'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
+            )
+        await device.power_on()
+
+        if address_or_name:
+            # Connect to the target peer
+            print(color('>>> Connecting...', 'green'))
+            connection = await device.connect(address_or_name)
+            print(color('>>> Connected', 'green'))
+
+            # Encrypt the connection if required
+            if encrypt:
+                print(color('+++ Encrypting connection...', 'blue'))
+                await connection.encrypt()
+                print(color('+++ Encryption established', 'blue'))
+
+            await show_device_info(Peer(connection), None)
+        else:
+            # Wait for a connection
+            done = asyncio.get_running_loop().create_future()
+            device.on(
+                'connection',
+                lambda connection: asyncio.create_task(
+                    show_device_info(Peer(connection), done)
+                ),
+            )
+            await device.start_advertising(auto_restart=True)
+
+            print(color('### Waiting for connection...', 'blue'))
+            await done
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]('--device-config', help='Device configuration', type=click.Path())
[email protected]('--encrypt', help='Encrypt the connection', is_flag=True, default=False)
[email protected]('transport')
[email protected]('address-or-name', required=False)
+def main(device_config, encrypt, transport, address_or_name):
+    """
+    Dump the GATT database on a remote device. If ADDRESS_OR_NAME is not specified,
+    wait for an incoming connection.
+    """
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(async_main(device_config, encrypt, transport, address_or_name))
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    main()
diff --git a/apps/gatt_dump.py b/apps/gatt_dump.py
index a3205c0..3b3e874 100644
--- a/apps/gatt_dump.py
+++ b/apps/gatt_dump.py
@@ -75,11 +75,15 @@
 
         if address_or_name:
             # Connect to the target peer
+            print(color('>>> Connecting...', 'green'))
             connection = await device.connect(address_or_name)
+            print(color('>>> Connected', 'green'))
 
             # Encrypt the connection if required
             if encrypt:
+                print(color('+++ Encrypting connection...', 'blue'))
                 await connection.encrypt()
+                print(color('+++ Encryption established', 'blue'))
 
             await dump_gatt_db(Peer(connection), None)
         else:
diff --git a/apps/lea_unicast/app.py b/apps/lea_unicast/app.py
index ae3b442..5885dab 100644
--- a/apps/lea_unicast/app.py
+++ b/apps/lea_unicast/app.py
@@ -33,7 +33,6 @@
 import wasmtime
 import wasmtime.loader
 import liblc3  # type: ignore
-import logging
 
 import click
 import aiohttp.web
@@ -43,7 +42,7 @@
 from bumble.colors import color
 from bumble.device import Device, DeviceConfiguration, AdvertisingParameters
 from bumble.transport import open_transport
-from bumble.profiles import bap
+from bumble.profiles import ascs, bap, pacs
 from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
 
 # -----------------------------------------------------------------------------
@@ -57,8 +56,8 @@
 DEFAULT_UI_PORT = 7654
 
 
-def _sink_pac_record() -> bap.PacRecord:
-    return bap.PacRecord(
+def _sink_pac_record() -> pacs.PacRecord:
+    return pacs.PacRecord(
         coding_format=CodingFormat(CodecID.LC3),
         codec_specific_capabilities=bap.CodecSpecificCapabilities(
             supported_sampling_frequencies=(
@@ -79,8 +78,8 @@
     )
 
 
-def _source_pac_record() -> bap.PacRecord:
-    return bap.PacRecord(
+def _source_pac_record() -> pacs.PacRecord:
+    return pacs.PacRecord(
         coding_format=CodingFormat(CodecID.LC3),
         codec_specific_capabilities=bap.CodecSpecificCapabilities(
             supported_sampling_frequencies=(
@@ -447,7 +446,7 @@
             )
 
             self.device.add_service(
-                bap.PublishedAudioCapabilitiesService(
+                pacs.PublishedAudioCapabilitiesService(
                     supported_source_context=bap.ContextType(0xFFFF),
                     available_source_context=bap.ContextType(0xFFFF),
                     supported_sink_context=bap.ContextType(0xFFFF),  # All context types
@@ -461,10 +460,10 @@
                 )
             )
 
-            ascs = bap.AudioStreamControlService(
+            ascs_service = ascs.AudioStreamControlService(
                 self.device, sink_ase_id=[1], source_ase_id=[2]
             )
-            self.device.add_service(ascs)
+            self.device.add_service(ascs_service)
 
             advertising_data = bytes(
                 AdvertisingData(
@@ -479,13 +478,13 @@
                         ),
                         (
                             AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
-                            bytes(bap.PublishedAudioCapabilitiesService.UUID),
+                            bytes(pacs.PublishedAudioCapabilitiesService.UUID),
                         ),
                     ]
                 )
             ) + bytes(bap.UnicastServerAdvertisingData())
 
-            def on_pdu(pdu: HCI_IsoDataPacket, ase: bap.AseStateMachine):
+            def on_pdu(pdu: HCI_IsoDataPacket, ase: ascs.AseStateMachine):
                 codec_config = ase.codec_specific_configuration
                 assert isinstance(codec_config, bap.CodecSpecificConfiguration)
                 pcm = decode(
@@ -495,12 +494,12 @@
                 )
                 self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
 
-            def on_ase_state_change(ase: bap.AseStateMachine) -> None:
-                if ase.state == bap.AseStateMachine.State.STREAMING:
+            def on_ase_state_change(ase: ascs.AseStateMachine) -> None:
+                if ase.state == ascs.AseStateMachine.State.STREAMING:
                     codec_config = ase.codec_specific_configuration
                     assert isinstance(codec_config, bap.CodecSpecificConfiguration)
                     assert ase.cis_link
-                    if ase.role == bap.AudioRole.SOURCE:
+                    if ase.role == ascs.AudioRole.SOURCE:
                         ase.cis_link.abort_on(
                             'disconnection',
                             lc3_source_task(
@@ -516,10 +515,10 @@
                         )
                     else:
                         ase.cis_link.sink = functools.partial(on_pdu, ase=ase)
-                elif ase.state == bap.AseStateMachine.State.CODEC_CONFIGURED:
+                elif ase.state == ascs.AseStateMachine.State.CODEC_CONFIGURED:
                     codec_config = ase.codec_specific_configuration
                     assert isinstance(codec_config, bap.CodecSpecificConfiguration)
-                    if ase.role == bap.AudioRole.SOURCE:
+                    if ase.role == ascs.AudioRole.SOURCE:
                         setup_encoders(
                             codec_config.sampling_frequency.hz,
                             codec_config.frame_duration.us,
@@ -532,7 +531,7 @@
                             codec_config.audio_channel_allocation.channel_count,
                         )
 
-            for ase in ascs.ase_state_machines.values():
+            for ase in ascs_service.ase_state_machines.values():
                 ase.on('state_change', functools.partial(on_ase_state_change, ase=ase))
 
             await self.device.power_on()
diff --git a/bumble/at.py b/bumble/at.py
index 78a4b08..ed9aeed 100644
--- a/bumble/at.py
+++ b/bumble/at.py
@@ -14,13 +14,19 @@
 
 from typing import List, Union
 
+from bumble import core
+
+
+class AtParsingError(core.InvalidPacketError):
+    """Error raised when parsing AT commands fails."""
+
 
 def tokenize_parameters(buffer: bytes) -> List[bytes]:
     """Split input parameters into tokens.
     Removes space characters outside of double quote blocks:
     T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
     are ignored [..], unless they are embedded in numeric or string constants"
-    Raises ValueError in case of invalid input string."""
+    Raises AtParsingError in case of invalid input string."""
 
     tokens = []
     in_quotes = False
@@ -43,11 +49,11 @@
                 token = bytearray()
             elif char == b'(':
                 if len(token) > 0:
-                    raise ValueError("open_paren following regular character")
+                    raise AtParsingError("open_paren following regular character")
                 tokens.append(char)
             elif char == b'"':
                 if len(token) > 0:
-                    raise ValueError("quote following regular character")
+                    raise AtParsingError("quote following regular character")
                 in_quotes = True
                 token.extend(char)
             else:
@@ -59,7 +65,7 @@
 
 def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
     """Parse the parameters using the comma and parenthesis separators.
-    Raises ValueError in case of invalid input string."""
+    Raises AtParsingError in case of invalid input string."""
 
     tokens = tokenize_parameters(buffer)
     accumulator: List[list] = [[]]
@@ -73,7 +79,7 @@
             accumulator.append([])
         elif token == b')':
             if len(accumulator) < 2:
-                raise ValueError("close_paren without matching open_paren")
+                raise AtParsingError("close_paren without matching open_paren")
             accumulator[-1].append(current)
             current = accumulator.pop()
         else:
@@ -81,5 +87,5 @@
 
     accumulator[-1].append(current)
     if len(accumulator) > 1:
-        raise ValueError("missing close_paren")
+        raise AtParsingError("missing close_paren")
     return accumulator[0]
diff --git a/bumble/avc.py b/bumble/avc.py
index 1d0a7dc..8e6b968 100644
--- a/bumble/avc.py
+++ b/bumble/avc.py
@@ -20,6 +20,7 @@
 import struct
 from typing import Dict, Type, Union, Tuple
 
+from bumble import core
 from bumble.utils import OpenIntEnum
 
 
@@ -88,7 +89,9 @@
             short_name = subclass.__name__.replace("ResponseFrame", "")
             category_class = ResponseFrame
         else:
-            raise ValueError(f"invalid subclass name {subclass.__name__}")
+            raise core.InvalidArgumentError(
+                f"invalid subclass name {subclass.__name__}"
+            )
 
         uppercase_indexes = [
             i for i in range(len(short_name)) if short_name[i].isupper()
@@ -106,7 +109,7 @@
     @staticmethod
     def from_bytes(data: bytes) -> Frame:
         if data[0] >> 4 != 0:
-            raise ValueError("first 4 bits must be 0s")
+            raise core.InvalidPacketError("first 4 bits must be 0s")
 
         ctype_or_response = data[0] & 0xF
         subunit_type = Frame.SubunitType(data[1] >> 3)
@@ -122,7 +125,7 @@
             # Extended to the next byte
             extension = data[2]
             if extension == 0:
-                raise ValueError("extended subunit ID value reserved")
+                raise core.InvalidPacketError("extended subunit ID value reserved")
             if extension == 0xFF:
                 subunit_id = 5 + 254 + data[3]
                 opcode_offset = 4
@@ -131,7 +134,7 @@
                 opcode_offset = 3
 
         elif subunit_id == 6:
-            raise ValueError("reserved subunit ID")
+            raise core.InvalidPacketError("reserved subunit ID")
 
         opcode = Frame.OperationCode(data[opcode_offset])
         operands = data[opcode_offset + 1 :]
@@ -448,7 +451,7 @@
         operation_data: bytes,
     ) -> None:
         if len(operation_data) > 255:
-            raise ValueError("operation data must be <= 255 bytes")
+            raise core.InvalidArgumentError("operation data must be <= 255 bytes")
         self.state_flag = state_flag
         self.operation_id = operation_id
         self.operation_data = operation_data
diff --git a/bumble/avctp.py b/bumble/avctp.py
index 2271324..6d70256 100644
--- a/bumble/avctp.py
+++ b/bumble/avctp.py
@@ -23,6 +23,7 @@
 
 from bumble.colors import color
 from bumble import avc
+from bumble import core
 from bumble import l2cap
 
 # -----------------------------------------------------------------------------
@@ -275,7 +276,7 @@
         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")
+            raise core.InvalidArgumentError("command handler not registered")
         del self.command_handlers[pid]
 
     def register_response_handler(
@@ -287,5 +288,5 @@
         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")
+            raise core.InvalidArgumentError("response handler not registered")
         del self.response_handlers[pid]
diff --git a/bumble/avdtp.py b/bumble/avdtp.py
index 713f7b7..85f7ede 100644
--- a/bumble/avdtp.py
+++ b/bumble/avdtp.py
@@ -43,6 +43,7 @@
     BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
     InvalidStateError,
     ProtocolError,
+    InvalidArgumentError,
     name_or_number,
 )
 from .a2dp import (
@@ -700,7 +701,7 @@
             signal_identifier_str = name[:-7]
             message_type = Message.MessageType.RESPONSE_REJECT
         else:
-            raise ValueError('invalid class name')
+            raise InvalidArgumentError('invalid class name')
 
         subclass.message_type = message_type
 
@@ -2162,6 +2163,9 @@
     def on_abort_command(self):
         self.emit('abort')
 
+    def on_delayreport_command(self, delay: int):
+        self.emit('delay_report', delay)
+
     def on_rtp_channel_open(self):
         self.emit('rtp_channel_open')
 
diff --git a/bumble/avrcp.py b/bumble/avrcp.py
index 11f4eff..e06a5a6 100644
--- a/bumble/avrcp.py
+++ b/bumble/avrcp.py
@@ -55,6 +55,7 @@
 )
 from bumble.utils import AsyncRunner, OpenIntEnum
 from bumble.core import (
+    InvalidArgumentError,
     ProtocolError,
     BT_L2CAP_PROTOCOL_ID,
     BT_AVCTP_PROTOCOL_ID,
@@ -1411,7 +1412,7 @@
     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")
+            raise InvalidArgumentError("identifier must be 8 bytes")
         self.notify_event(TrackChangedEvent(identifier))
 
     def notify_playback_position_changed(self, position: int) -> None:
diff --git a/bumble/codecs.py b/bumble/codecs.py
index 1d7ae82..cfb3cad 100644
--- a/bumble/codecs.py
+++ b/bumble/codecs.py
@@ -18,6 +18,8 @@
 from __future__ import annotations
 from dataclasses import dataclass
 
+from bumble import core
+
 
 # -----------------------------------------------------------------------------
 class BitReader:
@@ -40,7 +42,7 @@
         """ "Read up to 32 bits."""
 
         if bits > 32:
-            raise ValueError('maximum read size is 32')
+            raise core.InvalidArgumentError('maximum read size is 32')
 
         if self.bits_cached >= bits:
             # We have enough bits.
@@ -53,7 +55,7 @@
         feed_size = len(feed_bytes)
         feed_int = int.from_bytes(feed_bytes, byteorder='big')
         if 8 * feed_size + self.bits_cached < bits:
-            raise ValueError('trying to read past the data')
+            raise core.InvalidArgumentError('trying to read past the data')
         self.byte_position += feed_size
 
         # Combine the new cache and the old cache
@@ -68,7 +70,7 @@
 
     def read_bytes(self, count: int):
         if self.bit_position + 8 * count > 8 * len(self.data):
-            raise ValueError('not enough data')
+            raise core.InvalidArgumentError('not enough data')
 
         if self.bit_position % 8:
             # Not byte aligned
@@ -113,7 +115,7 @@
 
     @staticmethod
     def program_config_element(reader: BitReader):
-        raise ValueError('program_config_element not supported')
+        raise core.InvalidPacketError('program_config_element not supported')
 
     @dataclass
     class GASpecificConfig:
@@ -140,7 +142,7 @@
                     aac_spectral_data_resilience_flags = reader.read(1)
                 extension_flag_3 = reader.read(1)
                 if extension_flag_3 == 1:
-                    raise ValueError('extensionFlag3 == 1 not supported')
+                    raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
 
     @staticmethod
     def audio_object_type(reader: BitReader):
@@ -216,7 +218,7 @@
                     reader, self.channel_configuration, self.audio_object_type
                 )
             else:
-                raise ValueError(
+                raise core.InvalidPacketError(
                     f'audioObjectType {self.audio_object_type} not supported'
                 )
 
@@ -260,7 +262,7 @@
             else:
                 audio_mux_version_a = 0
             if audio_mux_version_a != 0:
-                raise ValueError('audioMuxVersionA != 0 not supported')
+                raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
             if audio_mux_version == 1:
                 tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
             stream_cnt = 0
@@ -268,10 +270,10 @@
             num_sub_frames = reader.read(6)
             num_program = reader.read(4)
             if num_program != 0:
-                raise ValueError('num_program != 0 not supported')
+                raise core.InvalidPacketError('num_program != 0 not supported')
             num_layer = reader.read(3)
             if num_layer != 0:
-                raise ValueError('num_layer != 0 not supported')
+                raise core.InvalidPacketError('num_layer != 0 not supported')
             if audio_mux_version == 0:
                 self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
                     reader
@@ -284,7 +286,7 @@
                 )
                 audio_specific_config_len = reader.bit_position - marker
                 if asc_len < audio_specific_config_len:
-                    raise ValueError('audio_specific_config_len > asc_len')
+                    raise core.InvalidPacketError('audio_specific_config_len > asc_len')
                 asc_len -= audio_specific_config_len
                 reader.skip(asc_len)
             frame_length_type = reader.read(3)
@@ -293,7 +295,9 @@
             elif frame_length_type == 1:
                 frame_length = reader.read(9)
             else:
-                raise ValueError(f'frame_length_type {frame_length_type} not supported')
+                raise core.InvalidPacketError(
+                    f'frame_length_type {frame_length_type} not supported'
+                )
 
             self.other_data_present = reader.read(1)
             if self.other_data_present:
@@ -318,12 +322,12 @@
 
         def __init__(self, reader: BitReader, mux_config_present: int):
             if mux_config_present == 0:
-                raise ValueError('muxConfigPresent == 0 not supported')
+                raise core.InvalidPacketError('muxConfigPresent == 0 not supported')
 
             # AudioMuxElement - ISO/EIC 14496-3 Table 1.41
             use_same_stream_mux = reader.read(1)
             if use_same_stream_mux:
-                raise ValueError('useSameStreamMux == 1 not supported')
+                raise core.InvalidPacketError('useSameStreamMux == 1 not supported')
             self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
 
             # We only support:
diff --git a/bumble/colors.py b/bumble/colors.py
index 2813cfe..37ce03a 100644
--- a/bumble/colors.py
+++ b/bumble/colors.py
@@ -16,6 +16,10 @@
 from typing import List, Optional, Union
 
 
+class ColorError(ValueError):
+    """Error raised when a color spec is invalid."""
+
+
 # ANSI color names. There is also a "default"
 COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
 
@@ -52,7 +56,7 @@
     elif isinstance(spec, int) and 0 <= spec <= 255:
         return _join(base + 8, 5, spec)
     else:
-        raise ValueError('Invalid color spec "%s"' % spec)
+        raise ColorError('Invalid color spec "%s"' % spec)
 
 
 def color(
@@ -72,7 +76,7 @@
             if style_part in STYLES:
                 codes.append(STYLES.index(style_part))
             else:
-                raise ValueError('Invalid style "%s"' % style_part)
+                raise ColorError('Invalid style "%s"' % style_part)
 
     if codes:
         return '\x1b[{0}m{1}\x1b[0m'.format(_join(*codes), s)
diff --git a/bumble/core.py b/bumble/core.py
index dce721a..f6d42dd 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -16,11 +16,14 @@
 # Imports
 # -----------------------------------------------------------------------------
 from __future__ import annotations
+import dataclasses
 import enum
 import struct
 from typing import List, Optional, Tuple, Union, cast, Dict
+from typing_extensions import Self
 
-from .company_ids import COMPANY_IDENTIFIERS
+from bumble.company_ids import COMPANY_IDENTIFIERS
+from bumble.utils import OpenIntEnum
 
 
 # -----------------------------------------------------------------------------
@@ -76,7 +79,13 @@
 # -----------------------------------------------------------------------------
 # Exceptions
 # -----------------------------------------------------------------------------
-class BaseError(Exception):
+
+
+class BaseBumbleError(Exception):
+    """Base Error raised by Bumble."""
+
+
+class BaseError(BaseBumbleError):
     """Base class for errors with an error code, error name and namespace"""
 
     def __init__(
@@ -115,18 +124,42 @@
     """Protocol Error"""
 
 
-class TimeoutError(Exception):  # pylint: disable=redefined-builtin
+class TimeoutError(BaseBumbleError):  # pylint: disable=redefined-builtin
     """Timeout Error"""
 
 
-class CommandTimeoutError(Exception):
+class CommandTimeoutError(BaseBumbleError):
     """Command Timeout Error"""
 
 
-class InvalidStateError(Exception):
+class InvalidStateError(BaseBumbleError):
     """Invalid State Error"""
 
 
+class InvalidArgumentError(BaseBumbleError, ValueError):
+    """Invalid Argument Error"""
+
+
+class InvalidPacketError(BaseBumbleError, ValueError):
+    """Invalid Packet Error"""
+
+
+class InvalidOperationError(BaseBumbleError, RuntimeError):
+    """Invalid Operation Error"""
+
+
+class NotSupportedError(BaseBumbleError, RuntimeError):
+    """Not Supported"""
+
+
+class OutOfResourcesError(BaseBumbleError, RuntimeError):
+    """Out of Resources Error"""
+
+
+class UnreachableError(BaseBumbleError):
+    """The code path raising this error should be unreachable."""
+
+
 class ConnectionError(BaseError):  # pylint: disable=redefined-builtin
     """Connection Error"""
 
@@ -185,12 +218,12 @@
                     or uuid_str_or_int[18] != '-'
                     or uuid_str_or_int[23] != '-'
                 ):
-                    raise ValueError('invalid UUID format')
+                    raise InvalidArgumentError('invalid UUID format')
                 uuid_str = uuid_str_or_int.replace('-', '')
             else:
                 uuid_str = uuid_str_or_int
             if len(uuid_str) != 32 and len(uuid_str) != 8 and len(uuid_str) != 4:
-                raise ValueError(f"invalid UUID format: {uuid_str}")
+                raise InvalidArgumentError(f"invalid UUID format: {uuid_str}")
             self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str)))
         self.name = name
 
@@ -215,7 +248,7 @@
 
             return self.register()
 
-        raise ValueError('only 2, 4 and 16 bytes are allowed')
+        raise InvalidArgumentError('only 2, 4 and 16 bytes are allowed')
 
     @classmethod
     def from_16_bits(cls, uuid_16: int, name: Optional[str] = None) -> UUID:
@@ -693,10 +726,568 @@
 
 
 # -----------------------------------------------------------------------------
+# Appearance
+# -----------------------------------------------------------------------------
+class Appearance:
+    class Category(OpenIntEnum):
+        UNKNOWN = 0x0000
+        PHONE = 0x0001
+        COMPUTER = 0x0002
+        WATCH = 0x0003
+        CLOCK = 0x0004
+        DISPLAY = 0x0005
+        REMOTE_CONTROL = 0x0006
+        EYE_GLASSES = 0x0007
+        TAG = 0x0008
+        KEYRING = 0x0009
+        MEDIA_PLAYER = 0x000A
+        BARCODE_SCANNER = 0x000B
+        THERMOMETER = 0x000C
+        HEART_RATE_SENSOR = 0x000D
+        BLOOD_PRESSURE = 0x000E
+        HUMAN_INTERFACE_DEVICE = 0x000F
+        GLUCOSE_METER = 0x0010
+        RUNNING_WALKING_SENSOR = 0x0011
+        CYCLING = 0x0012
+        CONTROL_DEVICE = 0x0013
+        NETWORK_DEVICE = 0x0014
+        SENSOR = 0x0015
+        LIGHT_FIXTURES = 0x0016
+        FAN = 0x0017
+        HVAC = 0x0018
+        AIR_CONDITIONING = 0x0019
+        HUMIDIFIER = 0x001A
+        HEATING = 0x001B
+        ACCESS_CONTROL = 0x001C
+        MOTORIZED_DEVICE = 0x001D
+        POWER_DEVICE = 0x001E
+        LIGHT_SOURCE = 0x001F
+        WINDOW_COVERING = 0x0020
+        AUDIO_SINK = 0x0021
+        AUDIO_SOURCE = 0x0022
+        MOTORIZED_VEHICLE = 0x0023
+        DOMESTIC_APPLIANCE = 0x0024
+        WEARABLE_AUDIO_DEVICE = 0x0025
+        AIRCRAFT = 0x0026
+        AV_EQUIPMENT = 0x0027
+        DISPLAY_EQUIPMENT = 0x0028
+        HEARING_AID = 0x0029
+        GAMING = 0x002A
+        SIGNAGE = 0x002B
+        PULSE_OXIMETER = 0x0031
+        WEIGHT_SCALE = 0x0032
+        PERSONAL_MOBILITY_DEVICE = 0x0033
+        CONTINUOUS_GLUCOSE_MONITOR = 0x0034
+        INSULIN_PUMP = 0x0035
+        MEDICATION_DELIVERY = 0x0036
+        SPIROMETER = 0x0037
+        OUTDOOR_SPORTS_ACTIVITY = 0x0051
+
+    class UnknownSubcategory(OpenIntEnum):
+        GENERIC_UNKNOWN = 0x00
+
+    class PhoneSubcategory(OpenIntEnum):
+        GENERIC_PHONE = 0x00
+
+    class ComputerSubcategory(OpenIntEnum):
+        GENERIC_COMPUTER = 0x00
+        DESKTOP_WORKSTATION = 0x01
+        SERVER_CLASS_COMPUTER = 0x02
+        LAPTOP = 0x03
+        HANDHELD_PC_PDA = 0x04
+        PALM_SIZE_PC_PDA = 0x05
+        WEARABLE_COMPUTER = 0x06
+        TABLET = 0x07
+        DOCKING_STATION = 0x08
+        ALL_IN_ONE = 0x09
+        BLADE_SERVER = 0x0A
+        CONVERTIBLE = 0x0B
+        DETACHABLE = 0x0C
+        IOT_GATEWAY = 0x0D
+        MINI_PC = 0x0E
+        STICK_PC = 0x0F
+
+    class WatchSubcategory(OpenIntEnum):
+        GENENERIC_WATCH = 0x00
+        SPORTS_WATCH = 0x01
+        SMARTWATCH = 0x02
+
+    class ClockSubcategory(OpenIntEnum):
+        GENERIC_CLOCK = 0x00
+
+    class DisplaySubcategory(OpenIntEnum):
+        GENERIC_DISPLAY = 0x00
+
+    class RemoteControlSubcategory(OpenIntEnum):
+        GENERIC_REMOTE_CONTROL = 0x00
+
+    class EyeglassesSubcategory(OpenIntEnum):
+        GENERIC_EYEGLASSES = 0x00
+
+    class TagSubcategory(OpenIntEnum):
+        GENERIC_TAG = 0x00
+
+    class KeyringSubcategory(OpenIntEnum):
+        GENERIC_KEYRING = 0x00
+
+    class MediaPlayerSubcategory(OpenIntEnum):
+        GENERIC_MEDIA_PLAYER = 0x00
+
+    class BarcodeScannerSubcategory(OpenIntEnum):
+        GENERIC_BARCODE_SCANNER = 0x00
+
+    class ThermometerSubcategory(OpenIntEnum):
+        GENERIC_THERMOMETER = 0x00
+        EAR_THERMOMETER = 0x01
+
+    class HeartRateSensorSubcategory(OpenIntEnum):
+        GENERIC_HEART_RATE_SENSOR = 0x00
+        HEART_RATE_BELT = 0x01
+
+    class BloodPressureSubcategory(OpenIntEnum):
+        GENERIC_BLOOD_PRESSURE = 0x00
+        ARM_BLOOD_PRESSURE = 0x01
+        WRIST_BLOOD_PRESSURE = 0x02
+
+    class HumanInterfaceDeviceSubcategory(OpenIntEnum):
+        GENERIC_HUMAN_INTERFACE_DEVICE = 0x00
+        KEYBOARD = 0x01
+        MOUSE = 0x02
+        JOYSTICK = 0x03
+        GAMEPAD = 0x04
+        DIGITIZER_TABLET = 0x05
+        CARD_READER = 0x06
+        DIGITAL_PEN = 0x07
+        BARCODE_SCANNER = 0x08
+        TOUCHPAD = 0x09
+        PRESENTATION_REMOTE = 0x0A
+
+    class GlucoseMeterSubcategory(OpenIntEnum):
+        GENERIC_GLUCOSE_METER = 0x00
+
+    class RunningWalkingSensorSubcategory(OpenIntEnum):
+        GENERIC_RUNNING_WALKING_SENSOR = 0x00
+        IN_SHOE_RUNNING_WALKING_SENSOR = 0x01
+        ON_SHOW_RUNNING_WALKING_SENSOR = 0x02
+        ON_HIP_RUNNING_WALKING_SENSOR = 0x03
+
+    class CyclingSubcategory(OpenIntEnum):
+        GENERIC_CYCLING = 0x00
+        CYCLING_COMPUTER = 0x01
+        SPEED_SENSOR = 0x02
+        CADENCE_SENSOR = 0x03
+        POWER_SENSOR = 0x04
+        SPEED_AND_CADENCE_SENSOR = 0x05
+
+    class ControlDeviceSubcategory(OpenIntEnum):
+        GENERIC_CONTROL_DEVICE = 0x00
+        SWITCH = 0x01
+        MULTI_SWITCH = 0x02
+        BUTTON = 0x03
+        SLIDER = 0x04
+        ROTARY_SWITCH = 0x05
+        TOUCH_PANEL = 0x06
+        SINGLE_SWITCH = 0x07
+        DOUBLE_SWITCH = 0x08
+        TRIPLE_SWITCH = 0x09
+        BATTERY_SWITCH = 0x0A
+        ENERGY_HARVESTING_SWITCH = 0x0B
+        PUSH_BUTTON = 0x0C
+
+    class NetworkDeviceSubcategory(OpenIntEnum):
+        GENERIC_NETWORK_DEVICE = 0x00
+        ACCESS_POINT = 0x01
+        MESH_DEVICE = 0x02
+        MESH_NETWORK_PROXY = 0x03
+
+    class SensorSubcategory(OpenIntEnum):
+        GENERIC_SENSOR = 0x00
+        MOTION_SENSOR = 0x01
+        AIR_QUALITY_SENSOR = 0x02
+        TEMPERATURE_SENSOR = 0x03
+        HUMIDITY_SENSOR = 0x04
+        LEAK_SENSOR = 0x05
+        SMOKE_SENSOR = 0x06
+        OCCUPANCY_SENSOR = 0x07
+        CONTACT_SENSOR = 0x08
+        CARBON_MONOXIDE_SENSOR = 0x09
+        CARBON_DIOXIDE_SENSOR = 0x0A
+        AMBIENT_LIGHT_SENSOR = 0x0B
+        ENERGY_SENSOR = 0x0C
+        COLOR_LIGHT_SENSOR = 0x0D
+        RAIN_SENSOR = 0x0E
+        FIRE_SENSOR = 0x0F
+        WIND_SENSOR = 0x10
+        PROXIMITY_SENSOR = 0x11
+        MULTI_SENSOR = 0x12
+        FLUSH_MOUNTED_SENSOR = 0x13
+        CEILING_MOUNTED_SENSOR = 0x14
+        WALL_MOUNTED_SENSOR = 0x15
+        MULTISENSOR = 0x16
+        ENERGY_METER = 0x17
+        FLAME_DETECTOR = 0x18
+        VEHICLE_TIRE_PRESSURE_SENSOR = 0x19
+
+    class LightFixturesSubcategory(OpenIntEnum):
+        GENERIC_LIGHT_FIXTURES = 0x00
+        WALL_LIGHT = 0x01
+        CEILING_LIGHT = 0x02
+        FLOOR_LIGHT = 0x03
+        CABINET_LIGHT = 0x04
+        DESK_LIGHT = 0x05
+        TROFFER_LIGHT = 0x06
+        PENDANT_LIGHT = 0x07
+        IN_GROUND_LIGHT = 0x08
+        FLOOD_LIGHT = 0x09
+        UNDERWATER_LIGHT = 0x0A
+        BOLLARD_WITH_LIGHT = 0x0B
+        PATHWAY_LIGHT = 0x0C
+        GARDEN_LIGHT = 0x0D
+        POLE_TOP_LIGHT = 0x0E
+        SPOTLIGHT = 0x0F
+        LINEAR_LIGHT = 0x10
+        STREET_LIGHT = 0x11
+        SHELVES_LIGHT = 0x12
+        BAY_LIGHT = 0x013
+        EMERGENCY_EXIT_LIGHT = 0x14
+        LIGHT_CONTROLLER = 0x15
+        LIGHT_DRIVER = 0x16
+        BULB = 0x17
+        LOW_BAY_LIGHT = 0x18
+        HIGH_BAY_LIGHT = 0x19
+
+    class FanSubcategory(OpenIntEnum):
+        GENERIC_FAN = 0x00
+        CEILING_FAN = 0x01
+        AXIAL_FAN = 0x02
+        EXHAUST_FAN = 0x03
+        PEDESTAL_FAN = 0x04
+        DESK_FAN = 0x05
+        WALL_FAN = 0x06
+
+    class HvacSubcategory(OpenIntEnum):
+        GENERIC_HVAC = 0x00
+        THERMOSTAT = 0x01
+        HUMIDIFIER = 0x02
+        DEHUMIDIFIER = 0x03
+        HEATER = 0x04
+        RADIATOR = 0x05
+        BOILER = 0x06
+        HEAT_PUMP = 0x07
+        INFRARED_HEATER = 0x08
+        RADIANT_PANEL_HEATER = 0x09
+        FAN_HEATER = 0x0A
+        AIR_CURTAIN = 0x0B
+
+    class AirConditioningSubcategory(OpenIntEnum):
+        GENERIC_AIR_CONDITIONING = 0x00
+
+    class HumidifierSubcategory(OpenIntEnum):
+        GENERIC_HUMIDIFIER = 0x00
+
+    class HeatingSubcategory(OpenIntEnum):
+        GENERIC_HEATING = 0x00
+        RADIATOR = 0x01
+        BOILER = 0x02
+        HEAT_PUMP = 0x03
+        INFRARED_HEATER = 0x04
+        RADIANT_PANEL_HEATER = 0x05
+        FAN_HEATER = 0x06
+        AIR_CURTAIN = 0x07
+
+    class AccessControlSubcategory(OpenIntEnum):
+        GENERIC_ACCESS_CONTROL = 0x00
+        ACCESS_DOOR = 0x01
+        GARAGE_DOOR = 0x02
+        EMERGENCY_EXIT_DOOR = 0x03
+        ACCESS_LOCK = 0x04
+        ELEVATOR = 0x05
+        WINDOW = 0x06
+        ENTRANCE_GATE = 0x07
+        DOOR_LOCK = 0x08
+        LOCKER = 0x09
+
+    class MotorizedDeviceSubcategory(OpenIntEnum):
+        GENERIC_MOTORIZED_DEVICE = 0x00
+        MOTORIZED_GATE = 0x01
+        AWNING = 0x02
+        BLINDS_OR_SHADES = 0x03
+        CURTAINS = 0x04
+        SCREEN = 0x05
+
+    class PowerDeviceSubcategory(OpenIntEnum):
+        GENERIC_POWER_DEVICE = 0x00
+        POWER_OUTLET = 0x01
+        POWER_STRIP = 0x02
+        PLUG = 0x03
+        POWER_SUPPLY = 0x04
+        LED_DRIVER = 0x05
+        FLUORESCENT_LAMP_GEAR = 0x06
+        HID_LAMP_GEAR = 0x07
+        CHARGE_CASE = 0x08
+        POWER_BANK = 0x09
+
+    class LightSourceSubcategory(OpenIntEnum):
+        GENERIC_LIGHT_SOURCE = 0x00
+        INCANDESCENT_LIGHT_BULB = 0x01
+        LED_LAMP = 0x02
+        HID_LAMP = 0x03
+        FLUORESCENT_LAMP = 0x04
+        LED_ARRAY = 0x05
+        MULTI_COLOR_LED_ARRAY = 0x06
+        LOW_VOLTAGE_HALOGEN = 0x07
+        ORGANIC_LIGHT_EMITTING_DIODE = 0x08
+
+    class WindowCoveringSubcategory(OpenIntEnum):
+        GENERIC_WINDOW_COVERING = 0x00
+        WINDOW_SHADES = 0x01
+        WINDOW_BLINDS = 0x02
+        WINDOW_AWNING = 0x03
+        WINDOW_CURTAIN = 0x04
+        EXTERIOR_SHUTTER = 0x05
+        EXTERIOR_SCREEN = 0x06
+
+    class AudioSinkSubcategory(OpenIntEnum):
+        GENERIC_AUDIO_SINK = 0x00
+        STANDALONE_SPEAKER = 0x01
+        SOUNDBAR = 0x02
+        BOOKSHELF_SPEAKER = 0x03
+        STANDMOUNTED_SPEAKER = 0x04
+        SPEAKERPHONE = 0x05
+
+    class AudioSourceSubcategory(OpenIntEnum):
+        GENERIC_AUDIO_SOURCE = 0x00
+        MICROPHONE = 0x01
+        ALARM = 0x02
+        BELL = 0x03
+        HORN = 0x04
+        BROADCASTING_DEVICE = 0x05
+        SERVICE_DESK = 0x06
+        KIOSK = 0x07
+        BROADCASTING_ROOM = 0x08
+        AUDITORIUM = 0x09
+
+    class MotorizedVehicleSubcategory(OpenIntEnum):
+        GENERIC_MOTORIZED_VEHICLE = 0x00
+        CAR = 0x01
+        LARGE_GOODS_VEHICLE = 0x02
+        TWO_WHEELED_VEHICLE = 0x03
+        MOTORBIKE = 0x04
+        SCOOTER = 0x05
+        MOPED = 0x06
+        THREE_WHEELED_VEHICLE = 0x07
+        LIGHT_VEHICLE = 0x08
+        QUAD_BIKE = 0x09
+        MINIBUS = 0x0A
+        BUS = 0x0B
+        TROLLEY = 0x0C
+        AGRICULTURAL_VEHICLE = 0x0D
+        CAMPER_CARAVAN = 0x0E
+        RECREATIONAL_VEHICLE_MOTOR_HOME = 0x0F
+
+    class DomesticApplianceSubcategory(OpenIntEnum):
+        GENERIC_DOMESTIC_APPLIANCE = 0x00
+        REFRIGERATOR = 0x01
+        FREEZER = 0x02
+        OVEN = 0x03
+        MICROWAVE = 0x04
+        TOASTER = 0x05
+        WASHING_MACHINE = 0x06
+        DRYER = 0x07
+        COFFEE_MAKER = 0x08
+        CLOTHES_IRON = 0x09
+        CURLING_IRON = 0x0A
+        HAIR_DRYER = 0x0B
+        VACUUM_CLEANER = 0x0C
+        ROBOTIC_VACUUM_CLEANER = 0x0D
+        RICE_COOKER = 0x0E
+        CLOTHES_STEAMER = 0x0F
+
+    class WearableAudioDeviceSubcategory(OpenIntEnum):
+        GENERIC_WEARABLE_AUDIO_DEVICE = 0x00
+        EARBUD = 0x01
+        HEADSET = 0x02
+        HEADPHONES = 0x03
+        NECK_BAND = 0x04
+
+    class AircraftSubcategory(OpenIntEnum):
+        GENERIC_AIRCRAFT = 0x00
+        LIGHT_AIRCRAFT = 0x01
+        MICROLIGHT = 0x02
+        PARAGLIDER = 0x03
+        LARGE_PASSENGER_AIRCRAFT = 0x04
+
+    class AvEquipmentSubcategory(OpenIntEnum):
+        GENERIC_AV_EQUIPMENT = 0x00
+        AMPLIFIER = 0x01
+        RECEIVER = 0x02
+        RADIO = 0x03
+        TUNER = 0x04
+        TURNTABLE = 0x05
+        CD_PLAYER = 0x06
+        DVD_PLAYER = 0x07
+        BLUERAY_PLAYER = 0x08
+        OPTICAL_DISC_PLAYER = 0x09
+        SET_TOP_BOX = 0x0A
+
+    class DisplayEquipmentSubcategory(OpenIntEnum):
+        GENERIC_DISPLAY_EQUIPMENT = 0x00
+        TELEVISION = 0x01
+        MONITOR = 0x02
+        PROJECTOR = 0x03
+
+    class HearingAidSubcategory(OpenIntEnum):
+        GENERIC_HEARING_AID = 0x00
+        IN_EAR_HEARING_AID = 0x01
+        BEHIND_EAR_HEARING_AID = 0x02
+        COCHLEAR_IMPLANT = 0x03
+
+    class GamingSubcategory(OpenIntEnum):
+        GENERIC_GAMING = 0x00
+        HOME_VIDEO_GAME_CONSOLE = 0x01
+        PORTABLE_HANDHELD_CONSOLE = 0x02
+
+    class SignageSubcategory(OpenIntEnum):
+        GENERIC_SIGNAGE = 0x00
+        DIGITAL_SIGNAGE = 0x01
+        ELECTRONIC_LABEL = 0x02
+
+    class PulseOximeterSubcategory(OpenIntEnum):
+        GENERIC_PULSE_OXIMETER = 0x00
+        FINGERTIP_PULSE_OXIMETER = 0x01
+        WRIST_WORN_PULSE_OXIMETER = 0x02
+
+    class WeightScaleSubcategory(OpenIntEnum):
+        GENERIC_WEIGHT_SCALE = 0x00
+
+    class PersonalMobilityDeviceSubcategory(OpenIntEnum):
+        GENERIC_PERSONAL_MOBILITY_DEVICE = 0x00
+        POWERED_WHEELCHAIR = 0x01
+        MOBILITY_SCOOTER = 0x02
+
+    class ContinuousGlucoseMonitorSubcategory(OpenIntEnum):
+        GENERIC_CONTINUOUS_GLUCOSE_MONITOR = 0x00
+
+    class InsulinPumpSubcategory(OpenIntEnum):
+        GENERIC_INSULIN_PUMP = 0x00
+        INSULIN_PUMP_DURABLE_PUMP = 0x01
+        INSULIN_PUMP_PATCH_PUMP = 0x02
+        INSULIN_PEN = 0x03
+
+    class MedicationDeliverySubcategory(OpenIntEnum):
+        GENERIC_MEDICATION_DELIVERY = 0x00
+
+    class SpirometerSubcategory(OpenIntEnum):
+        GENERIC_SPIROMETER = 0x00
+        HANDHELD_SPIROMETER = 0x01
+
+    class OutdoorSportsActivitySubcategory(OpenIntEnum):
+        GENERIC_OUTDOOR_SPORTS_ACTIVITY = 0x00
+        LOCATION_DISPLAY = 0x01
+        LOCATION_AND_NAVIGATION_DISPLAY = 0x02
+        LOCATION_POD = 0x03
+        LOCATION_AND_NAVIGATION_POD = 0x04
+
+    class _OpenSubcategory(OpenIntEnum):
+        GENERIC = 0x00
+
+    SUBCATEGORY_CLASSES = {
+        Category.UNKNOWN: UnknownSubcategory,
+        Category.PHONE: PhoneSubcategory,
+        Category.COMPUTER: ComputerSubcategory,
+        Category.WATCH: WatchSubcategory,
+        Category.CLOCK: ClockSubcategory,
+        Category.DISPLAY: DisplaySubcategory,
+        Category.REMOTE_CONTROL: RemoteControlSubcategory,
+        Category.EYE_GLASSES: EyeglassesSubcategory,
+        Category.TAG: TagSubcategory,
+        Category.KEYRING: KeyringSubcategory,
+        Category.MEDIA_PLAYER: MediaPlayerSubcategory,
+        Category.BARCODE_SCANNER: BarcodeScannerSubcategory,
+        Category.THERMOMETER: ThermometerSubcategory,
+        Category.HEART_RATE_SENSOR: HeartRateSensorSubcategory,
+        Category.BLOOD_PRESSURE: BloodPressureSubcategory,
+        Category.HUMAN_INTERFACE_DEVICE: HumanInterfaceDeviceSubcategory,
+        Category.GLUCOSE_METER: GlucoseMeterSubcategory,
+        Category.RUNNING_WALKING_SENSOR: RunningWalkingSensorSubcategory,
+        Category.CYCLING: CyclingSubcategory,
+        Category.CONTROL_DEVICE: ControlDeviceSubcategory,
+        Category.NETWORK_DEVICE: NetworkDeviceSubcategory,
+        Category.SENSOR: SensorSubcategory,
+        Category.LIGHT_FIXTURES: LightFixturesSubcategory,
+        Category.FAN: FanSubcategory,
+        Category.HVAC: HvacSubcategory,
+        Category.AIR_CONDITIONING: AirConditioningSubcategory,
+        Category.HUMIDIFIER: HumidifierSubcategory,
+        Category.HEATING: HeatingSubcategory,
+        Category.ACCESS_CONTROL: AccessControlSubcategory,
+        Category.MOTORIZED_DEVICE: MotorizedDeviceSubcategory,
+        Category.POWER_DEVICE: PowerDeviceSubcategory,
+        Category.LIGHT_SOURCE: LightSourceSubcategory,
+        Category.WINDOW_COVERING: WindowCoveringSubcategory,
+        Category.AUDIO_SINK: AudioSinkSubcategory,
+        Category.AUDIO_SOURCE: AudioSourceSubcategory,
+        Category.MOTORIZED_VEHICLE: MotorizedVehicleSubcategory,
+        Category.DOMESTIC_APPLIANCE: DomesticApplianceSubcategory,
+        Category.WEARABLE_AUDIO_DEVICE: WearableAudioDeviceSubcategory,
+        Category.AIRCRAFT: AircraftSubcategory,
+        Category.AV_EQUIPMENT: AvEquipmentSubcategory,
+        Category.DISPLAY_EQUIPMENT: DisplayEquipmentSubcategory,
+        Category.HEARING_AID: HearingAidSubcategory,
+        Category.GAMING: GamingSubcategory,
+        Category.SIGNAGE: SignageSubcategory,
+        Category.PULSE_OXIMETER: PulseOximeterSubcategory,
+        Category.WEIGHT_SCALE: WeightScaleSubcategory,
+        Category.PERSONAL_MOBILITY_DEVICE: PersonalMobilityDeviceSubcategory,
+        Category.CONTINUOUS_GLUCOSE_MONITOR: ContinuousGlucoseMonitorSubcategory,
+        Category.INSULIN_PUMP: InsulinPumpSubcategory,
+        Category.MEDICATION_DELIVERY: MedicationDeliverySubcategory,
+        Category.SPIROMETER: SpirometerSubcategory,
+        Category.OUTDOOR_SPORTS_ACTIVITY: OutdoorSportsActivitySubcategory,
+    }
+
+    category: Category
+    subcategory: enum.IntEnum
+
+    @classmethod
+    def from_int(cls, appearance: int) -> Self:
+        category = cls.Category(appearance >> 6)
+        return cls(category, appearance & 0x3F)
+
+    def __init__(self, category: Category, subcategory: int) -> None:
+        self.category = category
+        if subcategory_class := self.SUBCATEGORY_CLASSES.get(category):
+            self.subcategory = subcategory_class(subcategory)
+        else:
+            self.subcategory = self._OpenSubcategory(subcategory)
+
+    def __int__(self) -> int:
+        return self.category << 6 | self.subcategory
+
+    def __repr__(self) -> str:
+        return (
+            'Appearance('
+            f'category={self.category.name}, '
+            f'subcategory={self.subcategory.name}'
+            ')'
+        )
+
+    def __str__(self) -> str:
+        return f'{self.category.name}/{self.subcategory.name}'
+
+
+# -----------------------------------------------------------------------------
 # Advertising Data
 # -----------------------------------------------------------------------------
-AdvertisingObject = Union[
-    List[UUID], Tuple[UUID, bytes], bytes, str, int, Tuple[int, int], Tuple[int, bytes]
+AdvertisingDataObject = Union[
+    List[UUID],
+    Tuple[UUID, bytes],
+    bytes,
+    str,
+    int,
+    Tuple[int, int],
+    Tuple[int, bytes],
+    Appearance,
 ]
 
 
@@ -704,109 +1295,115 @@
     # fmt: off
     # pylint: disable=line-too-long
 
-    # This list is only partial, it still needs to be filled in from the spec
-    FLAGS                                          = 0x01
-    INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS  = 0x02
-    COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS    = 0x03
-    INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS  = 0x04
-    COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS    = 0x05
-    INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
-    COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS   = 0x07
-    SHORTENED_LOCAL_NAME                           = 0x08
-    COMPLETE_LOCAL_NAME                            = 0x09
-    TX_POWER_LEVEL                                 = 0x0A
-    CLASS_OF_DEVICE                                = 0x0D
-    SIMPLE_PAIRING_HASH_C                          = 0x0E
-    SIMPLE_PAIRING_HASH_C_192                      = 0x0E
-    SIMPLE_PAIRING_RANDOMIZER_R                    = 0x0F
-    SIMPLE_PAIRING_RANDOMIZER_R_192                = 0x0F
-    DEVICE_ID                                      = 0x10
-    SECURITY_MANAGER_TK_VALUE                      = 0x10
-    SECURITY_MANAGER_OUT_OF_BAND_FLAGS             = 0x11
-    PERIPHERAL_CONNECTION_INTERVAL_RANGE           = 0x12
-    LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS      = 0x14
-    LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS     = 0x15
-    SERVICE_DATA                                   = 0x16
-    SERVICE_DATA_16_BIT_UUID                       = 0x16
-    PUBLIC_TARGET_ADDRESS                          = 0x17
-    RANDOM_TARGET_ADDRESS                          = 0x18
-    APPEARANCE                                     = 0x19
-    ADVERTISING_INTERVAL                           = 0x1A
-    LE_BLUETOOTH_DEVICE_ADDRESS                    = 0x1B
-    LE_ROLE                                        = 0x1C
-    SIMPLE_PAIRING_HASH_C_256                      = 0x1D
-    SIMPLE_PAIRING_RANDOMIZER_R_256                = 0x1E
-    LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS      = 0x1F
-    SERVICE_DATA_32_BIT_UUID                       = 0x20
-    SERVICE_DATA_128_BIT_UUID                      = 0x21
-    LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE       = 0x22
-    LE_SECURE_CONNECTIONS_RANDOM_VALUE             = 0x23
-    URI                                            = 0x24
-    INDOOR_POSITIONING                             = 0x25
-    TRANSPORT_DISCOVERY_DATA                       = 0x26
-    LE_SUPPORTED_FEATURES                          = 0x27
-    CHANNEL_MAP_UPDATE_INDICATION                  = 0x28
-    PB_ADV                                         = 0x29
-    MESH_MESSAGE                                   = 0x2A
-    MESH_BEACON                                    = 0x2B
-    BIGINFO                                        = 0x2C
-    BROADCAST_CODE                                 = 0x2D
-    RESOLVABLE_SET_IDENTIFIER                      = 0x2E
-    ADVERTISING_INTERVAL_LONG                      = 0x2F
-    THREE_D_INFORMATION_DATA                       = 0x3D
-    MANUFACTURER_SPECIFIC_DATA                     = 0xFF
+    FLAGS                                            = 0x01
+    INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS    = 0x02
+    COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS      = 0x03
+    INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS    = 0x04
+    COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS      = 0x05
+    INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS   = 0x06
+    COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS     = 0x07
+    SHORTENED_LOCAL_NAME                             = 0x08
+    COMPLETE_LOCAL_NAME                              = 0x09
+    TX_POWER_LEVEL                                   = 0x0A
+    CLASS_OF_DEVICE                                  = 0x0D
+    SIMPLE_PAIRING_HASH_C                            = 0x0E
+    SIMPLE_PAIRING_HASH_C_192                        = 0x0E
+    SIMPLE_PAIRING_RANDOMIZER_R                      = 0x0F
+    SIMPLE_PAIRING_RANDOMIZER_R_192                  = 0x0F
+    DEVICE_ID                                        = 0x10
+    SECURITY_MANAGER_TK_VALUE                        = 0x10
+    SECURITY_MANAGER_OUT_OF_BAND_FLAGS               = 0x11
+    PERIPHERAL_CONNECTION_INTERVAL_RANGE             = 0x12
+    LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS        = 0x14
+    LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS       = 0x15
+    SERVICE_DATA                                     = 0x16
+    SERVICE_DATA_16_BIT_UUID                         = 0x16
+    PUBLIC_TARGET_ADDRESS                            = 0x17
+    RANDOM_TARGET_ADDRESS                            = 0x18
+    APPEARANCE                                       = 0x19
+    ADVERTISING_INTERVAL                             = 0x1A
+    LE_BLUETOOTH_DEVICE_ADDRESS                      = 0x1B
+    LE_ROLE                                          = 0x1C
+    SIMPLE_PAIRING_HASH_C_256                        = 0x1D
+    SIMPLE_PAIRING_RANDOMIZER_R_256                  = 0x1E
+    LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS        = 0x1F
+    SERVICE_DATA_32_BIT_UUID                         = 0x20
+    SERVICE_DATA_128_BIT_UUID                        = 0x21
+    LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE         = 0x22
+    LE_SECURE_CONNECTIONS_RANDOM_VALUE               = 0x23
+    URI                                              = 0x24
+    INDOOR_POSITIONING                               = 0x25
+    TRANSPORT_DISCOVERY_DATA                         = 0x26
+    LE_SUPPORTED_FEATURES                            = 0x27
+    CHANNEL_MAP_UPDATE_INDICATION                    = 0x28
+    PB_ADV                                           = 0x29
+    MESH_MESSAGE                                     = 0x2A
+    MESH_BEACON                                      = 0x2B
+    BIGINFO                                          = 0x2C
+    BROADCAST_CODE                                   = 0x2D
+    RESOLVABLE_SET_IDENTIFIER                        = 0x2E
+    ADVERTISING_INTERVAL_LONG                        = 0x2F
+    BROADCAST_NAME                                   = 0x30
+    ENCRYPTED_ADVERTISING_DATA                       = 0X31
+    PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = 0X32
+    ELECTRONIC_SHELF_LABEL                           = 0X34
+    THREE_D_INFORMATION_DATA                         = 0x3D
+    MANUFACTURER_SPECIFIC_DATA                       = 0xFF
 
     AD_TYPE_NAMES = {
-        FLAGS:                                          'FLAGS',
-        INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:  'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
-        COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:    'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
-        INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:  'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
-        COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:    'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
-        INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
-        COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:   'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
-        SHORTENED_LOCAL_NAME:                           'SHORTENED_LOCAL_NAME',
-        COMPLETE_LOCAL_NAME:                            'COMPLETE_LOCAL_NAME',
-        TX_POWER_LEVEL:                                 'TX_POWER_LEVEL',
-        CLASS_OF_DEVICE:                                'CLASS_OF_DEVICE',
-        SIMPLE_PAIRING_HASH_C:                          'SIMPLE_PAIRING_HASH_C',
-        SIMPLE_PAIRING_HASH_C_192:                      'SIMPLE_PAIRING_HASH_C_192',
-        SIMPLE_PAIRING_RANDOMIZER_R:                    'SIMPLE_PAIRING_RANDOMIZER_R',
-        SIMPLE_PAIRING_RANDOMIZER_R_192:                'SIMPLE_PAIRING_RANDOMIZER_R_192',
-        DEVICE_ID:                                      'DEVICE_ID',
-        SECURITY_MANAGER_TK_VALUE:                      'SECURITY_MANAGER_TK_VALUE',
-        SECURITY_MANAGER_OUT_OF_BAND_FLAGS:             'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
-        PERIPHERAL_CONNECTION_INTERVAL_RANGE:           'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
-        LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS:      'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
-        LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS:     'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
-        SERVICE_DATA:                                   'SERVICE_DATA',
-        SERVICE_DATA_16_BIT_UUID:                       'SERVICE_DATA_16_BIT_UUID',
-        PUBLIC_TARGET_ADDRESS:                          'PUBLIC_TARGET_ADDRESS',
-        RANDOM_TARGET_ADDRESS:                          'RANDOM_TARGET_ADDRESS',
-        APPEARANCE:                                     'APPEARANCE',
-        ADVERTISING_INTERVAL:                           'ADVERTISING_INTERVAL',
-        LE_BLUETOOTH_DEVICE_ADDRESS:                    'LE_BLUETOOTH_DEVICE_ADDRESS',
-        LE_ROLE:                                        'LE_ROLE',
-        SIMPLE_PAIRING_HASH_C_256:                      'SIMPLE_PAIRING_HASH_C_256',
-        SIMPLE_PAIRING_RANDOMIZER_R_256:                'SIMPLE_PAIRING_RANDOMIZER_R_256',
-        LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS:      'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
-        SERVICE_DATA_32_BIT_UUID:                       'SERVICE_DATA_32_BIT_UUID',
-        SERVICE_DATA_128_BIT_UUID:                      'SERVICE_DATA_128_BIT_UUID',
-        LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE:       'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
-        LE_SECURE_CONNECTIONS_RANDOM_VALUE:             'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
-        URI:                                            'URI',
-        INDOOR_POSITIONING:                             'INDOOR_POSITIONING',
-        TRANSPORT_DISCOVERY_DATA:                       'TRANSPORT_DISCOVERY_DATA',
-        LE_SUPPORTED_FEATURES:                          'LE_SUPPORTED_FEATURES',
-        CHANNEL_MAP_UPDATE_INDICATION:                  'CHANNEL_MAP_UPDATE_INDICATION',
-        PB_ADV:                                         'PB_ADV',
-        MESH_MESSAGE:                                   'MESH_MESSAGE',
-        MESH_BEACON:                                    'MESH_BEACON',
-        BIGINFO:                                        'BIGINFO',
-        BROADCAST_CODE:                                 'BROADCAST_CODE',
-        RESOLVABLE_SET_IDENTIFIER:                      'RESOLVABLE_SET_IDENTIFIER',
-        ADVERTISING_INTERVAL_LONG:                      'ADVERTISING_INTERVAL_LONG',
-        THREE_D_INFORMATION_DATA:                       'THREE_D_INFORMATION_DATA',
-        MANUFACTURER_SPECIFIC_DATA:                     'MANUFACTURER_SPECIFIC_DATA'
+        FLAGS:                                            'FLAGS',
+        INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:    'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
+        COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:      'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
+        INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:    'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
+        COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:      'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
+        INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:   'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
+        COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:     'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
+        SHORTENED_LOCAL_NAME:                             'SHORTENED_LOCAL_NAME',
+        COMPLETE_LOCAL_NAME:                              'COMPLETE_LOCAL_NAME',
+        TX_POWER_LEVEL:                                   'TX_POWER_LEVEL',
+        CLASS_OF_DEVICE:                                  'CLASS_OF_DEVICE',
+        SIMPLE_PAIRING_HASH_C:                            'SIMPLE_PAIRING_HASH_C',
+        SIMPLE_PAIRING_HASH_C_192:                        'SIMPLE_PAIRING_HASH_C_192',
+        SIMPLE_PAIRING_RANDOMIZER_R:                      'SIMPLE_PAIRING_RANDOMIZER_R',
+        SIMPLE_PAIRING_RANDOMIZER_R_192:                  'SIMPLE_PAIRING_RANDOMIZER_R_192',
+        DEVICE_ID:                                        'DEVICE_ID',
+        SECURITY_MANAGER_TK_VALUE:                        'SECURITY_MANAGER_TK_VALUE',
+        SECURITY_MANAGER_OUT_OF_BAND_FLAGS:               'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
+        PERIPHERAL_CONNECTION_INTERVAL_RANGE:             'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
+        LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS:        'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
+        LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS:       'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
+        SERVICE_DATA_16_BIT_UUID:                         'SERVICE_DATA_16_BIT_UUID',
+        PUBLIC_TARGET_ADDRESS:                            'PUBLIC_TARGET_ADDRESS',
+        RANDOM_TARGET_ADDRESS:                            'RANDOM_TARGET_ADDRESS',
+        APPEARANCE:                                       'APPEARANCE',
+        ADVERTISING_INTERVAL:                             'ADVERTISING_INTERVAL',
+        LE_BLUETOOTH_DEVICE_ADDRESS:                      'LE_BLUETOOTH_DEVICE_ADDRESS',
+        LE_ROLE:                                          'LE_ROLE',
+        SIMPLE_PAIRING_HASH_C_256:                        'SIMPLE_PAIRING_HASH_C_256',
+        SIMPLE_PAIRING_RANDOMIZER_R_256:                  'SIMPLE_PAIRING_RANDOMIZER_R_256',
+        LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS:        'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
+        SERVICE_DATA_32_BIT_UUID:                         'SERVICE_DATA_32_BIT_UUID',
+        SERVICE_DATA_128_BIT_UUID:                        'SERVICE_DATA_128_BIT_UUID',
+        LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE:         'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
+        LE_SECURE_CONNECTIONS_RANDOM_VALUE:               'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
+        URI:                                              'URI',
+        INDOOR_POSITIONING:                               'INDOOR_POSITIONING',
+        TRANSPORT_DISCOVERY_DATA:                         'TRANSPORT_DISCOVERY_DATA',
+        LE_SUPPORTED_FEATURES:                            'LE_SUPPORTED_FEATURES',
+        CHANNEL_MAP_UPDATE_INDICATION:                    'CHANNEL_MAP_UPDATE_INDICATION',
+        PB_ADV:                                           'PB_ADV',
+        MESH_MESSAGE:                                     'MESH_MESSAGE',
+        MESH_BEACON:                                      'MESH_BEACON',
+        BIGINFO:                                          'BIGINFO',
+        BROADCAST_CODE:                                   'BROADCAST_CODE',
+        RESOLVABLE_SET_IDENTIFIER:                        'RESOLVABLE_SET_IDENTIFIER',
+        ADVERTISING_INTERVAL_LONG:                        'ADVERTISING_INTERVAL_LONG',
+        BROADCAST_NAME:                                   'BROADCAST_NAME',
+        ENCRYPTED_ADVERTISING_DATA:                       'ENCRYPTED_ADVERTISING_DATA',
+        PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION: 'PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION',
+        ELECTRONIC_SHELF_LABEL:                           'ELECTRONIC_SHELF_LABEL',
+        THREE_D_INFORMATION_DATA:                         'THREE_D_INFORMATION_DATA',
+        MANUFACTURER_SPECIFIC_DATA:                       'MANUFACTURER_SPECIFIC_DATA'
     }
 
     LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01
@@ -915,7 +1512,11 @@
             ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
         elif ad_type == AdvertisingData.APPEARANCE:
             ad_type_str = 'Appearance'
-            ad_data_str = ad_data.hex()
+            appearance = Appearance.from_int(struct.unpack_from('<H', ad_data, 0)[0])
+            ad_data_str = str(appearance)
+        elif ad_type == AdvertisingData.BROADCAST_NAME:
+            ad_type_str = 'Broadcast Name'
+            ad_data_str = ad_data.decode('utf-8')
         else:
             ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}')
             ad_data_str = ad_data.hex()
@@ -924,7 +1525,7 @@
 
     # pylint: disable=too-many-return-statements
     @staticmethod
-    def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingObject:
+    def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingDataObject:
         if ad_type in (
             AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
             AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
@@ -959,16 +1560,14 @@
             AdvertisingData.SHORTENED_LOCAL_NAME,
             AdvertisingData.COMPLETE_LOCAL_NAME,
             AdvertisingData.URI,
+            AdvertisingData.BROADCAST_NAME,
         ):
             return ad_data.decode("utf-8")
 
         if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
             return cast(int, struct.unpack('B', ad_data)[0])
 
-        if ad_type in (
-            AdvertisingData.APPEARANCE,
-            AdvertisingData.ADVERTISING_INTERVAL,
-        ):
+        if ad_type in (AdvertisingData.ADVERTISING_INTERVAL,):
             return cast(int, struct.unpack('<H', ad_data)[0])
 
         if ad_type == AdvertisingData.CLASS_OF_DEVICE:
@@ -980,6 +1579,11 @@
         if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
             return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
 
+        if ad_type == AdvertisingData.APPEARANCE:
+            return Appearance.from_int(
+                cast(int, struct.unpack_from('<H', ad_data, 0)[0])
+            )
+
         return ad_data
 
     def append(self, data: bytes) -> None:
@@ -993,27 +1597,27 @@
                 self.ad_structures.append((ad_type, ad_data))
             offset += length
 
-    def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingObject]:
+    def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingDataObject]:
         '''
         Get Advertising Data Structure(s) with a given type
 
         Returns a (possibly empty) list of matches.
         '''
 
-        def process_ad_data(ad_data: bytes) -> AdvertisingObject:
+        def process_ad_data(ad_data: bytes) -> AdvertisingDataObject:
             return ad_data if raw else self.ad_data_to_object(type_id, ad_data)
 
         return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
 
-    def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingObject]:
+    def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingDataObject]:
         '''
         Get Advertising Data Structure(s) with a given type
 
         Returns the first entry, or None if no structure matches.
         '''
 
-        all = self.get_all(type_id, raw=raw)
-        return all[0] if all else None
+        all_objects = self.get_all(type_id, raw=raw)
+        return all_objects[0] if all_objects else None
 
     def __bytes__(self):
         return b''.join(
diff --git a/bumble/device.py b/bumble/device.py
index f9e6b9d..034b0e9 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -16,22 +16,22 @@
 # Imports
 # -----------------------------------------------------------------------------
 from __future__ import annotations
-from enum import IntEnum
-import copy
-import functools
-import json
 import asyncio
-import logging
-import secrets
-import sys
+from collections.abc import Iterable
 from contextlib import (
     asynccontextmanager,
     AsyncExitStack,
     closing,
-    AbstractAsyncContextManager,
 )
+import copy
 from dataclasses import dataclass, field
-from collections.abc import Iterable
+from enum import Enum, IntEnum
+import functools
+import itertools
+import json
+import logging
+import secrets
+import sys
 from typing import (
     Any,
     Callable,
@@ -51,6 +51,7 @@
 
 from pyee import EventEmitter
 
+from bumble import hci
 from .colors import color
 from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
 from .gatt import Characteristic, Descriptor, Service
@@ -81,6 +82,7 @@
     HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
     HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
     HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
+    HCI_OPERATION_CANCELLED_BY_HOST_ERROR,
     HCI_R2_PAGE_SCAN_REPETITION_MODE,
     HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
     HCI_SUCCESS,
@@ -102,11 +104,17 @@
     HCI_LE_Accept_CIS_Request_Command,
     HCI_LE_Add_Device_To_Resolving_List_Command,
     HCI_LE_Advertising_Report_Event,
+    HCI_LE_BIGInfo_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_Periodic_Advertising_Create_Sync_Command,
+    HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command,
+    HCI_LE_Periodic_Advertising_Report_Event,
+    HCI_LE_Periodic_Advertising_Sync_Transfer_Command,
+    HCI_LE_Periodic_Advertising_Terminate_Sync_Command,
     HCI_LE_Enable_Encryption_Command,
     HCI_LE_Extended_Advertising_Report_Event,
     HCI_LE_Extended_Create_Connection_Command,
@@ -162,21 +170,29 @@
     OwnAddressType,
     LeFeature,
     LeFeatureMask,
+    LmpFeatureMask,
     Phy,
     phy_list_to_bits,
 )
 from .host import Host
-from .gap import GenericAccessService
+from .profiles.gap import GenericAccessService
 from .core import (
     BT_BR_EDR_TRANSPORT,
     BT_CENTRAL_ROLE,
     BT_LE_TRANSPORT,
     BT_PERIPHERAL_ROLE,
     AdvertisingData,
+    BaseBumbleError,
     ConnectionParameterUpdateError,
     CommandTimeoutError,
+    ConnectionParameters,
     ConnectionPHY,
+    InvalidArgumentError,
+    InvalidOperationError,
     InvalidStateError,
+    NotSupportedError,
+    OutOfResourcesError,
+    UnreachableError,
 )
 from .utils import (
     AsyncRunner,
@@ -191,13 +207,13 @@
     KeyStore,
     PairingKeys,
 )
-from .pairing import PairingConfig
-from . import gatt_client
-from . import gatt_server
-from . import smp
-from . import sdp
-from . import l2cap
-from . import core
+from bumble import pairing
+from bumble import gatt_client
+from bumble import gatt_server
+from bumble import smp
+from bumble import sdp
+from bumble import l2cap
+from bumble import core
 
 if TYPE_CHECKING:
     from .transport.common import TransportSource, TransportSink
@@ -248,6 +264,9 @@
 DEVICE_DEFAULT_ADVERTISING_TX_POWER           = (
     HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE
 )
+DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP = 0
+DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT = 5.0
+DEVICE_DEFAULT_LE_RPA_TIMEOUT                 = 15 * 60 # 15 minutes (in seconds)
 
 # fmt: on
 # pylint: enable=line-too-long
@@ -259,6 +278,8 @@
 # -----------------------------------------------------------------------------
 # Classes
 # -----------------------------------------------------------------------------
+class ObjectLookupError(BaseBumbleError):
+    """Error raised when failed to lookup an object."""
 
 
 # -----------------------------------------------------------------------------
@@ -553,6 +574,70 @@
 
 
 # -----------------------------------------------------------------------------
+@dataclass
+class PeriodicAdvertisement:
+    address: Address
+    sid: int
+    tx_power: int = (
+        HCI_LE_Periodic_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
+    )
+    rssi: int = HCI_LE_Periodic_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+    is_truncated: bool = False
+    data_bytes: bytes = b''
+
+    # Constants
+    TX_POWER_NOT_AVAILABLE: ClassVar[int] = (
+        HCI_LE_Periodic_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
+    )
+    RSSI_NOT_AVAILABLE: ClassVar[int] = (
+        HCI_LE_Periodic_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+    )
+
+    def __post_init__(self) -> None:
+        self.data = (
+            None if self.is_truncated else AdvertisingData.from_bytes(self.data_bytes)
+        )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class BIGInfoAdvertisement:
+    address: Address
+    sid: int
+    num_bis: int
+    nse: int
+    iso_interval: int
+    bn: int
+    pto: int
+    irc: int
+    max_pdu: int
+    sdu_interval: int
+    max_sdu: int
+    phy: Phy
+    framed: bool
+    encrypted: bool
+
+    @classmethod
+    def from_report(cls, address: Address, sid: int, report) -> Self:
+        return cls(
+            address,
+            sid,
+            report.num_bis,
+            report.nse,
+            report.iso_interval,
+            report.bn,
+            report.pto,
+            report.irc,
+            report.max_pdu,
+            report.sdu_interval,
+            report.max_sdu,
+            Phy(report.phy),
+            report.framing != 0,
+            report.encryption != 0,
+        )
+
+
+# -----------------------------------------------------------------------------
 # TODO: replace with typing.TypeAlias when the code base is all Python >= 3.10
 AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
 
@@ -796,6 +881,206 @@
 
 
 # -----------------------------------------------------------------------------
+class PeriodicAdvertisingSync(EventEmitter):
+    class State(Enum):
+        INIT = 0
+        PENDING = 1
+        ESTABLISHED = 2
+        CANCELLED = 3
+        ERROR = 4
+        LOST = 5
+        TERMINATED = 6
+
+    _state: State
+    sync_handle: Optional[int]
+    advertiser_address: Address
+    sid: int
+    skip: int
+    sync_timeout: float  # Sync timeout, in seconds
+    filter_duplicates: bool
+    status: int
+    advertiser_phy: int
+    periodic_advertising_interval: int
+    advertiser_clock_accuracy: int
+
+    def __init__(
+        self,
+        device: Device,
+        advertiser_address: Address,
+        sid: int,
+        skip: int,
+        sync_timeout: float,
+        filter_duplicates: bool,
+    ) -> None:
+        super().__init__()
+        self._state = self.State.INIT
+        self.sync_handle = None
+        self.device = device
+        self.advertiser_address = advertiser_address
+        self.sid = sid
+        self.skip = skip
+        self.sync_timeout = sync_timeout
+        self.filter_duplicates = filter_duplicates
+        self.status = HCI_SUCCESS
+        self.advertiser_phy = 0
+        self.periodic_advertising_interval = 0
+        self.advertiser_clock_accuracy = 0
+        self.data_accumulator = b''
+
+    @property
+    def state(self) -> State:
+        return self._state
+
+    @state.setter
+    def state(self, state: State) -> None:
+        logger.debug(f'{self} -> {state.name}')
+        self._state = state
+        self.emit('state_change')
+
+    async def establish(self) -> None:
+        if self.state != self.State.INIT:
+            raise InvalidStateError('sync not in init state')
+
+        options = HCI_LE_Periodic_Advertising_Create_Sync_Command.Options(0)
+        if self.filter_duplicates:
+            options |= (
+                HCI_LE_Periodic_Advertising_Create_Sync_Command.Options.DUPLICATE_FILTERING_INITIALLY_ENABLED
+            )
+
+        response = await self.device.send_command(
+            HCI_LE_Periodic_Advertising_Create_Sync_Command(
+                options=options,
+                advertising_sid=self.sid,
+                advertiser_address_type=self.advertiser_address.address_type,
+                advertiser_address=self.advertiser_address,
+                skip=self.skip,
+                sync_timeout=int(self.sync_timeout * 100),
+                sync_cte_type=0,
+            )
+        )
+        if response.status != HCI_Command_Status_Event.PENDING:
+            raise HCI_StatusError(response)
+
+        self.state = self.State.PENDING
+
+    async def terminate(self) -> None:
+        if self.state in (self.State.INIT, self.State.CANCELLED, self.State.TERMINATED):
+            return
+
+        if self.state == self.State.PENDING:
+            self.state = self.State.CANCELLED
+            response = await self.device.send_command(
+                HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command(),
+            )
+            if response.return_parameters == HCI_SUCCESS:
+                if self in self.device.periodic_advertising_syncs:
+                    self.device.periodic_advertising_syncs.remove(self)
+            return
+
+        if self.state in (self.State.ESTABLISHED, self.State.ERROR, self.State.LOST):
+            self.state = self.State.TERMINATED
+            if self.sync_handle is not None:
+                await self.device.send_command(
+                    HCI_LE_Periodic_Advertising_Terminate_Sync_Command(
+                        sync_handle=self.sync_handle
+                    )
+                )
+            self.device.periodic_advertising_syncs.remove(self)
+
+    async def transfer(self, connection: Connection, service_data: int = 0) -> None:
+        if self.sync_handle is not None:
+            await connection.transfer_periodic_sync(self.sync_handle, service_data)
+
+    def on_establishment(
+        self,
+        status,
+        sync_handle,
+        advertiser_phy,
+        periodic_advertising_interval,
+        advertiser_clock_accuracy,
+    ) -> None:
+        self.status = status
+
+        if self.state == self.State.CANCELLED:
+            # Somehow, we receive an established event after trying to cancel, most
+            # likely because the cancel command was sent too late, when the sync was
+            # already established, but before the established event was sent.
+            # We need to automatically terminate.
+            logger.debug(
+                "received established event for cancelled sync, will terminate"
+            )
+            self.state = self.State.ESTABLISHED
+            AsyncRunner.spawn(self.terminate())
+            return
+
+        if status == HCI_SUCCESS:
+            self.sync_handle = sync_handle
+            self.advertiser_phy = advertiser_phy
+            self.periodic_advertising_interval = periodic_advertising_interval
+            self.advertiser_clock_accuracy = advertiser_clock_accuracy
+            self.state = self.State.ESTABLISHED
+            self.emit('establishment')
+            return
+
+        # We don't need to keep a reference anymore
+        if self in self.device.periodic_advertising_syncs:
+            self.device.periodic_advertising_syncs.remove(self)
+
+        if status == HCI_OPERATION_CANCELLED_BY_HOST_ERROR:
+            self.state = self.State.CANCELLED
+            self.emit('cancellation')
+            return
+
+        self.state = self.State.ERROR
+        self.emit('error')
+
+    def on_loss(self):
+        self.state = self.State.LOST
+        self.emit('loss')
+
+    def on_periodic_advertising_report(self, report) -> None:
+        self.data_accumulator += report.data
+        if (
+            report.data_status
+            == HCI_LE_Periodic_Advertising_Report_Event.DataStatus.DATA_INCOMPLETE_MORE_TO_COME
+        ):
+            return
+
+        self.emit(
+            'periodic_advertisement',
+            PeriodicAdvertisement(
+                self.advertiser_address,
+                self.sid,
+                report.tx_power,
+                report.rssi,
+                is_truncated=(
+                    report.data_status
+                    == HCI_LE_Periodic_Advertising_Report_Event.DataStatus.DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME
+                ),
+                data_bytes=self.data_accumulator,
+            ),
+        )
+        self.data_accumulator = b''
+
+    def on_biginfo_advertising_report(self, report) -> None:
+        self.emit(
+            'biginfo_advertisement',
+            BIGInfoAdvertisement.from_report(self.advertiser_address, self.sid, report),
+        )
+
+    def __str__(self) -> str:
+        return (
+            'PeriodicAdvertisingSync('
+            f'state={self.state.name}, '
+            f'sync_handle={self.sync_handle}, '
+            f'sid={self.sid}, '
+            f'skip={self.skip}, '
+            f'filter_duplicates={self.filter_duplicates}'
+            ')'
+        )
+
+
+# -----------------------------------------------------------------------------
 class LePhyOptions:
     # Coded PHY preference
     ANY_CODED_PHY = 0
@@ -867,6 +1152,15 @@
     async def discover_attributes(self) -> List[gatt_client.AttributeProxy]:
         return await self.gatt_client.discover_attributes()
 
+    async def discover_all(self):
+        await self.discover_services()
+        for service in self.services:
+            await self.discover_characteristics(service=service)
+
+        for service in self.services:
+            for characteristic in service.characteristics:
+                await self.discover_descriptors(characteristic=characteristic)
+
     async def subscribe(
         self,
         characteristic: gatt_client.CharacteristicProxy,
@@ -906,12 +1200,29 @@
         return self.gatt_client.get_services_by_uuid(uuid)
 
     def get_characteristics_by_uuid(
-        self, uuid: core.UUID, service: Optional[gatt_client.ServiceProxy] = None
+        self,
+        uuid: core.UUID,
+        service: Optional[Union[gatt_client.ServiceProxy, core.UUID]] = None,
     ) -> List[gatt_client.CharacteristicProxy]:
+        if isinstance(service, core.UUID):
+            return list(
+                itertools.chain(
+                    *[
+                        self.get_characteristics_by_uuid(uuid, s)
+                        for s in self.get_services_by_uuid(service)
+                    ]
+                )
+            )
+
         return self.gatt_client.get_characteristics_by_uuid(uuid, service)
 
-    def create_service_proxy(self, proxy_class: Type[_PROXY_CLASS]) -> _PROXY_CLASS:
-        return cast(_PROXY_CLASS, proxy_class.from_client(self.gatt_client))
+    def create_service_proxy(
+        self, proxy_class: Type[_PROXY_CLASS]
+    ) -> Optional[_PROXY_CLASS]:
+        if proxy := proxy_class.from_client(self.gatt_client):
+            return cast(_PROXY_CLASS, proxy)
+
+        return None
 
     async def discover_service_and_create_proxy(
         self, proxy_class: Type[_PROXY_CLASS]
@@ -1008,6 +1319,7 @@
     handle: int
     transport: int
     self_address: Address
+    self_resolvable_address: Optional[Address]
     peer_address: Address
     peer_resolvable_address: Optional[Address]
     peer_le_features: Optional[LeFeatureMask]
@@ -1055,6 +1367,7 @@
         handle,
         transport,
         self_address,
+        self_resolvable_address,
         peer_address,
         peer_resolvable_address,
         role,
@@ -1066,6 +1379,7 @@
         self.handle = handle
         self.transport = transport
         self.self_address = self_address
+        self.self_resolvable_address = self_resolvable_address
         self.peer_address = peer_address
         self.peer_resolvable_address = peer_resolvable_address
         self.peer_name = None  # Classic only
@@ -1099,6 +1413,7 @@
             None,
             BT_BR_EDR_TRANSPORT,
             device.public_address,
+            None,
             peer_address,
             None,
             role,
@@ -1192,11 +1507,9 @@
 
         try:
             await asyncio.wait_for(self.device.abort_on('flush', abort), timeout)
-        except asyncio.TimeoutError:
-            pass
-
-        self.remove_listener('disconnection', abort.set_result)
-        self.remove_listener('disconnection_failure', abort.set_exception)
+        finally:
+            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)
@@ -1227,6 +1540,11 @@
     async def get_phy(self):
         return await self.device.get_connection_phy(self)
 
+    async def transfer_periodic_sync(
+        self, sync_handle: int, service_data: int = 0
+    ) -> None:
+        await self.device.transfer_periodic_sync(self, sync_handle, service_data)
+
     # [Classic only]
     async def request_remote_name(self):
         return await self.device.request_remote_name(self)
@@ -1257,7 +1575,9 @@
             f'Connection(handle=0x{self.handle:04X}, '
             f'role={self.role_name}, '
             f'self_address={self.self_address}, '
-            f'peer_address={self.peer_address})'
+            f'self_resolvable_address={self.self_resolvable_address}, '
+            f'peer_address={self.peer_address}, '
+            f'peer_resolvable_address={self.peer_resolvable_address})'
         )
 
 
@@ -1272,13 +1592,15 @@
     advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
     advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
     le_enabled: bool = True
-    # LE host enable 2nd parameter
     le_simultaneous_enabled: bool = False
+    le_privacy_enabled: bool = False
+    le_rpa_timeout: int = DEVICE_DEFAULT_LE_RPA_TIMEOUT
     classic_enabled: bool = False
     classic_sc_enabled: bool = True
     classic_ssp_enabled: bool = True
     classic_smp_enabled: bool = True
     classic_accept_any: bool = True
+    classic_interlaced_scan_enabled: bool = True
     connectable: bool = True
     discoverable: bool = True
     advertising_data: bytes = bytes(
@@ -1289,7 +1611,10 @@
     irk: bytes = bytes(16)  # This really must be changed for any level of security
     keystore: Optional[str] = None
     address_resolution_offload: bool = False
+    address_generation_offload: bool = False
     cis_enabled: bool = False
+    identity_address_type: Optional[int] = None
+    io_capability: int = pairing.PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT
 
     def __post_init__(self) -> None:
         self.gatt_services: List[Dict[str, Any]] = []
@@ -1374,7 +1699,9 @@
     @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 ObjectLookupError(
+                f'no connection for handle: 0x{connection_handle:04x}'
+            )
         return function(self, connection, *args, **kwargs)
 
     return wrapper
@@ -1389,7 +1716,7 @@
         for connection in self.connections.values():
             if connection.peer_address == address:
                 return function(self, connection, *args, **kwargs)
-        raise ValueError('no connection for address')
+        raise ObjectLookupError('no connection for address')
 
     return wrapper
 
@@ -1409,6 +1736,20 @@
     return wrapper
 
 
+# Decorator that converts the first argument from a sync handle to a periodic
+# advertising sync object
+def with_periodic_advertising_sync_from_handle(function):
+    @functools.wraps(function)
+    def wrapper(self, sync_handle, *args, **kwargs):
+        if (sync := self.lookup_periodic_advertising_sync(sync_handle)) is None:
+            raise ValueError(
+                f'no periodic advertising sync for handle: 0x{sync_handle:04x}'
+            )
+        return function(self, sync, *args, **kwargs)
+
+    return wrapper
+
+
 # Decorator that adds a method to the list of event handlers for host events.
 # This assumes that the method name starts with `on_`
 def host_event_handler(function):
@@ -1425,8 +1766,9 @@
 # -----------------------------------------------------------------------------
 class Device(CompositeEventEmitter):
     # Incomplete list of fields.
-    random_address: Address
-    public_address: Address
+    random_address: Address  # Random address that may change with RPA
+    public_address: Address  # Public address (obtained from the controller)
+    static_address: Address  # Random address that can be set but does not change
     classic_enabled: bool
     name: str
     class_of_device: int
@@ -1439,6 +1781,7 @@
         Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]]
     ]
     advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
+    periodic_advertising_syncs: List[PeriodicAdvertisingSync]
     config: DeviceConfiguration
     legacy_advertiser: Optional[LegacyAdvertiser]
     sco_links: Dict[int, ScoLink]
@@ -1524,6 +1867,7 @@
             [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]
         )
         self.advertisement_accumulators = {}  # Accumulators, by address
+        self.periodic_advertising_syncs = []
         self.scanning = False
         self.scanning_is_passive = False
         self.discovering = False
@@ -1554,26 +1898,33 @@
         config = config or DeviceConfiguration()
         self.config = config
 
-        self.public_address = Address('00:00:00:00:00:00')
         self.name = config.name
+        self.public_address = Address.ANY
         self.random_address = config.address
+        self.static_address = config.address
         self.class_of_device = config.class_of_device
         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.le_privacy_enabled = config.le_privacy_enabled
+        self.le_rpa_timeout = config.le_rpa_timeout
+        self.le_rpa_periodic_update_task: Optional[asyncio.Task] = None
+        self.classic_enabled = config.classic_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
+        self.classic_interlaced_scan_enabled = config.classic_interlaced_scan_enabled
         self.discoverable = config.discoverable
         self.connectable = config.connectable
         self.classic_accept_any = config.classic_accept_any
         self.address_resolution_offload = config.address_resolution_offload
+        self.address_generation_offload = config.address_generation_offload
 
         # Extended advertising.
         self.extended_advertising_sets: Dict[int, AdvertisingSet] = {}
+        self.connecting_extended_advertising_sets: Dict[int, AdvertisingSet] = {}
 
         # Legacy advertising.
         # The advertising and scan response data, as well as the advertising interval
@@ -1625,10 +1976,23 @@
             if isinstance(address, str):
                 address = Address(address)
             self.random_address = address
+            self.static_address = address
 
         # Setup SMP
         self.smp_manager = smp.Manager(
-            self, pairing_config_factory=lambda connection: PairingConfig()
+            self,
+            pairing_config_factory=lambda connection: pairing.PairingConfig(
+                identity_address_type=(
+                    pairing.PairingConfig.AddressType(self.config.identity_address_type)
+                    if self.config.identity_address_type
+                    else None
+                ),
+                delegate=pairing.PairingDelegate(
+                    io_capability=pairing.PairingDelegate.IoCapability(
+                        self.config.io_capability
+                    )
+                ),
+            ),
         )
 
         self.l2cap_channel_manager.register_fixed_channel(smp.SMP_CID, self.on_smp_pdu)
@@ -1706,6 +2070,18 @@
 
         return None
 
+    def lookup_periodic_advertising_sync(
+        self, sync_handle: int
+    ) -> Optional[PeriodicAdvertisingSync]:
+        return next(
+            (
+                sync
+                for sync in self.periodic_advertising_syncs
+                if sync.sync_handle == sync_handle
+            ),
+            None,
+        )
+
     @deprecated("Please use create_l2cap_server()")
     def register_l2cap_server(self, psm, server) -> int:
         return self.l2cap_channel_manager.register_server(psm, server)
@@ -1798,7 +2174,7 @@
                 spec=spec,
             )
         else:
-            raise ValueError(f'Unexpected mode {spec}')
+            raise InvalidArgumentError(f'Unexpected mode {spec}')
 
     def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
         self.host.send_l2cap_pdu(connection_handle, cid, pdu)
@@ -1840,26 +2216,26 @@
                 HCI_Write_LE_Host_Support_Command(
                     le_supported_host=int(self.le_enabled),
                     simultaneous_le_host=int(self.le_simultaneous_enabled),
-                )
+                ),
+                check_result=True,
             )
 
         if self.le_enabled:
-            # Set the controller address
-            if self.random_address == Address.ANY_RANDOM:
-                # Try to use an address generated at random by the controller
-                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
+            # Generate a random address if not set.
+            if self.static_address == Address.ANY_RANDOM:
+                self.static_address = Address.generate_static_address()
+
+            # If LE Privacy is enabled, generate an RPA
+            if self.le_privacy_enabled:
+                self.random_address = Address.generate_private_address(self.irk)
+                logger.info(f'Initial RPA: {self.random_address}')
+                if self.le_rpa_timeout > 0:
+                    # Start a task to periodically generate a new RPA
+                    self.le_rpa_periodic_update_task = asyncio.create_task(
+                        self._run_rpa_periodic_update()
                     )
-
-                    # Ensure the address bytes can be a static random address
-                    address_bytes = response.return_parameters.random_number[
-                        :5
-                    ] + bytes([response.return_parameters.random_number[5] | 0xC0])
-
-                    # Create a static random address from the random bytes
-                    self.random_address = Address(address_bytes)
+            else:
+                self.random_address = self.static_address
 
             if self.random_address != Address.ANY_RANDOM:
                 logger.debug(
@@ -1884,7 +2260,8 @@
                 await self.send_command(
                     HCI_LE_Set_Address_Resolution_Enable_Command(
                         address_resolution_enable=1
-                    )
+                    ),
+                    check_result=True,
                 )
 
             if self.cis_enabled:
@@ -1892,7 +2269,8 @@
                     HCI_LE_Set_Host_Feature_Command(
                         bit_number=LeFeature.CONNECTED_ISOCHRONOUS_STREAM,
                         bit_value=1,
-                    )
+                    ),
+                    check_result=True,
                 )
 
         if self.classic_enabled:
@@ -1915,6 +2293,21 @@
             await self.set_connectable(self.connectable)
             await self.set_discoverable(self.discoverable)
 
+            if self.classic_interlaced_scan_enabled:
+                if self.host.supports_lmp_features(LmpFeatureMask.INTERLACED_PAGE_SCAN):
+                    await self.send_command(
+                        hci.HCI_Write_Page_Scan_Type_Command(page_scan_type=1),
+                        check_result=True,
+                    )
+
+                if self.host.supports_lmp_features(
+                    LmpFeatureMask.INTERLACED_INQUIRY_SCAN
+                ):
+                    await self.send_command(
+                        hci.HCI_Write_Inquiry_Scan_Type_Command(scan_type=1),
+                        check_result=True,
+                    )
+
         # Done
         self.powered_on = True
 
@@ -1923,9 +2316,45 @@
 
     async def power_off(self) -> None:
         if self.powered_on:
+            if self.le_rpa_periodic_update_task:
+                self.le_rpa_periodic_update_task.cancel()
+
             await self.host.flush()
+
             self.powered_on = False
 
+    async def update_rpa(self) -> bool:
+        """
+        Try to update the RPA.
+
+        Returns:
+          True if the RPA was updated, False if it could not be updated.
+        """
+
+        # Check if this is a good time to rotate the address
+        if self.is_advertising or self.is_scanning or self.is_le_connecting:
+            logger.debug('skipping RPA update')
+            return False
+
+        random_address = Address.generate_private_address(self.irk)
+        response = await self.send_command(
+            HCI_LE_Set_Random_Address_Command(random_address=self.random_address)
+        )
+        if response.return_parameters == HCI_SUCCESS:
+            logger.info(f'new RPA: {random_address}')
+            self.random_address = random_address
+            return True
+        else:
+            logger.warning(f'failed to set RPA: {response.return_parameters}')
+            return False
+
+    async def _run_rpa_periodic_update(self) -> None:
+        """Update the RPA periodically"""
+        while self.le_rpa_timeout != 0:
+            await asyncio.sleep(self.le_rpa_timeout)
+            if not self.update_rpa():
+                logger.debug("periodic RPA update failed")
+
     async def refresh_resolving_list(self) -> None:
         assert self.keystore is not None
 
@@ -1933,7 +2362,7 @@
         # Create a host-side address resolver
         self.address_resolver = smp.AddressResolver(resolving_keys)
 
-        if self.address_resolution_offload:
+        if self.address_resolution_offload or self.address_generation_offload:
             await self.send_command(HCI_LE_Clear_Resolving_List_Command())
 
             # Add an empty entry for non-directed address generation.
@@ -1959,7 +2388,7 @@
     def supports_le_features(self, feature: LeFeatureMask) -> bool:
         return self.host.supports_le_features(feature)
 
-    def supports_le_phy(self, phy):
+    def supports_le_phy(self, phy: int) -> bool:
         if phy == HCI_LE_1M_PHY:
             return True
 
@@ -1968,7 +2397,7 @@
             HCI_LE_CODED_PHY: LeFeatureMask.LE_CODED_PHY,
         }
         if phy not in feature_map:
-            raise ValueError('invalid PHY')
+            raise InvalidArgumentError('invalid PHY')
 
         return self.supports_le_features(feature_map[phy])
 
@@ -1976,6 +2405,10 @@
     def supports_le_extended_advertising(self):
         return self.supports_le_features(LeFeatureMask.LE_EXTENDED_ADVERTISING)
 
+    @property
+    def supports_le_periodic_advertising(self):
+        return self.supports_le_features(LeFeatureMask.LE_PERIODIC_ADVERTISING)
+
     async def start_advertising(
         self,
         advertising_type: AdvertisingType = AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
@@ -2028,7 +2461,7 @@
         # Decide what peer address to use
         if advertising_type.is_directed:
             if target is None:
-                raise ValueError('directed advertising requires a target')
+                raise InvalidArgumentError('directed advertising requires a target')
             peer_address = target
         else:
             peer_address = Address.ANY
@@ -2135,7 +2568,7 @@
             and advertising_data
             and scan_response_data
         ):
-            raise ValueError(
+            raise InvalidArgumentError(
                 "Extended advertisements can't have both data and scan \
                               response data"
             )
@@ -2151,7 +2584,9 @@
                 if handle not in self.extended_advertising_sets
             )
         except StopIteration as exc:
-            raise RuntimeError("all valid advertising handles already in use") from exc
+            raise OutOfResourcesError(
+                "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.
@@ -2250,14 +2685,14 @@
     ) -> None:
         # Check that the arguments are legal
         if scan_interval < scan_window:
-            raise ValueError('scan_interval must be >= scan_window')
+            raise InvalidArgumentError('scan_interval must be >= scan_window')
         if (
             scan_interval < DEVICE_MIN_SCAN_INTERVAL
             or scan_interval > DEVICE_MAX_SCAN_INTERVAL
         ):
-            raise ValueError('scan_interval out of range')
+            raise InvalidArgumentError('scan_interval out of range')
         if scan_window < DEVICE_MIN_SCAN_WINDOW or scan_window > DEVICE_MAX_SCAN_WINDOW:
-            raise ValueError('scan_interval out of range')
+            raise InvalidArgumentError('scan_interval out of range')
 
         # Reset the accumulators
         self.advertisement_accumulators = {}
@@ -2285,7 +2720,7 @@
                     scanning_phy_count += 1
 
             if scanning_phy_count == 0:
-                raise ValueError('at least one scanning PHY must be enabled')
+                raise InvalidArgumentError('at least one scanning PHY must be enabled')
 
             await self.send_command(
                 HCI_LE_Set_Extended_Scan_Parameters_Command(
@@ -2368,6 +2803,120 @@
         if advertisement := accumulator.update(report):
             self.emit('advertisement', advertisement)
 
+    async def create_periodic_advertising_sync(
+        self,
+        advertiser_address: Address,
+        sid: int,
+        skip: int = DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP,
+        sync_timeout: float = DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT,
+        filter_duplicates: bool = False,
+    ) -> PeriodicAdvertisingSync:
+        # Check that the controller supports the feature.
+        if not self.supports_le_periodic_advertising:
+            raise NotSupportedError()
+
+        # Check that there isn't already an equivalent entry
+        if any(
+            sync.advertiser_address == advertiser_address and sync.sid == sid
+            for sync in self.periodic_advertising_syncs
+        ):
+            raise ValueError("equivalent entry already created")
+
+        # Create a new entry
+        sync = PeriodicAdvertisingSync(
+            device=self,
+            advertiser_address=advertiser_address,
+            sid=sid,
+            skip=skip,
+            sync_timeout=sync_timeout,
+            filter_duplicates=filter_duplicates,
+        )
+
+        self.periodic_advertising_syncs.append(sync)
+
+        # Check if any sync should be started
+        await self._update_periodic_advertising_syncs()
+
+        return sync
+
+    async def _update_periodic_advertising_syncs(self) -> None:
+        # Check if there's already a pending sync
+        if any(
+            sync.state == PeriodicAdvertisingSync.State.PENDING
+            for sync in self.periodic_advertising_syncs
+        ):
+            logger.debug("at least one sync pending, nothing to update yet")
+            return
+
+        # Start the next sync that's waiting to be started
+        if ready := next(
+            (
+                sync
+                for sync in self.periodic_advertising_syncs
+                if sync.state == PeriodicAdvertisingSync.State.INIT
+            ),
+            None,
+        ):
+            await ready.establish()
+            return
+
+    @host_event_handler
+    def on_periodic_advertising_sync_establishment(
+        self,
+        status: int,
+        sync_handle: int,
+        advertising_sid: int,
+        advertiser_address: Address,
+        advertiser_phy: int,
+        periodic_advertising_interval: int,
+        advertiser_clock_accuracy: int,
+    ) -> None:
+        for periodic_advertising_sync in self.periodic_advertising_syncs:
+            if (
+                periodic_advertising_sync.advertiser_address == advertiser_address
+                and periodic_advertising_sync.sid == advertising_sid
+            ):
+                periodic_advertising_sync.on_establishment(
+                    status,
+                    sync_handle,
+                    advertiser_phy,
+                    periodic_advertising_interval,
+                    advertiser_clock_accuracy,
+                )
+
+                AsyncRunner.spawn(self._update_periodic_advertising_syncs())
+
+                return
+
+        logger.warning(
+            "periodic advertising sync establishment for unknown address/sid"
+        )
+
+    @host_event_handler
+    @with_periodic_advertising_sync_from_handle
+    def on_periodic_advertising_sync_loss(
+        self, periodic_advertising_sync: PeriodicAdvertisingSync
+    ):
+        periodic_advertising_sync.on_loss()
+
+    @host_event_handler
+    @with_periodic_advertising_sync_from_handle
+    def on_periodic_advertising_report(
+        self,
+        periodic_advertising_sync: PeriodicAdvertisingSync,
+        report: HCI_LE_Periodic_Advertising_Report_Event,
+    ):
+        periodic_advertising_sync.on_periodic_advertising_report(report)
+
+    @host_event_handler
+    @with_periodic_advertising_sync_from_handle
+    def on_biginfo_advertising_report(
+        self,
+        periodic_advertising_sync: PeriodicAdvertisingSync,
+        report: HCI_LE_BIGInfo_Advertising_Report_Event,
+    ):
+        periodic_advertising_sync.on_biginfo_advertising_report(report)
+
     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),
@@ -2463,23 +3012,52 @@
         ] = None,
         own_address_type: int = OwnAddressType.RANDOM,
         timeout: Optional[float] = DEVICE_DEFAULT_CONNECT_TIMEOUT,
+        always_resolve: bool = False,
     ) -> Connection:
         '''
         Request a connection to a peer.
-        When transport is BLE, this method cannot be called if there is already a
+
+        When the transport is BLE, this method cannot be called if there is already a
         pending connection.
 
-        connection_parameters_preferences: (BLE only, ignored for BR/EDR)
-          * None: use the 1M PHY with default parameters
-          * map: each entry has a PHY as key and a ConnectionParametersPreferences
-            object as value
+        Args:
+          peer_address:
+            Address or name of the device to connect to.
+            If a string is passed:
+              If the string is an address followed by a `@` suffix, the `always_resolve`
+              argument is implicitly set to True, so the connection is made to the
+              address after resolution.
+              If the string is any other address, the connection is made to that
+              address (with or without address resolution, depending on the
+              `always_resolve` argument).
+              For any other string, a scan for devices using that string as their name
+              is initiated, and a connection to the first matching device's address
+              is made. In that case, `always_resolve` is ignored.
 
-        own_address_type: (BLE only)
+          connection_parameters_preferences:
+            (BLE only, ignored for BR/EDR)
+            * None: use the 1M PHY with default parameters
+            * map: each entry has a PHY as key and a ConnectionParametersPreferences
+              object as value
+
+          own_address_type:
+            (BLE only, ignored for BR/EDR)
+            OwnAddressType.RANDOM to use this device's random address, or
+            OwnAddressType.PUBLIC to use this device's public address.
+
+          timeout:
+            Maximum time to wait for a connection to be established, in seconds.
+            Pass None for an unlimited time.
+
+          always_resolve:
+            (BLE only, ignored for BR/EDR)
+            If True, always initiate a scan, resolving addresses, and connect to the
+            address that resolves to `peer_address`.
         '''
 
         # Check parameters
         if transport not in (BT_LE_TRANSPORT, BT_BR_EDR_TRANSPORT):
-            raise ValueError('invalid transport')
+            raise InvalidArgumentError('invalid transport')
 
         # Adjust the transport automatically if we need to
         if transport == BT_LE_TRANSPORT and not self.le_enabled:
@@ -2493,11 +3071,19 @@
 
         if isinstance(peer_address, str):
             try:
-                peer_address = Address.from_string_for_transport(
-                    peer_address, transport
-                )
-            except ValueError:
+                if transport == BT_LE_TRANSPORT and peer_address.endswith('@'):
+                    peer_address = Address.from_string_for_transport(
+                        peer_address[:-1], transport
+                    )
+                    always_resolve = True
+                    logger.debug('forcing address resolution')
+                else:
+                    peer_address = Address.from_string_for_transport(
+                        peer_address, transport
+                    )
+            except (InvalidArgumentError, ValueError):
                 # If the address is not parsable, assume it is a name instead
+                always_resolve = False
                 logger.debug('looking for peer by name')
                 peer_address = await self.find_peer_by_name(
                     peer_address, transport
@@ -2508,10 +3094,16 @@
                 transport == BT_BR_EDR_TRANSPORT
                 and peer_address.address_type != Address.PUBLIC_DEVICE_ADDRESS
             ):
-                raise ValueError('BR/EDR addresses must be PUBLIC')
+                raise InvalidArgumentError('BR/EDR addresses must be PUBLIC')
 
         assert isinstance(peer_address, Address)
 
+        if transport == BT_LE_TRANSPORT and always_resolve:
+            logger.debug('resolving address')
+            peer_address = await self.find_peer_by_identity_address(
+                peer_address
+            )  # TODO: timeout
+
         def on_connection(connection):
             if transport == BT_LE_TRANSPORT or (
                 # match BR/EDR connection event against peer address
@@ -2559,7 +3151,7 @@
                         )
                     )
                     if not phys:
-                        raise ValueError('at least one supported PHY needed')
+                        raise InvalidArgumentError('at least one supported PHY needed')
 
                     phy_count = len(phys)
                     initiating_phys = phy_list_to_bits(phys)
@@ -2631,7 +3223,7 @@
                     )
                 else:
                     if HCI_LE_1M_PHY not in connection_parameters_preferences:
-                        raise ValueError('1M PHY preferences required')
+                        raise InvalidArgumentError('1M PHY preferences required')
 
                     prefs = connection_parameters_preferences[HCI_LE_1M_PHY]
                     result = await self.send_command(
@@ -2731,7 +3323,7 @@
         if isinstance(peer_address, str):
             try:
                 peer_address = Address(peer_address)
-            except ValueError:
+            except InvalidArgumentError:
                 # If the address is not parsable, assume it is a name instead
                 logger.debug('looking for peer by name')
                 peer_address = await self.find_peer_by_name(
@@ -2741,7 +3333,7 @@
         assert isinstance(peer_address, Address)
 
         if peer_address == Address.NIL:
-            raise ValueError('accept on nil address')
+            raise InvalidArgumentError('accept on nil address')
 
         # Create a future so that we can wait for the request
         pending_request_fut = asyncio.get_running_loop().create_future()
@@ -2854,7 +3446,7 @@
             if isinstance(peer_address, str):
                 try:
                     peer_address = Address(peer_address)
-                except ValueError:
+                except InvalidArgumentError:
                     # If the address is not parsable, assume it is a name instead
                     logger.debug('looking for peer by name')
                     peer_address = await self.find_peer_by_name(
@@ -2897,10 +3489,10 @@
 
     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')
+            raise InvalidArgumentError('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')
+            raise InvalidArgumentError('tx_time must be between 0x0148 and 0x4290')
 
         return await self.send_command(
             HCI_LE_Set_Data_Length_Command(
@@ -3013,15 +3605,26 @@
             check_result=True,
         )
 
+    async def transfer_periodic_sync(
+        self, connection: Connection, sync_handle: int, service_data: int = 0
+    ) -> None:
+        return await self.send_command(
+            HCI_LE_Periodic_Advertising_Sync_Transfer_Command(
+                connection_handle=connection.handle,
+                service_data=service_data,
+                sync_handle=sync_handle,
+            ),
+            check_result=True,
+        )
+
     async def find_peer_by_name(self, name, transport=BT_LE_TRANSPORT):
         """
-        Scan for a peer with a give name and return its address and transport
+        Scan for a peer with a given name and return its address.
         """
 
         # Create a future to wait for an address to be found
         peer_address = asyncio.get_running_loop().create_future()
 
-        # Scan/inquire with event handlers to handle scan/inquiry results
         def on_peer_found(address, ad_data):
             local_name = ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True)
             if local_name is None:
@@ -3030,13 +3633,13 @@
                 if local_name.decode('utf-8') == name:
                     peer_address.set_result(address)
 
-        handler = None
+        listener = None
         was_scanning = self.scanning
         was_discovering = self.discovering
         try:
             if transport == BT_LE_TRANSPORT:
                 event_name = 'advertisement'
-                handler = self.on(
+                listener = self.on(
                     event_name,
                     lambda advertisement: on_peer_found(
                         advertisement.address, advertisement.data
@@ -3048,7 +3651,7 @@
 
             elif transport == BT_BR_EDR_TRANSPORT:
                 event_name = 'inquiry_result'
-                handler = self.on(
+                listener = self.on(
                     event_name,
                     lambda address, class_of_device, eir_data, rssi: on_peer_found(
                         address, eir_data
@@ -3062,21 +3665,67 @@
 
             return await self.abort_on('flush', peer_address)
         finally:
-            if handler is not None:
-                self.remove_listener(event_name, handler)
+            if listener is not None:
+                self.remove_listener(event_name, listener)
 
             if transport == BT_LE_TRANSPORT and not was_scanning:
                 await self.stop_scanning()
             elif transport == BT_BR_EDR_TRANSPORT and not was_discovering:
                 await self.stop_discovery()
 
+    async def find_peer_by_identity_address(self, identity_address: Address) -> Address:
+        """
+        Scan for a peer with a resolvable address that can be resolved to a given
+        identity address.
+        """
+
+        # Create a future to wait for an address to be found
+        peer_address = asyncio.get_running_loop().create_future()
+
+        def on_peer_found(address, _):
+            if address == identity_address:
+                if not peer_address.done():
+                    logger.debug(f'*** Matching public address found for {address}')
+                    peer_address.set_result(address)
+                return
+
+            if address.is_resolvable:
+                resolved_address = self.address_resolver.resolve(address)
+                if resolved_address == identity_address:
+                    if not peer_address.done():
+                        logger.debug(f'*** Matching identity found for {address}')
+                        peer_address.set_result(address)
+                return
+
+        was_scanning = self.scanning
+        event_name = 'advertisement'
+        listener = None
+        try:
+            listener = self.on(
+                event_name,
+                lambda advertisement: on_peer_found(
+                    advertisement.address, advertisement.data
+                ),
+            )
+
+            if not self.scanning:
+                await self.start_scanning(filter_duplicates=True)
+
+            return await self.abort_on('flush', peer_address)
+        finally:
+            if listener is not None:
+                self.remove_listener(event_name, listener)
+
+            if not was_scanning:
+                await self.stop_scanning()
+
     @property
-    def pairing_config_factory(self) -> Callable[[Connection], PairingConfig]:
+    def pairing_config_factory(self) -> Callable[[Connection], pairing.PairingConfig]:
         return self.smp_manager.pairing_config_factory
 
     @pairing_config_factory.setter
     def pairing_config_factory(
-        self, pairing_config_factory: Callable[[Connection], PairingConfig]
+        self, pairing_config_factory: Callable[[Connection], pairing.PairingConfig]
     ) -> None:
         self.smp_manager.pairing_config_factory = pairing_config_factory
 
@@ -3175,7 +3824,7 @@
 
     async def encrypt(self, connection, enable=True):
         if not enable and connection.transport == BT_LE_TRANSPORT:
-            raise ValueError('`enable` parameter is classic only.')
+            raise InvalidArgumentError('`enable` parameter is classic only.')
 
         # Set up event handlers
         pending_encryption = asyncio.get_running_loop().create_future()
@@ -3194,11 +3843,12 @@
             if connection.transport == BT_LE_TRANSPORT:
                 # Look for a key in the key store
                 if self.keystore is None:
-                    raise RuntimeError('no key store')
+                    raise InvalidOperationError('no key store')
 
+                logger.debug(f'Looking up key for {connection.peer_address}')
                 keys = await self.keystore.get(str(connection.peer_address))
                 if keys is None:
-                    raise RuntimeError('keys not found in key store')
+                    raise InvalidOperationError('keys not found in key store')
 
                 if keys.ltk is not None:
                     ltk = keys.ltk.value
@@ -3209,7 +3859,7 @@
                     rand = keys.ltk_central.rand
                     ediv = keys.ltk_central.ediv
                 else:
-                    raise RuntimeError('no LTK found for peer')
+                    raise InvalidOperationError('no LTK found for peer')
 
                 if connection.role != HCI_CENTRAL_ROLE:
                     raise InvalidStateError('only centrals can start encryption')
@@ -3484,7 +4134,7 @@
                 return cis_link
 
         # Mypy believes this is reachable when context is an ExitStack.
-        raise InvalidStateError('Unreachable')
+        raise UnreachableError()
 
     # [LE only]
     @experimental('Only for testing.')
@@ -3605,18 +4255,38 @@
             )
             return
 
-        if not (connection := self.lookup_connection(connection_handle)):
-            logger.warning(f'no connection for handle 0x{connection_handle:04x}')
+        if connection := self.lookup_connection(connection_handle):
+            # We have already received the connection complete event.
+            self._complete_le_extended_advertising_connection(
+                connection, advertising_set
+            )
             return
 
+        # Associate the connection handle with the advertising set, the connection
+        # will complete later.
+        logger.debug(
+            f'the connection with handle {connection_handle:04X} will complete later'
+        )
+        self.connecting_extended_advertising_sets[connection_handle] = advertising_set
+
+    def _complete_le_extended_advertising_connection(
+        self, connection: Connection, advertising_set: AdvertisingSet
+    ) -> None:
         # Update the connection address.
         connection.self_address = (
             advertising_set.random_address
-            if advertising_set.advertising_parameters.own_address_type
+            if advertising_set.random_address is not None
+            and advertising_set.advertising_parameters.own_address_type
             in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
             else self.public_address
         )
 
+        if advertising_set.advertising_parameters.own_address_type in (
+            OwnAddressType.RANDOM,
+            OwnAddressType.PUBLIC,
+        ):
+            connection.self_resolvable_address = None
+
         # Setup auto-restart of the advertising set if needed.
         if advertising_set.auto_restart:
             connection.once(
@@ -3652,12 +4322,23 @@
     @host_event_handler
     def on_connection(
         self,
-        connection_handle,
-        transport,
-        peer_address,
-        role,
-        connection_parameters,
-    ):
+        connection_handle: int,
+        transport: int,
+        peer_address: Address,
+        self_resolvable_address: Optional[Address],
+        peer_resolvable_address: Optional[Address],
+        role: int,
+        connection_parameters: ConnectionParameters,
+    ) -> None:
+        # Convert all-zeros addresses into None.
+        if self_resolvable_address == Address.ANY_RANDOM:
+            self_resolvable_address = None
+        if (
+            peer_resolvable_address == Address.ANY_RANDOM
+            or not peer_address.is_resolved
+        ):
+            peer_resolvable_address = None
+
         logger.debug(
             f'*** Connection: [0x{connection_handle:04X}] '
             f'{peer_address} {"" if role is None else HCI_Constant.role_name(role)}'
@@ -3678,17 +4359,18 @@
 
             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
+        if peer_resolvable_address is None:
+            # 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
 
         self_address = None
+        own_address_type: Optional[int] = None
         if role == HCI_CENTRAL_ROLE:
             own_address_type = self.connect_own_address_type
             assert own_address_type is not None
@@ -3717,12 +4399,18 @@
                 else self.random_address
             )
 
+        # Some controllers may return local resolvable address even not using address
+        # generation offloading. Ignore the value to prevent SMP failure.
+        if own_address_type in (OwnAddressType.RANDOM, OwnAddressType.PUBLIC):
+            self_resolvable_address = None
+
         # Create a connection.
         connection = Connection(
             self,
             connection_handle,
             transport,
             self_address,
+            self_resolvable_address,
             peer_address,
             peer_resolvable_address,
             role,
@@ -3733,9 +4421,10 @@
 
         if role == HCI_PERIPHERAL_ROLE and self.legacy_advertiser:
             if self.legacy_advertiser.auto_restart:
+                advertiser = self.legacy_advertiser
                 connection.once(
                     'disconnection',
-                    lambda _: self.abort_on('flush', self.legacy_advertiser.start()),
+                    lambda _: self.abort_on('flush', advertiser.start()),
                 )
             else:
                 self.legacy_advertiser = None
@@ -3743,6 +4432,16 @@
         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)
+            return
+
+        if role == HCI_PERIPHERAL_ROLE and self.supports_le_extended_advertising:
+            if advertising_set := self.connecting_extended_advertising_sets.pop(
+                connection_handle, None
+            ):
+                # We have already received the advertising set termination event.
+                self._complete_le_extended_advertising_connection(
+                    connection, advertising_set
+                )
 
     @host_event_handler
     def on_connection_failure(self, transport, peer_address, error_code):
@@ -3948,7 +4647,7 @@
             return await pairing_config.delegate.confirm(auto=True)
 
         async def na() -> bool:
-            assert False, "N/A: unreachable"
+            raise UnreachableError()
 
         # See Bluetooth spec @ Vol 3, Part C 5.2.2.6
         methods = {
@@ -4409,5 +5108,6 @@
         return (
             f'Device(name="{self.name}", '
             f'random_address="{self.random_address}", '
-            f'public_address="{self.public_address}")'
+            f'public_address="{self.public_address}", '
+            f'static_address="{self.static_address}")'
         )
diff --git a/bumble/drivers/rtk.py b/bumble/drivers/rtk.py
index 4a9034d..1336d2c 100644
--- a/bumble/drivers/rtk.py
+++ b/bumble/drivers/rtk.py
@@ -33,6 +33,7 @@
 import weakref
 
 
+from bumble import core
 from bumble.hci import (
     hci_vendor_command_op_code,
     STATUS_SPEC,
@@ -49,6 +50,10 @@
 logger = logging.getLogger(__name__)
 
 
+class RtkFirmwareError(core.BaseBumbleError):
+    """Error raised when RTK firmware initialization fails."""
+
+
 # -----------------------------------------------------------------------------
 # Constants
 # -----------------------------------------------------------------------------
@@ -208,15 +213,15 @@
         extension_sig = bytes([0x51, 0x04, 0xFD, 0x77])
 
         if not firmware.startswith(RTK_EPATCH_SIGNATURE):
-            raise ValueError("Firmware does not start with epatch signature")
+            raise RtkFirmwareError("Firmware does not start with epatch signature")
 
         if not firmware.endswith(extension_sig):
-            raise ValueError("Firmware does not end with extension sig")
+            raise RtkFirmwareError("Firmware does not end with extension sig")
 
         # The firmware should start with a 14 byte header.
         epatch_header_size = 14
         if len(firmware) < epatch_header_size:
-            raise ValueError("Firmware too short")
+            raise RtkFirmwareError("Firmware too short")
 
         # Look for the "project ID", starting from the end.
         offset = len(firmware) - len(extension_sig)
@@ -230,7 +235,7 @@
                 break
 
             if length == 0:
-                raise ValueError("Invalid 0-length instruction")
+                raise RtkFirmwareError("Invalid 0-length instruction")
 
             if opcode == 0 and length == 1:
                 project_id = firmware[offset - 1]
@@ -239,7 +244,7 @@
             offset -= length
 
         if project_id < 0:
-            raise ValueError("Project ID not found")
+            raise RtkFirmwareError("Project ID not found")
 
         self.project_id = project_id
 
@@ -252,7 +257,7 @@
         # <PatchLength_1><PatchLength_2>...<PatchLength_N> (16 bits each)
         # <PatchOffset_1><PatchOffset_2>...<PatchOffset_N> (32 bits each)
         if epatch_header_size + 8 * num_patches > len(firmware):
-            raise ValueError("Firmware too short")
+            raise RtkFirmwareError("Firmware too short")
         chip_id_table_offset = epatch_header_size
         patch_length_table_offset = chip_id_table_offset + 2 * num_patches
         patch_offset_table_offset = chip_id_table_offset + 4 * num_patches
@@ -266,7 +271,7 @@
                 "<I", firmware, patch_offset_table_offset + 4 * patch_index
             )
             if patch_offset + patch_length > len(firmware):
-                raise ValueError("Firmware too short")
+                raise RtkFirmwareError("Firmware too short")
 
             # Get the SVN version for the patch
             (svn_version,) = struct.unpack_from(
@@ -645,7 +650,7 @@
         ):
             return await self.download_for_rtl8723b()
 
-        raise ValueError("ROM not supported")
+        raise RtkFirmwareError("ROM not supported")
 
     async def init_controller(self):
         await self.download_firmware()
diff --git a/bumble/gatt.py b/bumble/gatt.py
index 896cec0..438c17c 100644
--- a/bumble/gatt.py
+++ b/bumble/gatt.py
@@ -39,7 +39,7 @@
 )
 
 from bumble.colors import color
-from bumble.core import UUID
+from bumble.core import BaseBumbleError, UUID
 from bumble.att import Attribute, AttributeValue
 
 if TYPE_CHECKING:
@@ -321,6 +321,11 @@
 
 
 # -----------------------------------------------------------------------------
+class InvalidServiceError(BaseBumbleError):
+    """The service is not compliant with the spec/profile"""
+
+
+# -----------------------------------------------------------------------------
 class Service(Attribute):
     '''
     See Vol 3, Part G - 3.1 SERVICE DEFINITION
diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py
index c71aabd..f2b8df6 100644
--- a/bumble/gatt_client.py
+++ b/bumble/gatt_client.py
@@ -253,7 +253,7 @@
     SERVICE_CLASS: Type[TemplateService]
 
     @classmethod
-    def from_client(cls, client: Client) -> ProfileServiceProxy:
+    def from_client(cls, client: Client) -> Optional[ProfileServiceProxy]:
         return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
 
 
@@ -283,6 +283,8 @@
         self.services = []
         self.cached_values = {}
 
+        connection.on('disconnection', self.on_disconnection)
+
     def send_gatt_pdu(self, pdu: bytes) -> None:
         self.connection.send_l2cap_pdu(ATT_CID, pdu)
 
@@ -331,9 +333,9 @@
     async def request_mtu(self, mtu: int) -> int:
         # Check the range
         if mtu < ATT_DEFAULT_MTU:
-            raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
+            raise core.InvalidArgumentError(f'MTU must be >= {ATT_DEFAULT_MTU}')
         if mtu > 0xFFFF:
-            raise ValueError('MTU must be <= 0xFFFF')
+            raise core.InvalidArgumentError('MTU must be <= 0xFFFF')
 
         # We can only send one request per connection
         if self.mtu_exchange_done:
@@ -405,7 +407,7 @@
         if not already_known:
             self.services.append(service)
 
-    async def discover_services(self, uuids: Iterable[UUID] = []) -> List[ServiceProxy]:
+    async def discover_services(self, uuids: Iterable[UUID] = ()) -> List[ServiceProxy]:
         '''
         See Vol 3, Part G - 4.4.1 Discover All Primary Services
         '''
@@ -1072,6 +1074,10 @@
                 )
             )
 
+    def on_disconnection(self, _) -> None:
+        if self.pending_response and not self.pending_response.done():
+            self.pending_response.cancel()
+
     def on_gatt_pdu(self, att_pdu: ATT_PDU) -> None:
         logger.debug(
             f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
diff --git a/bumble/hci.py b/bumble/hci.py
index 9ef40bf..af39976 100644
--- a/bumble/hci.py
+++ b/bumble/hci.py
@@ -26,16 +26,19 @@
 from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
 
 from bumble import crypto
-from .colors import color
-from .core import (
+from bumble.colors import color
+from bumble.core import (
     BT_BR_EDR_TRANSPORT,
     AdvertisingData,
     DeviceClass,
+    InvalidArgumentError,
+    InvalidPacketError,
     ProtocolError,
     bit_flags_to_strings,
     name_or_number,
     padded_bytes,
 )
+from bumble.utils import OpenIntEnum
 
 
 # -----------------------------------------------------------------------------
@@ -91,14 +94,14 @@
     )
 
 
-def phy_list_to_bits(phys):
+def phy_list_to_bits(phys: Optional[Iterable[int]]) -> int:
     if phys is None:
         return 0
 
     phy_bits = 0
     for phy in phys:
         if phy not in HCI_LE_PHY_TYPE_TO_BIT:
-            raise ValueError('invalid PHY')
+            raise InvalidArgumentError('invalid PHY')
         phy_bits |= 1 << HCI_LE_PHY_TYPE_TO_BIT[phy]
     return phy_bits
 
@@ -1104,7 +1107,7 @@
 
 # LE Supported Features
 # See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
-class LeFeature(enum.IntEnum):
+class LeFeature(OpenIntEnum):
     LE_ENCRYPTION                                  = 0
     CONNECTION_PARAMETERS_REQUEST_PROCEDURE        = 1
     EXTENDED_REJECT_INDICATION                     = 2
@@ -1380,7 +1383,7 @@
 STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
 
 
-class CodecID(enum.IntEnum):
+class CodecID(OpenIntEnum):
     # fmt: off
     U_LOG           = 0x00
     A_LOG           = 0x01
@@ -1552,7 +1555,7 @@
             new_offset, field_value = field_type(data, offset)
             return (field_value, new_offset - offset)
 
-        raise ValueError(f'unknown field type {field_type}')
+        raise InvalidArgumentError(f'unknown field type {field_type}')
 
     @staticmethod
     def dict_from_bytes(data, offset, fields):
@@ -1621,7 +1624,7 @@
                 if 0 <= field_value <= 255:
                     field_bytes = bytes([field_value])
                 else:
-                    raise ValueError('value too large for *-typed field')
+                    raise InvalidArgumentError('value too large for *-typed field')
             else:
                 field_bytes = bytes(field_value)
         elif field_type == 'v':
@@ -1640,7 +1643,9 @@
                 elif len(field_bytes) > field_type:
                     field_bytes = field_bytes[:field_type]
         else:
-            raise ValueError(f"don't know how to serialize type {type(field_value)}")
+            raise InvalidArgumentError(
+                f"don't know how to serialize type {type(field_value)}"
+            )
 
         return field_bytes
 
@@ -1835,6 +1840,12 @@
         )
 
     @staticmethod
+    def parse_random_address(data, offset):
+        return Address.parse_address_with_type(
+            data, offset, Address.RANDOM_DEVICE_ADDRESS
+        )
+
+    @staticmethod
     def parse_address_with_type(data, offset, address_type):
         return offset + 6, Address(data[offset : offset + 6], address_type)
 
@@ -1904,7 +1915,7 @@
             self.address_bytes = bytes(reversed(bytes.fromhex(address)))
 
         if len(self.address_bytes) != 6:
-            raise ValueError('invalid address length')
+            raise InvalidArgumentError('invalid address length')
 
         self.address_type = address_type
 
@@ -1960,13 +1971,17 @@
 
     def __eq__(self, other):
         return (
-            self.address_bytes == other.address_bytes
+            isinstance(other, Address)
+            and self.address_bytes == other.address_bytes
             and self.is_public == other.is_public
         )
 
     def __str__(self):
         return self.to_string()
 
+    def __repr__(self):
+        return f'Address({self.to_string(False)}/{self.address_type_name(self.address_type)})'
+
 
 # Predefined address values
 Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS)
@@ -2104,7 +2119,7 @@
         op_code, length = struct.unpack_from('<HB', packet, 1)
         parameters = packet[4:]
         if len(parameters) != length:
-            raise ValueError('invalid packet length')
+            raise InvalidPacketError('invalid packet length')
 
         # Look for a registered class
         cls = HCI_Command.command_classes.get(op_code)
@@ -4455,6 +4470,68 @@
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
     [
+        (
+            'options',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_LE_Periodic_Advertising_Create_Sync_Command.Options(
+                    x
+                ).name,
+            },
+        ),
+        ('advertising_sid', 1),
+        ('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
+        ('advertiser_address', Address.parse_address_preceded_by_type),
+        ('skip', 2),
+        ('sync_timeout', 2),
+        (
+            'sync_cte_type',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_LE_Periodic_Advertising_Create_Sync_Command.CteType(
+                    x
+                ).name,
+            },
+        ),
+    ]
+)
+class HCI_LE_Periodic_Advertising_Create_Sync_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.67 LE Periodic Advertising Create Sync command
+    '''
+
+    class Options(enum.IntFlag):
+        USE_PERIODIC_ADVERTISER_LIST = 1 << 0
+        REPORTING_INITIALLY_DISABLED = 1 << 1
+        DUPLICATE_FILTERING_INITIALLY_ENABLED = 1 << 2
+
+    class CteType(enum.IntFlag):
+        DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOA_CONSTANT_TONE_EXTENSION = 1 << 0
+        DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOD_CONSTANT_TONE_EXTENSION_1US = 1 << 1
+        DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOD_CONSTANT_TONE_EXTENSION_2US = 1 << 2
+        DO_NOT_SYNC_TO_PACKETS_WITH_A_TYPE_3_CONSTANT_TONE_EXTENSION = 1 << 3
+        DO_NOT_SYNC_TO_PACKETS_WITHOUT_A_CONSTANT_TONE_EXTENSION = 1 << 4
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command()
+class HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.68 LE Periodic Advertising Create Sync Cancel Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([('sync_handle', 2)])
+class HCI_LE_Periodic_Advertising_Terminate_Sync_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.69 LE Periodic Advertising Terminate Sync Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    [
         ('peer_identity_address_type', Address.ADDRESS_TYPE_SPEC),
         ('peer_identity_address', Address.parse_address_preceded_by_type),
         (
@@ -4488,10 +4565,28 @@
 
 
 # -----------------------------------------------------------------------------
-@HCI_Command.command([('bit_number', 1), ('bit_value', 1)])
-class HCI_LE_Set_Host_Feature_Command(HCI_Command):
+@HCI_Command.command([('sync_handle', 2), ('enable', 1)])
+class HCI_LE_Set_Periodic_Advertising_Receive_Enable_Command(HCI_Command):
     '''
-    See Bluetooth spec @ 7.8.115 LE Set Host Feature Command
+    See Bluetooth spec @ 7.8.88 LE Set Periodic Advertising Receive Enable Command
+    '''
+
+    class Enable(enum.IntFlag):
+        REPORTING_ENABLED = 1 << 0
+        DUPLICATE_FILTERING_ENABLED = 1 << 1
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[('connection_handle', 2), ('service_data', 2), ('sync_handle', 2)],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('connection_handle', 2),
+    ],
+)
+class HCI_LE_Periodic_Advertising_Sync_Transfer_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.89 LE Periodic Advertising Sync Transfer Command
     '''
 
 
@@ -4656,6 +4751,14 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Command.command([('bit_number', 1), ('bit_value', 1)])
+class HCI_LE_Set_Host_Feature_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.115 LE Set Host Feature Command
+    '''
+
+
+# -----------------------------------------------------------------------------
 # HCI Events
 # -----------------------------------------------------------------------------
 class HCI_Event(HCI_Packet):
@@ -4729,7 +4832,7 @@
         length = packet[2]
         parameters = packet[3:]
         if len(parameters) != length:
-            raise ValueError('invalid packet length')
+            raise InvalidPacketError('invalid packet length')
 
         cls: Any
         if event_code == HCI_LE_META_EVENT:
@@ -5096,8 +5199,8 @@
         ),
         ('peer_address_type', Address.ADDRESS_TYPE_SPEC),
         ('peer_address', Address.parse_address_preceded_by_type),
-        ('local_resolvable_private_address', Address.parse_address),
-        ('peer_resolvable_private_address', Address.parse_address),
+        ('local_resolvable_private_address', Address.parse_random_address),
+        ('peer_resolvable_private_address', Address.parse_random_address),
         ('connection_interval', 2),
         ('peripheral_latency', 2),
         ('supervision_timeout', 2),
@@ -5274,6 +5377,142 @@
 # -----------------------------------------------------------------------------
 @HCI_LE_Meta_Event.event(
     [
+        ('status', STATUS_SPEC),
+        ('sync_handle', 2),
+        ('advertising_sid', 1),
+        ('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
+        ('advertiser_address', Address.parse_address_preceded_by_type),
+        ('advertiser_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+        ('periodic_advertising_interval', 2),
+        ('advertiser_clock_accuracy', 1),
+    ]
+)
+class HCI_LE_Periodic_Advertising_Sync_Established_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.14 LE Periodic Advertising Sync Established Event
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
+        ('status', STATUS_SPEC),
+        ('sync_handle', 2),
+        ('advertising_sid', 1),
+        ('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
+        ('advertiser_address', Address.parse_address_preceded_by_type),
+        ('advertiser_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+        ('periodic_advertising_interval', 2),
+        ('advertiser_clock_accuracy', 1),
+        ('num_subevents', 1),
+        ('subevent_interval', 1),
+        ('response_slot_delay', 1),
+        ('response_slot_spacing', 1),
+    ]
+)
+class HCI_LE_Periodic_Advertising_Sync_Established_V2_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.14 LE Periodic Advertising Sync Established Event
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
+        ('sync_handle', 2),
+        ('tx_power', -1),
+        ('rssi', -1),
+        (
+            'cte_type',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.CteType(
+                    x
+                ).name,
+            },
+        ),
+        (
+            'data_status',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.DataStatus(
+                    x
+                ).name,
+            },
+        ),
+        ('data', 'v'),
+    ]
+)
+class HCI_LE_Periodic_Advertising_Report_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.15 LE Periodic Advertising Report Event
+    '''
+
+    TX_POWER_INFORMATION_NOT_AVAILABLE = 0x7F
+    RSSI_NOT_AVAILABLE = 0x7F
+
+    class CteType(OpenIntEnum):
+        AOA_CONSTANT_TONE_EXTENSION = 0x00
+        AOD_CONSTANT_TONE_EXTENSION_1US = 0x01
+        AOD_CONSTANT_TONE_EXTENSION_2US = 0x02
+        NO_CONSTANT_TONE_EXTENSION = 0xFF
+
+    class DataStatus(OpenIntEnum):
+        DATA_COMPLETE = 0x00
+        DATA_INCOMPLETE_MORE_TO_COME = 0x01
+        DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME = 0x02
+
+
+# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
+        ('sync_handle', 2),
+        ('tx_power', -1),
+        ('rssi', -1),
+        (
+            'cte_type',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.CteType(
+                    x
+                ).name,
+            },
+        ),
+        ('periodic_event_counter', 2),
+        ('subevent', 1),
+        (
+            'data_status',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.DataStatus(
+                    x
+                ).name,
+            },
+        ),
+        ('data', 'v'),
+    ]
+)
+class HCI_LE_Periodic_Advertising_Report_V2_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.15 LE Periodic Advertising Report Event
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
+        ('sync_handle', 2),
+    ]
+)
+class HCI_LE_Periodic_Advertising_Sync_Lost_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.16 LE Periodic Advertising Sync Lost Event
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
         ('status', 1),
         ('advertising_handle', 1),
         ('connection_handle', 2),
@@ -5337,6 +5576,30 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+    [
+        ('sync_handle', 2),
+        ('num_bis', 1),
+        ('nse', 1),
+        ('iso_interval', 2),
+        ('bn', 1),
+        ('pto', 1),
+        ('irc', 1),
+        ('max_pdu', 2),
+        ('sdu_interval', 3),
+        ('max_sdu', 2),
+        ('phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+        ('framing', 1),
+        ('encryption', 1),
+    ]
+)
+class HCI_LE_BIGInfo_Advertising_Report_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.34 LE BIGInfo Advertising Report Event
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Event.event([('status', STATUS_SPEC)])
 class HCI_Inquiry_Complete_Event(HCI_Event):
     '''
@@ -6104,7 +6367,7 @@
         bc_flag = (h >> 14) & 3
         data = packet[5:]
         if len(data) != data_total_length:
-            raise ValueError('invalid packet length')
+            raise InvalidPacketError('invalid packet length')
         return HCI_AclDataPacket(
             connection_handle, pb_flag, bc_flag, data_total_length, data
         )
@@ -6152,7 +6415,7 @@
         packet_status = (h >> 12) & 0b11
         data = packet[4:]
         if len(data) != data_total_length:
-            raise ValueError(
+            raise InvalidPacketError(
                 f'invalid packet length {len(data)} != {data_total_length}'
             )
         return HCI_SynchronousDataPacket(
diff --git a/bumble/hid.py b/bumble/hid.py
index 1b4aa00..d4a2a72 100644
--- a/bumble/hid.py
+++ b/bumble/hid.py
@@ -23,13 +23,12 @@
 
 from abc import ABC, abstractmethod
 from pyee import EventEmitter
-from typing import Optional, Callable, TYPE_CHECKING
+from typing import Optional, Callable
 from typing_extensions import override
 
 from bumble import l2cap, device
-from bumble.colors import color
 from bumble.core import InvalidStateError, ProtocolError
-from .hci import Address
+from bumble.hci import Address
 
 
 # -----------------------------------------------------------------------------
@@ -220,31 +219,27 @@
     async def connect_control_channel(self) -> None:
         # Create a new L2CAP connection - control channel
         try:
-            self.l2cap_ctrl_channel = await self.device.l2cap_channel_manager.connect(
+            channel = await self.device.l2cap_channel_manager.connect(
                 self.connection, HID_CONTROL_PSM
             )
+            channel.sink = self.on_ctrl_pdu
+            self.l2cap_ctrl_channel = channel
         except ProtocolError:
             logging.exception(f'L2CAP connection failed.')
             raise
 
-        assert self.l2cap_ctrl_channel is not None
-        # Become a sink for the L2CAP channel
-        self.l2cap_ctrl_channel.sink = self.on_ctrl_pdu
-
     async def connect_interrupt_channel(self) -> None:
         # Create a new L2CAP connection - interrupt channel
         try:
-            self.l2cap_intr_channel = await self.device.l2cap_channel_manager.connect(
+            channel = await self.device.l2cap_channel_manager.connect(
                 self.connection, HID_INTERRUPT_PSM
             )
+            channel.sink = self.on_intr_pdu
+            self.l2cap_intr_channel = channel
         except ProtocolError:
             logging.exception(f'L2CAP connection failed.')
             raise
 
-        assert self.l2cap_intr_channel is not None
-        # Become a sink for the L2CAP channel
-        self.l2cap_intr_channel.sink = self.on_intr_pdu
-
     async def disconnect_interrupt_channel(self) -> None:
         if self.l2cap_intr_channel is None:
             raise InvalidStateError('invalid state')
@@ -334,17 +329,18 @@
         ERR_INVALID_PARAMETER = 0x04
         SUCCESS = 0xFF
 
+    @dataclass
     class GetSetStatus:
-        def __init__(self) -> None:
-            self.data = bytearray()
-            self.status = 0
+        data: bytes = b''
+        status: int = 0
+
+    get_report_cb: Optional[Callable[[int, int, int], GetSetStatus]] = None
+    set_report_cb: Optional[Callable[[int, int, int, bytes], GetSetStatus]] = None
+    get_protocol_cb: Optional[Callable[[], GetSetStatus]] = None
+    set_protocol_cb: Optional[Callable[[int], GetSetStatus]] = None
 
     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:
@@ -410,7 +406,6 @@
             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:
@@ -428,7 +423,9 @@
         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:
+    def register_get_report_cb(
+        self, cb: Callable[[int, int, int], Device.GetSetStatus]
+    ) -> None:
         self.get_report_cb = cb
         logger.debug("GetReport callback registered successfully")
 
@@ -442,7 +439,6 @@
         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:
@@ -453,7 +449,7 @@
             self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
 
     def register_set_report_cb(
-        self, cb: Callable[[int, int, int, bytes], None]
+        self, cb: Callable[[int, int, int, bytes], Device.GetSetStatus]
     ) -> None:
         self.set_report_cb = cb
         logger.debug("SetReport callback registered successfully")
@@ -464,13 +460,12 @@
             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:
+    def register_get_protocol_cb(self, cb: Callable[[], Device.GetSetStatus]) -> None:
         self.get_protocol_cb = cb
         logger.debug("GetProtocol callback registered successfully")
 
@@ -480,13 +475,14 @@
             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:
+    def register_set_protocol_cb(
+        self, cb: Callable[[int], Device.GetSetStatus]
+    ) -> None:
         self.set_protocol_cb = cb
         logger.debug("SetProtocol callback registered successfully")
 
diff --git a/bumble/host.py b/bumble/host.py
index 64b6668..8085d5c 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -772,6 +772,8 @@
                 event.connection_handle,
                 BT_LE_TRANSPORT,
                 event.peer_address,
+                getattr(event, 'local_resolvable_private_address', None),
+                getattr(event, 'peer_resolvable_private_address', None),
                 event.role,
                 connection_parameters,
             )
@@ -787,6 +789,10 @@
         # Just use the same implementation as for the non-enhanced event for now
         self.on_hci_le_connection_complete_event(event)
 
+    def on_hci_le_enhanced_connection_complete_v2_event(self, event):
+        # Just use the same implementation as for the v1 event for now
+        self.on_hci_le_enhanced_connection_complete_event(event)
+
     def on_hci_connection_complete_event(self, event):
         if event.status == hci.HCI_SUCCESS:
             # Create/update the connection
@@ -813,6 +819,8 @@
                 event.bd_addr,
                 None,
                 None,
+                None,
+                None,
             )
         else:
             logger.debug(f'### BR/EDR CONNECTION FAILED: {event.status}')
@@ -905,6 +913,27 @@
             event.num_completed_extended_advertising_events,
         )
 
+    def on_hci_le_periodic_advertising_sync_established_event(self, event):
+        self.emit(
+            'periodic_advertising_sync_establishment',
+            event.status,
+            event.sync_handle,
+            event.advertising_sid,
+            event.advertiser_address,
+            event.advertiser_phy,
+            event.periodic_advertising_interval,
+            event.advertiser_clock_accuracy,
+        )
+
+    def on_hci_le_periodic_advertising_sync_lost_event(self, event):
+        self.emit('periodic_advertising_sync_loss', event.sync_handle)
+
+    def on_hci_le_periodic_advertising_report_event(self, event):
+        self.emit('periodic_advertising_report', event.sync_handle, event)
+
+    def on_hci_le_biginfo_advertising_report_event(self, event):
+        self.emit('biginfo_advertising_report', event.sync_handle, event)
+
     def on_hci_le_cis_request_event(self, event):
         self.emit(
             'cis_request',
diff --git a/bumble/l2cap.py b/bumble/l2cap.py
index b4f0121..53c84d5 100644
--- a/bumble/l2cap.py
+++ b/bumble/l2cap.py
@@ -41,7 +41,14 @@
 
 from .utils import deprecated
 from .colors import color
-from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
+from .core import (
+    BT_CENTRAL_ROLE,
+    InvalidStateError,
+    InvalidArgumentError,
+    InvalidPacketError,
+    OutOfResourcesError,
+    ProtocolError,
+)
 from .hci import (
     HCI_LE_Connection_Update_Command,
     HCI_Object,
@@ -189,17 +196,17 @@
             self.max_credits < 1
             or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
         ):
-            raise ValueError('max credits out of range')
+            raise InvalidArgumentError('max credits out of range')
         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')
+            raise InvalidArgumentError('MTU out of range')
         if (
             self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
             or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
         ):
-            raise ValueError('MPS out of range')
+            raise InvalidArgumentError('MPS out of range')
 
 
 class L2CAP_PDU:
@@ -211,7 +218,7 @@
     def from_bytes(data: bytes) -> L2CAP_PDU:
         # Check parameters
         if len(data) < 4:
-            raise ValueError('not enough data for L2CAP header')
+            raise InvalidPacketError('not enough data for L2CAP header')
 
         _, l2cap_pdu_cid = struct.unpack_from('<HH', data, 0)
         l2cap_pdu_payload = data[4:]
@@ -816,7 +823,7 @@
 
         # Check that we can start a new connection
         if self.connection_result:
-            raise RuntimeError('connection already pending')
+            raise InvalidStateError('connection already pending')
 
         self._change_state(self.State.WAIT_CONNECT_RSP)
         self.send_control_frame(
@@ -1129,7 +1136,7 @@
         # Check that we can start a new connection
         identifier = self.manager.next_identifier(self.connection)
         if identifier in self.manager.le_coc_requests:
-            raise RuntimeError('too many concurrent connection requests')
+            raise InvalidStateError('too many concurrent connection requests')
 
         self._change_state(self.State.CONNECTING)
         request = L2CAP_LE_Credit_Based_Connection_Request(
@@ -1516,7 +1523,7 @@
             if cid not in channels:
                 return cid
 
-        raise RuntimeError('no free CID available')
+        raise OutOfResourcesError('no free CID available')
 
     @staticmethod
     def find_free_le_cid(channels: Iterable[int]) -> int:
@@ -1529,7 +1536,7 @@
             if cid not in channels:
                 return cid
 
-        raise RuntimeError('no free CID')
+        raise OutOfResourcesError('no free CID')
 
     def next_identifier(self, connection: Connection) -> int:
         identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
@@ -1576,15 +1583,15 @@
         else:
             # Check that the PSM isn't already in use
             if spec.psm in self.servers:
-                raise ValueError('PSM already in use')
+                raise InvalidArgumentError('PSM already in use')
 
             # Check that the PSM is valid
             if spec.psm % 2 == 0:
-                raise ValueError('invalid PSM (not odd)')
+                raise InvalidArgumentError('invalid PSM (not odd)')
             check = spec.psm >> 8
             while check:
                 if check % 2 != 0:
-                    raise ValueError('invalid PSM')
+                    raise InvalidArgumentError('invalid PSM')
                 check >>= 8
 
         self.servers[spec.psm] = ClassicChannelServer(self, spec.psm, handler, spec.mtu)
@@ -1626,7 +1633,7 @@
         else:
             # Check that the PSM isn't already in use
             if spec.psm in self.le_coc_servers:
-                raise ValueError('PSM already in use')
+                raise InvalidArgumentError('PSM already in use')
 
         self.le_coc_servers[spec.psm] = LeCreditBasedChannelServer(
             self,
@@ -2154,10 +2161,10 @@
         connection_channels = self.channels.setdefault(connection.handle, {})
         source_cid = self.find_free_le_cid(connection_channels)
         if source_cid is None:  # Should never happen!
-            raise RuntimeError('all CIDs already in use')
+            raise OutOfResourcesError('all CIDs already in use')
 
         if spec.psm is None:
-            raise ValueError('PSM cannot be None')
+            raise InvalidArgumentError('PSM cannot be None')
 
         # Create the channel
         logger.debug(f'creating coc channel with cid={source_cid} for psm {spec.psm}')
@@ -2206,10 +2213,10 @@
         connection_channels = self.channels.setdefault(connection.handle, {})
         source_cid = self.find_free_br_edr_cid(connection_channels)
         if source_cid is None:  # Should never happen!
-            raise RuntimeError('all CIDs already in use')
+            raise OutOfResourcesError('all CIDs already in use')
 
         if spec.psm is None:
-            raise ValueError('PSM cannot be None')
+            raise InvalidArgumentError('PSM cannot be None')
 
         # Create the channel
         logger.debug(
diff --git a/bumble/link.py b/bumble/link.py
index 5ef56b7..8971e21 100644
--- a/bumble/link.py
+++ b/bumble/link.py
@@ -19,7 +19,12 @@
 import asyncio
 from functools import partial
 
-from bumble.core import BT_PERIPHERAL_ROLE, BT_BR_EDR_TRANSPORT, BT_LE_TRANSPORT
+from bumble.core import (
+    BT_PERIPHERAL_ROLE,
+    BT_BR_EDR_TRANSPORT,
+    BT_LE_TRANSPORT,
+    InvalidStateError,
+)
 from bumble.colors import color
 from bumble.hci import (
     Address,
@@ -405,12 +410,12 @@
 
     def add_controller(self, controller):
         if self.controller:
-            raise ValueError('controller already set')
+            raise InvalidStateError('controller already set')
         self.controller = controller
 
     def remove_controller(self, controller):
         if self.controller != controller:
-            raise ValueError('controller mismatch')
+            raise InvalidStateError('controller mismatch')
         self.controller = None
 
     def get_pending_connection(self):
diff --git a/bumble/pandora/host.py b/bumble/pandora/host.py
index 4904274..aff063c 100644
--- a/bumble/pandora/host.py
+++ b/bumble/pandora/host.py
@@ -28,6 +28,7 @@
     BT_PERIPHERAL_ROLE,
     UUID,
     AdvertisingData,
+    Appearance,
     ConnectionError,
 )
 from bumble.device import (
@@ -988,8 +989,8 @@
             dt.random_target_addresses.extend(
                 [data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
             )
-        if i := cast(int, ad.get(AdvertisingData.APPEARANCE)):
-            dt.appearance = i
+        if appearance := cast(Appearance, ad.get(AdvertisingData.APPEARANCE)):
+            dt.appearance = int(appearance)
         if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)):
             dt.advertising_interval = i
         if s := cast(str, ad.get(AdvertisingData.URI)):
diff --git a/bumble/profiles/ascs.py b/bumble/profiles/ascs.py
new file mode 100644
index 0000000..35f4594
--- /dev/null
+++ b/bumble/profiles/ascs.py
@@ -0,0 +1,739 @@
+# 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
+
+"""LE Audio - Audio Stream Control Service"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import enum
+import logging
+import struct
+from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union
+
+from bumble import colors
+from bumble.profiles.bap import CodecSpecificConfiguration
+from bumble.profiles import le_audio
+from bumble import device
+from bumble import gatt
+from bumble import gatt_client
+from bumble import hci
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# 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
+
+
+# -----------------------------------------------------------------------------
+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
+    metadata = le_audio.Metadata()
+
+    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 (
+            cig_id == self.cig_id
+            and 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.cig_id == self.cig_id
+            and cis_link.cis_id == self.cis_id
+            and self.state == self.State.ENABLING
+        ):
+            cis_link.on('disconnection', self.on_cis_disconnection)
+
+            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'',
+                    )
+                )
+                if self.role == AudioRole.SINK:
+                    self.state = self.State.STREAMING
+                await self.service.device.notify_subscribers(self, self.value)
+
+            cis_link.acl_connection.abort_on('flush', post_cis_established())
+            self.cis_link = cis_link
+
+    def on_cis_disconnection(self, _reason) -> None:
+        self.cis_link = None
+
+    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 = le_audio.Metadata.from_bytes(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,
+            )
+        if self.role == AudioRole.SINK:
+            self.state = self.State.QOS_CONFIGURED
+        else:
+            self.state = self.State.DISABLING
+        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+    def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
+        if (
+            self.role != AudioRole.SOURCE
+            or 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 = le_audio.Metadata.from_bytes(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
+        self.emit('state_change')
+
+    @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,
+        ):
+            metadata_bytes = bytes(self.metadata)
+            additional_parameters = (
+                bytes([self.cig_id, self.cis_id, len(metadata_bytes)]) + metadata_bytes
+            )
+        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
+    _active_client: Optional[device.Connection] = None
+
+    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_client_disconnected(self, _reason: int) -> None:
+        for ase in self.ase_state_machines.values():
+            ase.state = AseStateMachine.State.IDLE
+        self._active_client = None
+
+    def on_write_ase_control_point(self, connection, data):
+        if not self._active_client and connection:
+            self._active_client = connection
+            connection.once('disconnection', self._on_client_disconnected)
+
+        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),
+                )
+
+
+# -----------------------------------------------------------------------------
+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/bap.py b/bumble/profiles/bap.py
index c0123b1..8a00eaf 100644
--- a/bumble/profiles/bap.py
+++ b/bumble/profiles/bap.py
@@ -24,14 +24,14 @@
 import struct
 import functools
 import logging
-from typing import Optional, List, Union, Type, Dict, Any, Tuple
+from typing import List
+from typing_extensions import Self
 
 from bumble import core
-from bumble import colors
-from bumble import device
 from bumble import hci
 from bumble import gatt
-from bumble import gatt_client
+from bumble import utils
+from bumble.profiles import le_audio
 
 
 # -----------------------------------------------------------------------------
@@ -115,7 +115,7 @@
     EMERGENCY_ALARM  = 0x0800
 
 
-class SamplingFrequency(enum.IntEnum):
+class SamplingFrequency(utils.OpenIntEnum):
     '''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
 
     # fmt: off
@@ -240,7 +240,7 @@
     DURATION_10000_US_PREFERRED = 0b0010
 
 
-class AnnouncementType(enum.IntEnum):
+class AnnouncementType(utils.OpenIntEnum):
     '''Basic Audio Profile, 3.5.3. Additional Audio Stream Control Service requirements'''
 
     # fmt: off
@@ -248,231 +248,6 @@
     TARGETED = 0x01
 
 
-# -----------------------------------------------------------------------------
-# 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
-
-
 @dataclasses.dataclass
 class UnicastServerAdvertisingData:
     """Advertising Data for ASCS."""
@@ -613,7 +388,7 @@
     * Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements
     '''
 
-    class Type(enum.IntEnum):
+    class Type(utils.OpenIntEnum):
         # fmt: off
         SAMPLING_FREQUENCY       = 0x01
         FRAME_DURATION           = 0x02
@@ -681,645 +456,93 @@
 
 
 @dataclasses.dataclass
-class PacRecord:
-    coding_format: hci.CodingFormat
-    codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
-    # TODO: Parse Metadata
-    metadata: bytes = b''
+class BroadcastAudioAnnouncement:
+    broadcast_id: int
 
     @classmethod
-    def from_bytes(cls, data: bytes) -> PacRecord:
-        offset, coding_format = hci.CodingFormat.parse_from_bytes(data, 0)
-        codec_specific_capabilities_size = data[offset]
+    def from_bytes(cls, data: bytes) -> Self:
+        return cls(int.from_bytes(data[:3], 'little'))
 
-        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
[email protected]
+class BasicAudioAnnouncement:
+    @dataclasses.dataclass
+    class BIS:
+        index: int
+        codec_specific_configuration: CodecSpecificConfiguration
+
+    @dataclasses.dataclass
+    class CodecInfo:
+        coding_format: hci.CodecID
+        company_id: int
+        vendor_specific_codec_id: int
+
+        @classmethod
+        def from_bytes(cls, data: bytes) -> Self:
+            coding_format = hci.CodecID(data[0])
+            company_id = int.from_bytes(data[1:3], 'little')
+            vendor_specific_codec_id = int.from_bytes(data[3:5], 'little')
+            return cls(coding_format, company_id, vendor_specific_codec_id)
+
+    @dataclasses.dataclass
+    class Subgroup:
+        codec_id: BasicAudioAnnouncement.CodecInfo
+        codec_specific_configuration: CodecSpecificConfiguration
+        metadata: le_audio.Metadata
+        bis: List[BasicAudioAnnouncement.BIS]
+
+    presentation_delay: int
+    subgroups: List[BasicAudioAnnouncement.Subgroup]
+
+    @classmethod
+    def from_bytes(cls, data: bytes) -> Self:
+        presentation_delay = int.from_bytes(data[:3], 'little')
+        subgroups = []
+        offset = 4
+        for _ in range(data[3]):
+            num_bis = data[offset]
+            offset += 1
+            codec_id = cls.CodecInfo.from_bytes(data[offset : offset + 5])
+            offset += 5
+            codec_specific_configuration_length = data[offset]
+            offset += 1
+            codec_specific_configuration = data[
+                offset : offset + codec_specific_configuration_length
+            ]
+            offset += codec_specific_configuration_length
+            metadata_length = data[offset]
+            offset += 1
+            metadata = le_audio.Metadata.from_bytes(
+                data[offset : offset + metadata_length]
             )
+            offset += metadata_length
 
-        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 (
-            cig_id == self.cig_id
-            and 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.cig_id == self.cig_id
-            and cis_link.cis_id == self.cis_id
-            and self.state == self.State.ENABLING
-        ):
-            cis_link.on('disconnection', self.on_cis_disconnection)
-
-            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'',
+            bis = []
+            for _ in range(num_bis):
+                bis_index = data[offset]
+                offset += 1
+                bis_codec_specific_configuration_length = data[offset]
+                offset += 1
+                bis_codec_specific_configuration = data[
+                    offset : offset + bis_codec_specific_configuration_length
+                ]
+                offset += bis_codec_specific_configuration_length
+                bis.append(
+                    cls.BIS(
+                        bis_index,
+                        CodecSpecificConfiguration.from_bytes(
+                            bis_codec_specific_configuration
+                        ),
                     )
                 )
-                if self.role == AudioRole.SINK:
-                    self.state = self.State.STREAMING
-                await self.service.device.notify_subscribers(self, self.value)
 
-            cis_link.acl_connection.abort_on('flush', post_cis_established())
-            self.cis_link = cis_link
-
-    def on_cis_disconnection(self, _reason) -> None:
-        self.cis_link = None
-
-    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,
-            )
-        if self.role == AudioRole.SINK:
-            self.state = self.State.QOS_CONFIGURED
-        else:
-            self.state = self.State.DISABLING
-        return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
-
-    def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
-        if (
-            self.role != AudioRole.SOURCE
-            or 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,
+            subgroups.append(
+                cls.Subgroup(
+                    codec_id,
+                    CodecSpecificConfiguration.from_bytes(codec_specific_configuration),
+                    metadata,
+                    bis,
                 )
             )
-            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
-        self.emit('state_change')
-
-    @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
-    _active_client: Optional[device.Connection] = None
-
-    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_client_disconnected(self, _reason: int) -> None:
-        for ase in self.ase_state_machines.values():
-            ase.state = AseStateMachine.State.IDLE
-        self._active_client = None
-
-    def on_write_ase_control_point(self, connection, data):
-        if not self._active_client and connection:
-            self._active_client = connection
-            connection.once('disconnection', self._on_client_disconnected)
-
-        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]
+        return cls(presentation_delay, subgroups)
diff --git a/bumble/profiles/bass.py b/bumble/profiles/bass.py
new file mode 100644
index 0000000..57531db
--- /dev/null
+++ b/bumble/profiles/bass.py
@@ -0,0 +1,440 @@
+# 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
+
+"""LE Audio - Broadcast Audio Scan Service"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import dataclasses
+import logging
+import struct
+from typing import ClassVar, List, Optional, Sequence
+
+from bumble import core
+from bumble import device
+from bumble import gatt
+from bumble import gatt_client
+from bumble import hci
+from bumble import utils
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+class ApplicationError(utils.OpenIntEnum):
+    OPCODE_NOT_SUPPORTED = 0x80
+    INVALID_SOURCE_ID = 0x81
+
+
+# -----------------------------------------------------------------------------
+def encode_subgroups(subgroups: Sequence[SubgroupInfo]) -> bytes:
+    return bytes([len(subgroups)]) + b"".join(
+        struct.pack("<IB", subgroup.bis_sync, len(subgroup.metadata))
+        + subgroup.metadata
+        for subgroup in subgroups
+    )
+
+
+def decode_subgroups(data: bytes) -> List[SubgroupInfo]:
+    num_subgroups = data[0]
+    offset = 1
+    subgroups = []
+    for _ in range(num_subgroups):
+        bis_sync = struct.unpack("<I", data[offset : offset + 4])[0]
+        metadata_length = data[offset + 4]
+        metadata = data[offset + 5 : offset + 5 + metadata_length]
+        offset += 5 + metadata_length
+        subgroups.append(SubgroupInfo(bis_sync, metadata))
+
+    return subgroups
+
+
+# -----------------------------------------------------------------------------
+class PeriodicAdvertisingSyncParams(utils.OpenIntEnum):
+    DO_NOT_SYNCHRONIZE_TO_PA = 0x00
+    SYNCHRONIZE_TO_PA_PAST_AVAILABLE = 0x01
+    SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE = 0x02
+
+
[email protected]
+class SubgroupInfo:
+    ANY_BIS: ClassVar[int] = 0xFFFFFFFF
+
+    bis_sync: int
+    metadata: bytes
+
+
+class ControlPointOperation:
+    class OpCode(utils.OpenIntEnum):
+        REMOTE_SCAN_STOPPED = 0x00
+        REMOTE_SCAN_STARTED = 0x01
+        ADD_SOURCE = 0x02
+        MODIFY_SOURCE = 0x03
+        SET_BROADCAST_CODE = 0x04
+        REMOVE_SOURCE = 0x05
+
+    op_code: OpCode
+    parameters: bytes
+
+    @classmethod
+    def from_bytes(cls, data: bytes) -> ControlPointOperation:
+        op_code = data[0]
+
+        if op_code == cls.OpCode.REMOTE_SCAN_STOPPED:
+            return RemoteScanStoppedOperation()
+
+        if op_code == cls.OpCode.REMOTE_SCAN_STARTED:
+            return RemoteScanStartedOperation()
+
+        if op_code == cls.OpCode.ADD_SOURCE:
+            return AddSourceOperation.from_parameters(data[1:])
+
+        if op_code == cls.OpCode.MODIFY_SOURCE:
+            return ModifySourceOperation.from_parameters(data[1:])
+
+        if op_code == cls.OpCode.SET_BROADCAST_CODE:
+            return SetBroadcastCodeOperation.from_parameters(data[1:])
+
+        if op_code == cls.OpCode.REMOVE_SOURCE:
+            return RemoveSourceOperation.from_parameters(data[1:])
+
+        raise core.InvalidArgumentError("invalid op code")
+
+    def __init__(self, op_code: OpCode, parameters: bytes = b"") -> None:
+        self.op_code = op_code
+        self.parameters = parameters
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.op_code]) + self.parameters
+
+
+class RemoteScanStoppedOperation(ControlPointOperation):
+    def __init__(self) -> None:
+        super().__init__(ControlPointOperation.OpCode.REMOTE_SCAN_STOPPED)
+
+
+class RemoteScanStartedOperation(ControlPointOperation):
+    def __init__(self) -> None:
+        super().__init__(ControlPointOperation.OpCode.REMOTE_SCAN_STARTED)
+
+
+class AddSourceOperation(ControlPointOperation):
+    @classmethod
+    def from_parameters(cls, parameters: bytes) -> AddSourceOperation:
+        instance = cls.__new__(cls)
+        instance.op_code = ControlPointOperation.OpCode.ADD_SOURCE
+        instance.parameters = parameters
+        instance.advertiser_address = hci.Address.parse_address_preceded_by_type(
+            parameters, 1
+        )[1]
+        instance.advertising_sid = parameters[7]
+        instance.broadcast_id = int.from_bytes(parameters[8:11], "little")
+        instance.pa_sync = PeriodicAdvertisingSyncParams(parameters[11])
+        instance.pa_interval = struct.unpack("<H", parameters[12:14])[0]
+        instance.subgroups = decode_subgroups(parameters[14:])
+        return instance
+
+    def __init__(
+        self,
+        advertiser_address: hci.Address,
+        advertising_sid: int,
+        broadcast_id: int,
+        pa_sync: PeriodicAdvertisingSyncParams,
+        pa_interval: int,
+        subgroups: Sequence[SubgroupInfo],
+    ) -> None:
+        super().__init__(
+            ControlPointOperation.OpCode.ADD_SOURCE,
+            struct.pack(
+                "<B6sB3sBH",
+                advertiser_address.address_type,
+                bytes(advertiser_address),
+                advertising_sid,
+                broadcast_id.to_bytes(3, "little"),
+                pa_sync,
+                pa_interval,
+            )
+            + encode_subgroups(subgroups),
+        )
+        self.advertiser_address = advertiser_address
+        self.advertising_sid = advertising_sid
+        self.broadcast_id = broadcast_id
+        self.pa_sync = pa_sync
+        self.pa_interval = pa_interval
+        self.subgroups = list(subgroups)
+
+
+class ModifySourceOperation(ControlPointOperation):
+    @classmethod
+    def from_parameters(cls, parameters: bytes) -> ModifySourceOperation:
+        instance = cls.__new__(cls)
+        instance.op_code = ControlPointOperation.OpCode.MODIFY_SOURCE
+        instance.parameters = parameters
+        instance.source_id = parameters[0]
+        instance.pa_sync = PeriodicAdvertisingSyncParams(parameters[1])
+        instance.pa_interval = struct.unpack("<H", parameters[2:4])[0]
+        instance.subgroups = decode_subgroups(parameters[4:])
+        return instance
+
+    def __init__(
+        self,
+        source_id: int,
+        pa_sync: PeriodicAdvertisingSyncParams,
+        pa_interval: int,
+        subgroups: Sequence[SubgroupInfo],
+    ) -> None:
+        super().__init__(
+            ControlPointOperation.OpCode.MODIFY_SOURCE,
+            struct.pack("<BBH", source_id, pa_sync, pa_interval)
+            + encode_subgroups(subgroups),
+        )
+        self.source_id = source_id
+        self.pa_sync = pa_sync
+        self.pa_interval = pa_interval
+        self.subgroups = list(subgroups)
+
+
+class SetBroadcastCodeOperation(ControlPointOperation):
+    @classmethod
+    def from_parameters(cls, parameters: bytes) -> SetBroadcastCodeOperation:
+        instance = cls.__new__(cls)
+        instance.op_code = ControlPointOperation.OpCode.SET_BROADCAST_CODE
+        instance.parameters = parameters
+        instance.source_id = parameters[0]
+        instance.broadcast_code = parameters[1:17]
+        return instance
+
+    def __init__(
+        self,
+        source_id: int,
+        broadcast_code: bytes,
+    ) -> None:
+        super().__init__(
+            ControlPointOperation.OpCode.SET_BROADCAST_CODE,
+            bytes([source_id]) + broadcast_code,
+        )
+        self.source_id = source_id
+        self.broadcast_code = broadcast_code
+
+        if len(self.broadcast_code) != 16:
+            raise core.InvalidArgumentError("broadcast_code must be 16 bytes")
+
+
+class RemoveSourceOperation(ControlPointOperation):
+    @classmethod
+    def from_parameters(cls, parameters: bytes) -> RemoveSourceOperation:
+        instance = cls.__new__(cls)
+        instance.op_code = ControlPointOperation.OpCode.REMOVE_SOURCE
+        instance.parameters = parameters
+        instance.source_id = parameters[0]
+        return instance
+
+    def __init__(self, source_id: int) -> None:
+        super().__init__(ControlPointOperation.OpCode.REMOVE_SOURCE, bytes([source_id]))
+        self.source_id = source_id
+
+
[email protected]
+class BroadcastReceiveState:
+    class PeriodicAdvertisingSyncState(utils.OpenIntEnum):
+        NOT_SYNCHRONIZED_TO_PA = 0x00
+        SYNCINFO_REQUEST = 0x01
+        SYNCHRONIZED_TO_PA = 0x02
+        FAILED_TO_SYNCHRONIZE_TO_PA = 0x03
+        NO_PAST = 0x04
+
+    class BigEncryption(utils.OpenIntEnum):
+        NOT_ENCRYPTED = 0x00
+        BROADCAST_CODE_REQUIRED = 0x01
+        DECRYPTING = 0x02
+        BAD_CODE = 0x03
+
+    source_id: int
+    source_address: hci.Address
+    source_adv_sid: int
+    broadcast_id: int
+    pa_sync_state: PeriodicAdvertisingSyncState
+    big_encryption: BigEncryption
+    bad_code: bytes
+    subgroups: List[SubgroupInfo]
+
+    @classmethod
+    def from_bytes(cls, data: bytes) -> Optional[BroadcastReceiveState]:
+        if not data:
+            return None
+
+        source_id = data[0]
+        _, source_address = hci.Address.parse_address_preceded_by_type(data, 2)
+        source_adv_sid = data[8]
+        broadcast_id = int.from_bytes(data[9:12], "little")
+        pa_sync_state = cls.PeriodicAdvertisingSyncState(data[12])
+        big_encryption = cls.BigEncryption(data[13])
+        if big_encryption == cls.BigEncryption.BAD_CODE:
+            bad_code = data[14:30]
+            subgroups = decode_subgroups(data[30:])
+        else:
+            bad_code = b""
+            subgroups = decode_subgroups(data[14:])
+
+        return cls(
+            source_id,
+            source_address,
+            source_adv_sid,
+            broadcast_id,
+            pa_sync_state,
+            big_encryption,
+            bad_code,
+            subgroups,
+        )
+
+    def __bytes__(self) -> bytes:
+        return (
+            struct.pack(
+                "<BB6sB3sBB",
+                self.source_id,
+                self.source_address.address_type,
+                bytes(self.source_address),
+                self.source_adv_sid,
+                self.broadcast_id.to_bytes(3, "little"),
+                self.pa_sync_state,
+                self.big_encryption,
+            )
+            + self.bad_code
+            + encode_subgroups(self.subgroups)
+        )
+
+
+# -----------------------------------------------------------------------------
+class BroadcastAudioScanService(gatt.TemplateService):
+    UUID = gatt.GATT_BROADCAST_AUDIO_SCAN_SERVICE
+
+    def __init__(self):
+        self.broadcast_audio_scan_control_point_characteristic = gatt.Characteristic(
+            gatt.GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC,
+            gatt.Characteristic.Properties.WRITE
+            | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
+            gatt.Characteristic.WRITEABLE,
+            gatt.CharacteristicValue(
+                write=self.on_broadcast_audio_scan_control_point_write
+            ),
+        )
+
+        self.broadcast_receive_state_characteristic = gatt.Characteristic(
+            gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC,
+            gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.NOTIFY,
+            gatt.Characteristic.Permissions.READABLE
+            | gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            b"12",  # TEST
+        )
+
+        super().__init__([self.battery_level_characteristic])
+
+    def on_broadcast_audio_scan_control_point_write(
+        self, connection: device.Connection, value: bytes
+    ) -> None:
+        pass
+
+
+# -----------------------------------------------------------------------------
+class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
+    SERVICE_CLASS = BroadcastAudioScanService
+
+    broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
+    broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter]
+
+    def __init__(self, service_proxy: gatt_client.ServiceProxy):
+        self.service_proxy = service_proxy
+
+        if not (
+            characteristics := service_proxy.get_characteristics_by_uuid(
+                gatt.GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC
+            )
+        ):
+            raise gatt.InvalidServiceError(
+                "Broadcast Audio Scan Control Point characteristic not found"
+            )
+        self.broadcast_audio_scan_control_point = characteristics[0]
+
+        if not (
+            characteristics := service_proxy.get_characteristics_by_uuid(
+                gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC
+            )
+        ):
+            raise gatt.InvalidServiceError(
+                "Broadcast Receive State characteristic not found"
+            )
+        self.broadcast_receive_states = [
+            gatt.DelegatedCharacteristicAdapter(
+                characteristic, decode=BroadcastReceiveState.from_bytes
+            )
+            for characteristic in characteristics
+        ]
+
+    async def send_control_point_operation(
+        self, operation: ControlPointOperation
+    ) -> None:
+        await self.broadcast_audio_scan_control_point.write_value(
+            bytes(operation), with_response=True
+        )
+
+    async def remote_scan_started(self) -> None:
+        await self.send_control_point_operation(RemoteScanStartedOperation())
+
+    async def remote_scan_stopped(self) -> None:
+        await self.send_control_point_operation(RemoteScanStoppedOperation())
+
+    async def add_source(
+        self,
+        advertiser_address: hci.Address,
+        advertising_sid: int,
+        broadcast_id: int,
+        pa_sync: PeriodicAdvertisingSyncParams,
+        pa_interval: int,
+        subgroups: Sequence[SubgroupInfo],
+    ) -> None:
+        await self.send_control_point_operation(
+            AddSourceOperation(
+                advertiser_address,
+                advertising_sid,
+                broadcast_id,
+                pa_sync,
+                pa_interval,
+                subgroups,
+            )
+        )
+
+    async def modify_source(
+        self,
+        source_id: int,
+        pa_sync: PeriodicAdvertisingSyncParams,
+        pa_interval: int,
+        subgroups: Sequence[SubgroupInfo],
+    ) -> None:
+        await self.send_control_point_operation(
+            ModifySourceOperation(
+                source_id,
+                pa_sync,
+                pa_interval,
+                subgroups,
+            )
+        )
+
+    async def remove_source(self, source_id: int) -> None:
+        await self.send_control_point_operation(RemoveSourceOperation(source_id))
diff --git a/bumble/profiles/csip.py b/bumble/profiles/csip.py
index 03fba9c..9ba3baf 100644
--- a/bumble/profiles/csip.py
+++ b/bumble/profiles/csip.py
@@ -113,7 +113,7 @@
         set_member_rank: Optional[int] = None,
     ) -> None:
         if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
-            raise ValueError(
+            raise core.InvalidArgumentError(
                 f'Invalid SIRK length {len(set_identity_resolving_key)}, expected {SET_IDENTITY_RESOLVING_KEY_LENGTH}'
             )
 
@@ -178,7 +178,7 @@
                 key = await connection.device.get_link_key(connection.peer_address)
 
             if not key:
-                raise RuntimeError('LTK or LinkKey is not present')
+                raise core.InvalidOperationError('LTK or LinkKey is not present')
 
             sirk_bytes = sef(key, self.set_identity_resolving_key)
 
@@ -234,7 +234,7 @@
         '''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')
+            raise core.InvalidPacketError('Invalid SIRK value')
 
         sirk_type = SirkType(response[0])
         if sirk_type == SirkType.PLAINTEXT:
@@ -250,7 +250,7 @@
                 key = await device.get_link_key(connection.peer_address)
 
             if not key:
-                raise RuntimeError('LTK or LinkKey is not present')
+                raise core.InvalidOperationError('LTK or LinkKey is not present')
 
             sirk = sef(key, response[1:])
 
diff --git a/bumble/profiles/gap.py b/bumble/profiles/gap.py
new file mode 100644
index 0000000..0dd6e51
--- /dev/null
+++ b/bumble/profiles/gap.py
@@ -0,0 +1,110 @@
+# 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.
+
+"""Generic Access Profile"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import logging
+import struct
+from typing import Optional, Tuple, Union
+
+from bumble.core import Appearance
+from bumble.gatt import (
+    TemplateService,
+    Characteristic,
+    CharacteristicAdapter,
+    DelegatedCharacteristicAdapter,
+    UTF8CharacteristicAdapter,
+    GATT_GENERIC_ACCESS_SERVICE,
+    GATT_DEVICE_NAME_CHARACTERISTIC,
+    GATT_APPEARANCE_CHARACTERISTIC,
+)
+from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
+
+
+# -----------------------------------------------------------------------------
+class GenericAccessService(TemplateService):
+    UUID = GATT_GENERIC_ACCESS_SERVICE
+
+    def __init__(
+        self, device_name: str, appearance: Union[Appearance, Tuple[int, int], int] = 0
+    ):
+        if isinstance(appearance, int):
+            appearance_int = appearance
+        elif isinstance(appearance, tuple):
+            appearance_int = (appearance[0] << 6) | appearance[1]
+        elif isinstance(appearance, Appearance):
+            appearance_int = int(appearance)
+        else:
+            raise TypeError()
+
+        self.device_name_characteristic = Characteristic(
+            GATT_DEVICE_NAME_CHARACTERISTIC,
+            Characteristic.Properties.READ,
+            Characteristic.READABLE,
+            device_name.encode('utf-8')[:248],
+        )
+
+        self.appearance_characteristic = Characteristic(
+            GATT_APPEARANCE_CHARACTERISTIC,
+            Characteristic.Properties.READ,
+            Characteristic.READABLE,
+            struct.pack('<H', appearance_int),
+        )
+
+        super().__init__(
+            [self.device_name_characteristic, self.appearance_characteristic]
+        )
+
+
+# -----------------------------------------------------------------------------
+class GenericAccessServiceProxy(ProfileServiceProxy):
+    SERVICE_CLASS = GenericAccessService
+
+    device_name: Optional[CharacteristicAdapter]
+    appearance: Optional[DelegatedCharacteristicAdapter]
+
+    def __init__(self, service_proxy: ServiceProxy):
+        self.service_proxy = service_proxy
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            GATT_DEVICE_NAME_CHARACTERISTIC
+        ):
+            self.device_name = UTF8CharacteristicAdapter(characteristics[0])
+        else:
+            self.device_name = None
+
+        if characteristics := service_proxy.get_characteristics_by_uuid(
+            GATT_APPEARANCE_CHARACTERISTIC
+        ):
+            self.appearance = DelegatedCharacteristicAdapter(
+                characteristics[0],
+                decode=lambda value: Appearance.from_int(
+                    struct.unpack_from('<H', value, 0)[0],
+                ),
+            )
+        else:
+            self.appearance = None
diff --git a/bumble/profiles/heart_rate_service.py b/bumble/profiles/heart_rate_service.py
index fe46cb2..0c9a12f 100644
--- a/bumble/profiles/heart_rate_service.py
+++ b/bumble/profiles/heart_rate_service.py
@@ -19,6 +19,7 @@
 from enum import IntEnum
 import struct
 
+from bumble import core
 from ..gatt_client import ProfileServiceProxy
 from ..att import ATT_Error
 from ..gatt import (
@@ -59,17 +60,17 @@
             rr_intervals=None,
         ):
             if heart_rate < 0 or heart_rate > 0xFFFF:
-                raise ValueError('heart_rate out of range')
+                raise core.InvalidArgumentError('heart_rate out of range')
 
             if energy_expended is not None and (
                 energy_expended < 0 or energy_expended > 0xFFFF
             ):
-                raise ValueError('energy_expended out of range')
+                raise core.InvalidArgumentError('energy_expended out of range')
 
             if rr_intervals:
                 for rr_interval in rr_intervals:
                     if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
-                        raise ValueError('rr_intervals out of range')
+                        raise core.InvalidArgumentError('rr_intervals out of range')
 
             self.heart_rate = heart_rate
             self.sensor_contact_detected = sensor_contact_detected
diff --git a/bumble/profiles/le_audio.py b/bumble/profiles/le_audio.py
new file mode 100644
index 0000000..b152fd9
--- /dev/null
+++ b/bumble/profiles/le_audio.py
@@ -0,0 +1,83 @@
+# 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
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import dataclasses
+import struct
+from typing import List, Type
+from typing_extensions import Self
+
+from bumble import utils
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
[email protected]
+class Metadata:
+    '''Bluetooth Assigned Numbers, Section 6.12.6 - Metadata LTV structures.
+
+    As Metadata fields may extend, and Spec doesn't forbid duplication, we don't parse
+    Metadata into a key-value style dataclass here. Rather, we encourage users to parse
+    again outside the lib.
+    '''
+
+    class Tag(utils.OpenIntEnum):
+        # fmt: off
+        PREFERRED_AUDIO_CONTEXTS                 = 0x01
+        STREAMING_AUDIO_CONTEXTS                 = 0x02
+        PROGRAM_INFO                             = 0x03
+        LANGUAGE                                 = 0x04
+        CCID_LIST                                = 0x05
+        PARENTAL_RATING                          = 0x06
+        PROGRAM_INFO_URI                         = 0x07
+        AUDIO_ACTIVE_STATE                       = 0x08
+        BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG = 0x09
+        ASSISTED_LISTENING_STREAM                = 0x0A
+        BROADCAST_NAME                           = 0x0B
+        EXTENDED_METADATA                        = 0xFE
+        VENDOR_SPECIFIC                          = 0xFF
+
+    @dataclasses.dataclass
+    class Entry:
+        tag: Metadata.Tag
+        data: bytes
+
+        @classmethod
+        def from_bytes(cls: Type[Self], data: bytes) -> Self:
+            return cls(tag=Metadata.Tag(data[0]), data=data[1:])
+
+        def __bytes__(self) -> bytes:
+            return bytes([len(self.data) + 1, self.tag]) + self.data
+
+    entries: List[Entry] = dataclasses.field(default_factory=list)
+
+    @classmethod
+    def from_bytes(cls: Type[Self], data: bytes) -> Self:
+        entries = []
+        offset = 0
+        length = len(data)
+        while offset < length:
+            entry_length = data[offset]
+            offset += 1
+            entries.append(cls.Entry.from_bytes(data[offset : offset + entry_length]))
+            offset += entry_length
+
+        return cls(entries)
+
+    def __bytes__(self) -> bytes:
+        return b''.join([bytes(entry) for entry in self.entries])
diff --git a/bumble/profiles/mcp.py b/bumble/profiles/mcp.py
new file mode 100644
index 0000000..5e12573
--- /dev/null
+++ b/bumble/profiles/mcp.py
@@ -0,0 +1,448 @@
+# 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 asyncio
+import dataclasses
+import enum
+import struct
+
+from bumble import core
+from bumble import device
+from bumble import gatt
+from bumble import gatt_client
+from bumble import utils
+
+from typing import Type, Optional, ClassVar, Dict, TYPE_CHECKING
+from typing_extensions import Self
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+
+class PlayingOrder(utils.OpenIntEnum):
+    '''See Media Control Service 3.15. Playing Order.'''
+
+    SINGLE_ONCE = 0x01
+    SINGLE_REPEAT = 0x02
+    IN_ORDER_ONCE = 0x03
+    IN_ORDER_REPEAT = 0x04
+    OLDEST_ONCE = 0x05
+    OLDEST_REPEAT = 0x06
+    NEWEST_ONCE = 0x07
+    NEWEST_REPEAT = 0x08
+    SHUFFLE_ONCE = 0x09
+    SHUFFLE_REPEAT = 0x0A
+
+
+class PlayingOrderSupported(enum.IntFlag):
+    '''See Media Control Service 3.16. Playing Orders Supported.'''
+
+    SINGLE_ONCE = 0x0001
+    SINGLE_REPEAT = 0x0002
+    IN_ORDER_ONCE = 0x0004
+    IN_ORDER_REPEAT = 0x0008
+    OLDEST_ONCE = 0x0010
+    OLDEST_REPEAT = 0x0020
+    NEWEST_ONCE = 0x0040
+    NEWEST_REPEAT = 0x0080
+    SHUFFLE_ONCE = 0x0100
+    SHUFFLE_REPEAT = 0x0200
+
+
+class MediaState(utils.OpenIntEnum):
+    '''See Media Control Service 3.17. Media State.'''
+
+    INACTIVE = 0x00
+    PLAYING = 0x01
+    PAUSED = 0x02
+    SEEKING = 0x03
+
+
+class MediaControlPointOpcode(utils.OpenIntEnum):
+    '''See Media Control Service 3.18. Media Control Point.'''
+
+    PLAY = 0x01
+    PAUSE = 0x02
+    FAST_REWIND = 0x03
+    FAST_FORWARD = 0x04
+    STOP = 0x05
+    MOVE_RELATIVE = 0x10
+    PREVIOUS_SEGMENT = 0x20
+    NEXT_SEGMENT = 0x21
+    FIRST_SEGMENT = 0x22
+    LAST_SEGMENT = 0x23
+    GOTO_SEGMENT = 0x24
+    PREVIOUS_TRACK = 0x30
+    NEXT_TRACK = 0x31
+    FIRST_TRACK = 0x32
+    LAST_TRACK = 0x33
+    GOTO_TRACK = 0x34
+    PREVIOUS_GROUP = 0x40
+    NEXT_GROUP = 0x41
+    FIRST_GROUP = 0x42
+    LAST_GROUP = 0x43
+    GOTO_GROUP = 0x44
+
+
+class MediaControlPointResultCode(enum.IntFlag):
+    '''See Media Control Service 3.18.2. Media Control Point Notification.'''
+
+    SUCCESS = 0x01
+    OPCODE_NOT_SUPPORTED = 0x02
+    MEDIA_PLAYER_INACTIVE = 0x03
+    COMMAND_CANNOT_BE_COMPLETED = 0x04
+
+
+class MediaControlPointOpcodeSupported(enum.IntFlag):
+    '''See Media Control Service 3.19. Media Control Point Opcodes Supported.'''
+
+    PLAY = 0x00000001
+    PAUSE = 0x00000002
+    FAST_REWIND = 0x00000004
+    FAST_FORWARD = 0x00000008
+    STOP = 0x00000010
+    MOVE_RELATIVE = 0x00000020
+    PREVIOUS_SEGMENT = 0x00000040
+    NEXT_SEGMENT = 0x00000080
+    FIRST_SEGMENT = 0x00000100
+    LAST_SEGMENT = 0x00000200
+    GOTO_SEGMENT = 0x00000400
+    PREVIOUS_TRACK = 0x00000800
+    NEXT_TRACK = 0x00001000
+    FIRST_TRACK = 0x00002000
+    LAST_TRACK = 0x00004000
+    GOTO_TRACK = 0x00008000
+    PREVIOUS_GROUP = 0x00010000
+    NEXT_GROUP = 0x00020000
+    FIRST_GROUP = 0x00040000
+    LAST_GROUP = 0x00080000
+    GOTO_GROUP = 0x00100000
+
+
+class SearchControlPointItemType(utils.OpenIntEnum):
+    '''See Media Control Service 3.20. Search Control Point.'''
+
+    TRACK_NAME = 0x01
+    ARTIST_NAME = 0x02
+    ALBUM_NAME = 0x03
+    GROUP_NAME = 0x04
+    EARLIEST_YEAR = 0x05
+    LATEST_YEAR = 0x06
+    GENRE = 0x07
+    ONLY_TRACKS = 0x08
+    ONLY_GROUPS = 0x09
+
+
+class ObjectType(utils.OpenIntEnum):
+    '''See Media Control Service 4.4.1. Object Type field.'''
+
+    TASK = 0
+    GROUP = 1
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
+
+
+class ObjectId(int):
+    '''See Media Control Service 4.4.2. Object ID field.'''
+
+    @classmethod
+    def create_from_bytes(cls: Type[Self], data: bytes) -> Self:
+        return cls(int.from_bytes(data, byteorder='little', signed=False))
+
+    def __bytes__(self) -> bytes:
+        return self.to_bytes(6, 'little')
+
+
[email protected]
+class GroupObjectType:
+    '''See Media Control Service 4.4. Group Object Type.'''
+
+    object_type: ObjectType
+    object_id: ObjectId
+
+    @classmethod
+    def from_bytes(cls: Type[Self], data: bytes) -> Self:
+        return cls(
+            object_type=ObjectType(data[0]),
+            object_id=ObjectId.create_from_bytes(data[1:]),
+        )
+
+    def __bytes__(self) -> bytes:
+        return bytes([self.object_type]) + bytes(self.object_id)
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+class MediaControlService(gatt.TemplateService):
+    '''Media Control Service server implementation, only for testing currently.'''
+
+    UUID = gatt.GATT_MEDIA_CONTROL_SERVICE
+
+    def __init__(self, media_player_name: Optional[str] = None) -> None:
+        self.track_position = 0
+
+        self.media_player_name_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=media_player_name or 'Bumble Player',
+        )
+        self.track_changed_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_TRACK_CHANGED_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=b'',
+        )
+        self.track_title_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_TRACK_TITLE_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=b'',
+        )
+        self.track_duration_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_TRACK_DURATION_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=b'',
+        )
+        self.track_position_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_TRACK_POSITION_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.WRITE
+            | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
+            | gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
+            value=b'',
+        )
+        self.media_state_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_MEDIA_STATE_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=b'',
+        )
+        self.media_control_point_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.WRITE
+            | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
+            | gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
+            value=gatt.CharacteristicValue(write=self.on_media_control_point),
+        )
+        self.media_control_point_opcodes_supported_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ
+            | gatt.Characteristic.Properties.NOTIFY,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=b'',
+        )
+        self.content_control_id_characteristic = gatt.Characteristic(
+            uuid=gatt.GATT_CONTENT_CONTROL_ID_CHARACTERISTIC,
+            properties=gatt.Characteristic.Properties.READ,
+            permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+            value=b'',
+        )
+
+        super().__init__(
+            [
+                self.media_player_name_characteristic,
+                self.track_changed_characteristic,
+                self.track_title_characteristic,
+                self.track_duration_characteristic,
+                self.track_position_characteristic,
+                self.media_state_characteristic,
+                self.media_control_point_characteristic,
+                self.media_control_point_opcodes_supported_characteristic,
+                self.content_control_id_characteristic,
+            ]
+        )
+
+    async def on_media_control_point(
+        self, connection: Optional[device.Connection], data: bytes
+    ) -> None:
+        if not connection:
+            raise core.InvalidStateError()
+
+        opcode = MediaControlPointOpcode(data[0])
+
+        await connection.device.notify_subscriber(
+            connection,
+            self.media_control_point_characteristic,
+            value=bytes([opcode, MediaControlPointResultCode.SUCCESS]),
+        )
+
+
+class GenericMediaControlService(MediaControlService):
+    UUID = gatt.GATT_GENERIC_MEDIA_CONTROL_SERVICE
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+class MediaControlServiceProxy(
+    gatt_client.ProfileServiceProxy, utils.CompositeEventEmitter
+):
+    SERVICE_CLASS = MediaControlService
+
+    _CHARACTERISTICS: ClassVar[Dict[str, core.UUID]] = {
+        'media_player_name': gatt.GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC,
+        'media_player_icon_object_id': gatt.GATT_MEDIA_PLAYER_ICON_OBJECT_ID_CHARACTERISTIC,
+        'media_player_icon_url': gatt.GATT_MEDIA_PLAYER_ICON_URL_CHARACTERISTIC,
+        'track_changed': gatt.GATT_TRACK_CHANGED_CHARACTERISTIC,
+        'track_title': gatt.GATT_TRACK_TITLE_CHARACTERISTIC,
+        'track_duration': gatt.GATT_TRACK_DURATION_CHARACTERISTIC,
+        'track_position': gatt.GATT_TRACK_POSITION_CHARACTERISTIC,
+        'playback_speed': gatt.GATT_PLAYBACK_SPEED_CHARACTERISTIC,
+        'seeking_speed': gatt.GATT_SEEKING_SPEED_CHARACTERISTIC,
+        'current_track_segments_object_id': gatt.GATT_CURRENT_TRACK_SEGMENTS_OBJECT_ID_CHARACTERISTIC,
+        'current_track_object_id': gatt.GATT_CURRENT_TRACK_OBJECT_ID_CHARACTERISTIC,
+        'next_track_object_id': gatt.GATT_NEXT_TRACK_OBJECT_ID_CHARACTERISTIC,
+        'parent_group_object_id': gatt.GATT_PARENT_GROUP_OBJECT_ID_CHARACTERISTIC,
+        'current_group_object_id': gatt.GATT_CURRENT_GROUP_OBJECT_ID_CHARACTERISTIC,
+        'playing_order': gatt.GATT_PLAYING_ORDER_CHARACTERISTIC,
+        'playing_orders_supported': gatt.GATT_PLAYING_ORDERS_SUPPORTED_CHARACTERISTIC,
+        'media_state': gatt.GATT_MEDIA_STATE_CHARACTERISTIC,
+        'media_control_point': gatt.GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC,
+        'media_control_point_opcodes_supported': gatt.GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC,
+        'search_control_point': gatt.GATT_SEARCH_CONTROL_POINT_CHARACTERISTIC,
+        'search_results_object_id': gatt.GATT_SEARCH_RESULTS_OBJECT_ID_CHARACTERISTIC,
+        'content_control_id': gatt.GATT_CONTENT_CONTROL_ID_CHARACTERISTIC,
+    }
+
+    media_player_name: Optional[gatt_client.CharacteristicProxy] = None
+    media_player_icon_object_id: Optional[gatt_client.CharacteristicProxy] = None
+    media_player_icon_url: Optional[gatt_client.CharacteristicProxy] = None
+    track_changed: Optional[gatt_client.CharacteristicProxy] = None
+    track_title: Optional[gatt_client.CharacteristicProxy] = None
+    track_duration: Optional[gatt_client.CharacteristicProxy] = None
+    track_position: Optional[gatt_client.CharacteristicProxy] = None
+    playback_speed: Optional[gatt_client.CharacteristicProxy] = None
+    seeking_speed: Optional[gatt_client.CharacteristicProxy] = None
+    current_track_segments_object_id: Optional[gatt_client.CharacteristicProxy] = None
+    current_track_object_id: Optional[gatt_client.CharacteristicProxy] = None
+    next_track_object_id: Optional[gatt_client.CharacteristicProxy] = None
+    parent_group_object_id: Optional[gatt_client.CharacteristicProxy] = None
+    current_group_object_id: Optional[gatt_client.CharacteristicProxy] = None
+    playing_order: Optional[gatt_client.CharacteristicProxy] = None
+    playing_orders_supported: Optional[gatt_client.CharacteristicProxy] = None
+    media_state: Optional[gatt_client.CharacteristicProxy] = None
+    media_control_point: Optional[gatt_client.CharacteristicProxy] = None
+    media_control_point_opcodes_supported: Optional[gatt_client.CharacteristicProxy] = (
+        None
+    )
+    search_control_point: Optional[gatt_client.CharacteristicProxy] = None
+    search_results_object_id: Optional[gatt_client.CharacteristicProxy] = None
+    content_control_id: Optional[gatt_client.CharacteristicProxy] = None
+
+    if TYPE_CHECKING:
+        media_control_point_notifications: asyncio.Queue[bytes]
+
+    def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
+        utils.CompositeEventEmitter.__init__(self)
+        self.service_proxy = service_proxy
+        self.lock = asyncio.Lock()
+        self.media_control_point_notifications = asyncio.Queue()
+
+        for field, uuid in self._CHARACTERISTICS.items():
+            if characteristics := service_proxy.get_characteristics_by_uuid(uuid):
+                setattr(self, field, characteristics[0])
+
+    async def subscribe_characteristics(self) -> None:
+        if self.media_control_point:
+            await self.media_control_point.subscribe(self._on_media_control_point)
+        if self.media_state:
+            await self.media_state.subscribe(self._on_media_state)
+        if self.track_changed:
+            await self.track_changed.subscribe(self._on_track_changed)
+        if self.track_title:
+            await self.track_title.subscribe(self._on_track_title)
+        if self.track_duration:
+            await self.track_duration.subscribe(self._on_track_duration)
+        if self.track_position:
+            await self.track_position.subscribe(self._on_track_position)
+
+    async def write_control_point(
+        self, opcode: MediaControlPointOpcode
+    ) -> MediaControlPointResultCode:
+        '''Writes a Media Control Point Opcode to peer and waits for the notification.
+
+        The write operation will be executed when there isn't other pending commands.
+
+        Args:
+            opcode: opcode defined in `MediaControlPointOpcode`.
+
+        Returns:
+            Response code provided in `MediaControlPointResultCode`
+
+        Raises:
+            InvalidOperationError: Server does not have Media Control Point Characteristic.
+            InvalidStateError: Server replies a notification with mismatched opcode.
+        '''
+        if not self.media_control_point:
+            raise core.InvalidOperationError("Peer does not have media control point")
+
+        async with self.lock:
+            await self.media_control_point.write_value(
+                bytes([opcode]),
+                with_response=False,
+            )
+
+            (
+                response_opcode,
+                response_code,
+            ) = await self.media_control_point_notifications.get()
+            if response_opcode != opcode:
+                raise core.InvalidStateError(
+                    f"Expected {opcode} notification, but get {response_opcode}"
+                )
+            return MediaControlPointResultCode(response_code)
+
+    def _on_media_control_point(self, data: bytes) -> None:
+        self.media_control_point_notifications.put_nowait(data)
+
+    def _on_media_state(self, data: bytes) -> None:
+        self.emit('media_state', MediaState(data[0]))
+
+    def _on_track_changed(self, data: bytes) -> None:
+        del data
+        self.emit('track_changed')
+
+    def _on_track_title(self, data: bytes) -> None:
+        self.emit('track_title', data.decode("utf-8"))
+
+    def _on_track_duration(self, data: bytes) -> None:
+        self.emit('track_duration', struct.unpack_from('<i', data)[0])
+
+    def _on_track_position(self, data: bytes) -> None:
+        self.emit('track_position', struct.unpack_from('<i', data)[0])
+
+
+class GenericMediaControlServiceProxy(MediaControlServiceProxy):
+    SERVICE_CLASS = GenericMediaControlService
diff --git a/bumble/profiles/pacs.py b/bumble/profiles/pacs.py
new file mode 100644
index 0000000..adab088
--- /dev/null
+++ b/bumble/profiles/pacs.py
@@ -0,0 +1,210 @@
+# 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
+
+"""LE Audio - Published Audio Capabilities Service"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import dataclasses
+import logging
+import struct
+from typing import Optional, Sequence, Union
+
+from bumble.profiles.bap import AudioLocation, CodecSpecificCapabilities, ContextType
+from bumble.profiles import le_audio
+from bumble import gatt
+from bumble import gatt_client
+from bumble import hci
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
[email protected]
+class PacRecord:
+    '''Published Audio Capabilities Service, Table 3.2/3.4.'''
+
+    coding_format: hci.CodingFormat
+    codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
+    metadata: le_audio.Metadata = dataclasses.field(default_factory=le_audio.Metadata)
+
+    @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]
+        offset += 1
+        metadata = le_audio.Metadata.from_bytes(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)
+        metadata_bytes = bytes(self.metadata)
+        return (
+            bytes(self.coding_format)
+            + bytes([len(capabilities_bytes)])
+            + capabilities_bytes
+            + bytes([len(metadata_bytes)])
+            + metadata_bytes
+        )
+
+
+# -----------------------------------------------------------------------------
+# 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)
+
+
+# -----------------------------------------------------------------------------
+# 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]
diff --git a/bumble/profiles/pbp.py b/bumble/profiles/pbp.py
new file mode 100644
index 0000000..058bd6d
--- /dev/null
+++ b/bumble/profiles/pbp.py
@@ -0,0 +1,46 @@
+# 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
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import dataclasses
+import enum
+from typing_extensions import Self
+
+from bumble.profiles import le_audio
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
[email protected]
+class PublicBroadcastAnnouncement:
+    class Features(enum.IntFlag):
+        ENCRYPTED = 1 << 0
+        STANDARD_QUALITY_CONFIGURATION = 1 << 1
+        HIGH_QUALITY_CONFIGURATION = 1 << 2
+
+    features: Features
+    metadata: le_audio.Metadata
+
+    @classmethod
+    def from_bytes(cls, data: bytes) -> Self:
+        features = cls.Features(data[0])
+        metadata_length = data[1]
+        metadata_ltv = data[1 : 1 + metadata_length]
+        return cls(
+            features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
+        )
diff --git a/bumble/profiles/tmap.py b/bumble/profiles/tmap.py
new file mode 100644
index 0000000..7b65015
--- /dev/null
+++ b/bumble/profiles/tmap.py
@@ -0,0 +1,89 @@
+# 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.
+
+"""LE Audio - Telephony and Media Audio Profile"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import enum
+import logging
+import struct
+
+from bumble.gatt import (
+    TemplateService,
+    Characteristic,
+    DelegatedCharacteristicAdapter,
+    InvalidServiceError,
+    GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE,
+    GATT_TMAP_ROLE_CHARACTERISTIC,
+)
+from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
+class Role(enum.IntFlag):
+    CALL_GATEWAY = 1 << 0
+    CALL_TERMINAL = 1 << 1
+    UNICAST_MEDIA_SENDER = 1 << 2
+    UNICAST_MEDIA_RECEIVER = 1 << 3
+    BROADCAST_MEDIA_SENDER = 1 << 4
+    BROADCAST_MEDIA_RECEIVER = 1 << 5
+
+
+# -----------------------------------------------------------------------------
+class TelephonyAndMediaAudioService(TemplateService):
+    UUID = GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE
+
+    def __init__(self, role: Role):
+        self.role_characteristic = Characteristic(
+            GATT_TMAP_ROLE_CHARACTERISTIC,
+            Characteristic.Properties.READ,
+            Characteristic.READABLE,
+            struct.pack('<H', int(role)),
+        )
+
+        super().__init__([self.role_characteristic])
+
+
+# -----------------------------------------------------------------------------
+class TelephonyAndMediaAudioServiceProxy(ProfileServiceProxy):
+    SERVICE_CLASS = TelephonyAndMediaAudioService
+
+    role: DelegatedCharacteristicAdapter
+
+    def __init__(self, service_proxy: ServiceProxy):
+        self.service_proxy = service_proxy
+
+        if not (
+            characteristics := service_proxy.get_characteristics_by_uuid(
+                GATT_TMAP_ROLE_CHARACTERISTIC
+            )
+        ):
+            raise InvalidServiceError('TMAP Role characteristic not found')
+
+        self.role = DelegatedCharacteristicAdapter(
+            characteristics[0],
+            decode=lambda value: Role(
+                struct.unpack_from('<H', value, 0)[0],
+            ),
+        )
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index 2d8a627..2de7374 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -36,7 +36,9 @@
     BT_RFCOMM_PROTOCOL_ID,
     BT_BR_EDR_TRANSPORT,
     BT_L2CAP_PROTOCOL_ID,
+    InvalidArgumentError,
     InvalidStateError,
+    InvalidPacketError,
     ProtocolError,
 )
 
@@ -335,7 +337,7 @@
         frame = RFCOMM_Frame(frame_type, c_r, dlci, p_f, information)
         if frame.fcs != fcs:
             logger.warning(f'FCS mismatch: got {fcs:02X}, expected {frame.fcs:02X}')
-            raise ValueError('fcs mismatch')
+            raise InvalidPacketError('fcs mismatch')
 
         return frame
 
@@ -713,7 +715,7 @@
                 # Automatically convert strings to bytes using UTF-8
                 data = data.encode('utf-8')
             else:
-                raise ValueError('write only accept bytes or strings')
+                raise InvalidArgumentError('write only accept bytes or strings')
 
         self.tx_buffer += data
         self.drained.clear()
@@ -734,7 +736,16 @@
         self.emit('close')
 
     def __str__(self) -> str:
-        return f'DLC(dlci={self.dlci},state={self.state.name})'
+        return (
+            f'DLC(dlci={self.dlci}, '
+            f'state={self.state.name}, '
+            f'rx_max_frame_size={self.rx_max_frame_size}, '
+            f'rx_credits={self.rx_credits}, '
+            f'rx_max_credits={self.rx_max_credits}, '
+            f'tx_max_frame_size={self.tx_max_frame_size}, '
+            f'tx_credits={self.tx_credits}'
+            ')'
+        )
 
 
 # -----------------------------------------------------------------------------
diff --git a/bumble/sdp.py b/bumble/sdp.py
index 543c322..88c575d 100644
--- a/bumble/sdp.py
+++ b/bumble/sdp.py
@@ -23,7 +23,7 @@
 
 from . import core, l2cap
 from .colors import color
-from .core import InvalidStateError
+from .core import InvalidStateError, InvalidArgumentError, InvalidPacketError
 from .hci import HCI_Object, name_or_number, key_with_value
 
 if TYPE_CHECKING:
@@ -189,7 +189,9 @@
         self.bytes = None
         if element_type in (DataElement.UNSIGNED_INTEGER, DataElement.SIGNED_INTEGER):
             if value_size is None:
-                raise ValueError('integer types must have a value size specified')
+                raise InvalidArgumentError(
+                    'integer types must have a value size specified'
+                )
 
     @staticmethod
     def nil() -> DataElement:
@@ -265,7 +267,7 @@
         if len(data) == 8:
             return struct.unpack('>Q', data)[0]
 
-        raise ValueError(f'invalid integer length {len(data)}')
+        raise InvalidPacketError(f'invalid integer length {len(data)}')
 
     @staticmethod
     def signed_integer_from_bytes(data):
@@ -281,7 +283,7 @@
         if len(data) == 8:
             return struct.unpack('>q', data)[0]
 
-        raise ValueError(f'invalid integer length {len(data)}')
+        raise InvalidPacketError(f'invalid integer length {len(data)}')
 
     @staticmethod
     def list_from_bytes(data):
@@ -354,7 +356,7 @@
             data = b''
         elif self.type == DataElement.UNSIGNED_INTEGER:
             if self.value < 0:
-                raise ValueError('UNSIGNED_INTEGER cannot be negative')
+                raise InvalidArgumentError('UNSIGNED_INTEGER cannot be negative')
 
             if self.value_size == 1:
                 data = struct.pack('B', self.value)
@@ -365,7 +367,7 @@
             elif self.value_size == 8:
                 data = struct.pack('>Q', self.value)
             else:
-                raise ValueError('invalid value_size')
+                raise InvalidArgumentError('invalid value_size')
         elif self.type == DataElement.SIGNED_INTEGER:
             if self.value_size == 1:
                 data = struct.pack('b', self.value)
@@ -376,7 +378,7 @@
             elif self.value_size == 8:
                 data = struct.pack('>q', self.value)
             else:
-                raise ValueError('invalid value_size')
+                raise InvalidArgumentError('invalid value_size')
         elif self.type == DataElement.UUID:
             data = bytes(reversed(bytes(self.value)))
         elif self.type == DataElement.URL:
@@ -392,7 +394,7 @@
         size_bytes = b''
         if self.type == DataElement.NIL:
             if size != 0:
-                raise ValueError('NIL must be empty')
+                raise InvalidArgumentError('NIL must be empty')
             size_index = 0
         elif self.type in (
             DataElement.UNSIGNED_INTEGER,
@@ -410,7 +412,7 @@
             elif size == 16:
                 size_index = 4
             else:
-                raise ValueError('invalid data size')
+                raise InvalidArgumentError('invalid data size')
         elif self.type in (
             DataElement.TEXT_STRING,
             DataElement.SEQUENCE,
@@ -427,10 +429,10 @@
                 size_index = 7
                 size_bytes = struct.pack('>I', size)
             else:
-                raise ValueError('invalid data size')
+                raise InvalidArgumentError('invalid data size')
         elif self.type == DataElement.BOOLEAN:
             if size != 1:
-                raise ValueError('boolean must be 1 byte')
+                raise InvalidArgumentError('boolean must be 1 byte')
             size_index = 0
 
         self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
diff --git a/bumble/smp.py b/bumble/smp.py
index 3a88a31..5d6bcc5 100644
--- a/bumble/smp.py
+++ b/bumble/smp.py
@@ -55,6 +55,7 @@
     BT_CENTRAL_ROLE,
     BT_LE_TRANSPORT,
     AdvertisingData,
+    InvalidArgumentError,
     ProtocolError,
     name_or_number,
 )
@@ -766,8 +767,11 @@
         self.oob_data_flag = 0 if pairing_config.oob is None else 1
 
         # Set up addresses
-        self_address = connection.self_address
+        self_address = connection.self_resolvable_address or connection.self_address
         peer_address = connection.peer_resolvable_address or connection.peer_address
+        logger.debug(
+            f"pairing with self_address={self_address}, peer_address={peer_address}"
+        )
         if self.is_initiator:
             self.ia = bytes(self_address)
             self.iat = 1 if self_address.is_random else 0
@@ -784,7 +788,7 @@
             self.peer_oob_data = pairing_config.oob.peer_data
             if pairing_config.sc:
                 if pairing_config.oob.our_context is None:
-                    raise ValueError(
+                    raise InvalidArgumentError(
                         "oob pairing config requires a context when sc is True"
                     )
                 self.r = pairing_config.oob.our_context.r
@@ -793,7 +797,7 @@
                     self.tk = pairing_config.oob.legacy_context.tk
             else:
                 if pairing_config.oob.legacy_context is None:
-                    raise ValueError(
+                    raise InvalidArgumentError(
                         "oob pairing config requires a legacy context when sc is False"
                     )
                 self.r = bytes(16)
@@ -1074,11 +1078,19 @@
         )
 
     def send_identity_address_command(self) -> None:
-        identity_address = {
-            None: self.connection.self_address,
-            Address.PUBLIC_DEVICE_ADDRESS: self.manager.device.public_address,
-            Address.RANDOM_DEVICE_ADDRESS: self.manager.device.random_address,
-        }[self.pairing_config.identity_address_type]
+        if self.pairing_config.identity_address_type == Address.PUBLIC_DEVICE_ADDRESS:
+            identity_address = self.manager.device.public_address
+        elif self.pairing_config.identity_address_type == Address.RANDOM_DEVICE_ADDRESS:
+            identity_address = self.manager.device.static_address
+        else:
+            # No identity address type set. If the controller has a public address, it
+            # will be more responsible to be the identity address.
+            if self.manager.device.public_address != Address.ANY:
+                logger.debug("No identity address type set, using PUBLIC")
+                identity_address = self.manager.device.public_address
+            else:
+                logger.debug("No identity address type set, using RANDOM")
+                identity_address = self.manager.device.static_address
         self.send_command(
             SMP_Identity_Address_Information_Command(
                 addr_type=identity_address.address_type,
diff --git a/bumble/snoop.py b/bumble/snoop.py
index 4b331d2..326603f 100644
--- a/bumble/snoop.py
+++ b/bumble/snoop.py
@@ -23,6 +23,7 @@
 from typing import BinaryIO, Generator
 import os
 
+from bumble import core
 from bumble.hci import HCI_COMMAND_PACKET, HCI_EVENT_PACKET
 
 
@@ -138,13 +139,13 @@
 
     """
     if ':' not in spec:
-        raise ValueError('snooper type prefix missing')
+        raise core.InvalidArgumentError('snooper type prefix missing')
 
     snooper_type, snooper_args = spec.split(':', maxsplit=1)
 
     if snooper_type == 'btsnoop':
         if ':' not in snooper_args:
-            raise ValueError('I/O type for btsnoop snooper type missing')
+            raise core.InvalidArgumentError('I/O type for btsnoop snooper type missing')
 
         io_type, io_name = snooper_args.split(':', maxsplit=1)
         if io_type == 'file':
@@ -165,6 +166,6 @@
                 _SNOOPER_INSTANCE_COUNT -= 1
                 return
 
-        raise ValueError(f'I/O type {io_type} not supported')
+        raise core.InvalidArgumentError(f'I/O type {io_type} not supported')
 
-    raise ValueError(f'snooper type {snooper_type} not found')
+    raise core.InvalidArgumentError(f'snooper type {snooper_type} not found')
diff --git a/bumble/transport/__init__.py b/bumble/transport/__init__.py
index 6a9a6b5..0d42343 100644
--- a/bumble/transport/__init__.py
+++ b/bumble/transport/__init__.py
@@ -20,7 +20,7 @@
 import os
 from typing import Optional
 
-from .common import Transport, AsyncPipeSink, SnoopingTransport
+from .common import Transport, AsyncPipeSink, SnoopingTransport, TransportSpecError
 from ..snoop import create_snooper
 
 # -----------------------------------------------------------------------------
@@ -180,7 +180,13 @@
 
         return await open_android_netsim_transport(spec)
 
-    raise ValueError('unknown transport scheme')
+    if scheme == 'unix':
+        from .unix import open_unix_client_transport
+
+        assert spec
+        return await open_unix_client_transport(spec)
+
+    raise TransportSpecError('unknown transport scheme')
 
 
 # -----------------------------------------------------------------------------
diff --git a/bumble/transport/android_emulator.py b/bumble/transport/android_emulator.py
index 9cd7ec2..d2bc8ef 100644
--- a/bumble/transport/android_emulator.py
+++ b/bumble/transport/android_emulator.py
@@ -20,7 +20,13 @@
 
 from typing import Optional, Union
 
-from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
+from .common import (
+    PumpedTransport,
+    PumpedPacketSource,
+    PumpedPacketSink,
+    Transport,
+    TransportSpecError,
+)
 
 # pylint: disable=no-name-in-module
 from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
@@ -77,7 +83,7 @@
             elif ':' in param:
                 server_host, server_port = param.split(':')
             else:
-                raise ValueError('invalid parameter')
+                raise TransportSpecError('invalid parameter')
 
     # Connect to the gRPC server
     server_address = f'{server_host}:{server_port}'
@@ -94,7 +100,7 @@
         service = VhciForwardingServiceStub(channel)
         hci_device = HciDevice(service.attachVhci())
     else:
-        raise ValueError('invalid mode')
+        raise TransportSpecError('invalid mode')
 
     # Create the transport object
     class EmulatorTransport(PumpedTransport):
diff --git a/bumble/transport/android_netsim.py b/bumble/transport/android_netsim.py
index e9d36cd..264266d 100644
--- a/bumble/transport/android_netsim.py
+++ b/bumble/transport/android_netsim.py
@@ -31,6 +31,8 @@
     PumpedPacketSource,
     PumpedPacketSink,
     Transport,
+    TransportSpecError,
+    TransportInitError,
 )
 
 # pylint: disable=no-name-in-module
@@ -135,7 +137,7 @@
     server_host: Optional[str], server_port: int, options: Dict[str, str]
 ) -> Transport:
     if not server_port:
-        raise ValueError('invalid port')
+        raise TransportSpecError('invalid port')
     if server_host == '_' or not server_host:
         server_host = 'localhost'
 
@@ -288,7 +290,7 @@
         instance_number = 0 if options is None else int(options.get('instance', '0'))
         server_port = find_grpc_port(instance_number)
         if not server_port:
-            raise RuntimeError('gRPC server port not found')
+            raise TransportInitError('gRPC server port not found')
 
     # Connect to the gRPC server
     server_address = f'{server_host}:{server_port}'
@@ -326,7 +328,7 @@
 
             if response_type == 'error':
                 logger.warning(f'received error: {response.error}')
-                raise RuntimeError(response.error)
+                raise TransportInitError(response.error)
 
             if response_type == 'hci_packet':
                 return (
@@ -334,7 +336,7 @@
                     + response.hci_packet.packet
                 )
 
-            raise ValueError('unsupported response type')
+            raise TransportSpecError('unsupported response type')
 
         async def write(self, packet):
             await self.hci_device.write(
@@ -429,7 +431,7 @@
     options: Dict[str, str] = {}
     for param in params[params_offset:]:
         if '=' not in param:
-            raise ValueError('invalid parameter, expected <name>=<value>')
+            raise TransportSpecError('invalid parameter, expected <name>=<value>')
         option_name, option_value = param.split('=')
         options[option_name] = option_value
 
@@ -440,7 +442,7 @@
         )
     if mode == 'controller':
         if host is None:
-            raise ValueError('<host>:<port> missing')
+            raise TransportSpecError('<host>:<port> missing')
         return await open_android_netsim_controller_transport(host, port, options)
 
-    raise ValueError('invalid mode option')
+    raise TransportSpecError('invalid mode option')
diff --git a/bumble/transport/common.py b/bumble/transport/common.py
index ffbf7b0..f2c7fcb 100644
--- a/bumble/transport/common.py
+++ b/bumble/transport/common.py
@@ -23,6 +23,7 @@
 import io
 from typing import Any, ContextManager, Tuple, Optional, Protocol, Dict
 
+from bumble import core
 from bumble import hci
 from bumble.colors import color
 from bumble.snoop import Snooper
@@ -49,10 +50,16 @@
 # -----------------------------------------------------------------------------
 # Errors
 # -----------------------------------------------------------------------------
-class TransportLostError(Exception):
-    """
-    The Transport has been lost/disconnected.
-    """
+class TransportLostError(core.BaseBumbleError, RuntimeError):
+    """The Transport has been lost/disconnected."""
+
+
+class TransportInitError(core.BaseBumbleError, RuntimeError):
+    """Error raised when the transport cannot be initialized."""
+
+
+class TransportSpecError(core.BaseBumbleError, ValueError):
+    """Error raised when the transport spec is invalid."""
 
 
 # -----------------------------------------------------------------------------
@@ -132,7 +139,9 @@
                         packet_type
                     ) or self.extended_packet_info.get(packet_type)
                     if self.packet_info is None:
-                        raise ValueError(f'invalid packet type {packet_type}')
+                        raise core.InvalidPacketError(
+                            f'invalid packet type {packet_type}'
+                        )
                     self.state = PacketParser.NEED_LENGTH
                     self.bytes_needed = self.packet_info[0] + self.packet_info[1]
                 elif self.state == PacketParser.NEED_LENGTH:
@@ -178,19 +187,19 @@
         # Get the packet info based on its type
         packet_info = HCI_PACKET_INFO.get(packet_type[0])
         if packet_info is None:
-            raise ValueError(f'invalid packet type {packet_type[0]} found')
+            raise core.InvalidPacketError(f'invalid packet type {packet_type[0]} found')
 
         # Read the header (that includes the length)
         header_size = packet_info[0] + packet_info[1]
         header = self.source.read(header_size)
         if len(header) != header_size:
-            raise ValueError('packet too short')
+            raise core.InvalidPacketError('packet too short')
 
         # Read the body
         body_length = struct.unpack_from(packet_info[2], header, packet_info[1])[0]
         body = self.source.read(body_length)
         if len(body) != body_length:
-            raise ValueError('packet too short')
+            raise core.InvalidPacketError('packet too short')
 
         return packet_type + header + body
 
@@ -211,7 +220,7 @@
         # Get the packet info based on its type
         packet_info = HCI_PACKET_INFO.get(packet_type[0])
         if packet_info is None:
-            raise ValueError(f'invalid packet type {packet_type[0]} found')
+            raise core.InvalidPacketError(f'invalid packet type {packet_type[0]} found')
 
         # Read the header (that includes the length)
         header_size = packet_info[0] + packet_info[1]
@@ -239,26 +248,28 @@
 
 
 # -----------------------------------------------------------------------------
-class ParserSource:
+class BaseSource:
     """
     Base class designed to be subclassed by transport-specific source classes
     """
 
     terminated: asyncio.Future[None]
-    parser: PacketParser
+    sink: Optional[TransportSink]
 
     def __init__(self) -> None:
-        self.parser = PacketParser()
         self.terminated = asyncio.get_running_loop().create_future()
+        self.sink = None
 
     def set_packet_sink(self, sink: TransportSink) -> None:
-        self.parser.set_packet_sink(sink)
+        self.sink = sink
 
     def on_transport_lost(self) -> None:
-        self.terminated.set_result(None)
-        if self.parser.sink:
-            if hasattr(self.parser.sink, 'on_transport_lost'):
-                self.parser.sink.on_transport_lost()
+        if not self.terminated.done():
+            self.terminated.set_result(None)
+
+        if self.sink:
+            if hasattr(self.sink, 'on_transport_lost'):
+                self.sink.on_transport_lost()
 
     async def wait_for_termination(self) -> None:
         """
@@ -272,6 +283,23 @@
 
 
 # -----------------------------------------------------------------------------
+class ParserSource(BaseSource):
+    """
+    Base class for sources that use an HCI parser.
+    """
+
+    parser: PacketParser
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.parser = PacketParser()
+
+    def set_packet_sink(self, sink: TransportSink) -> None:
+        super().set_packet_sink(sink)
+        self.parser.set_packet_sink(sink)
+
+
+# -----------------------------------------------------------------------------
 class StreamPacketSource(asyncio.Protocol, ParserSource):
     def data_received(self, data: bytes) -> None:
         self.parser.feed_data(data)
@@ -420,7 +448,7 @@
             return SnoopingTransport(
                 transport, exit_stack.enter_context(snooper), exit_stack.pop_all().close
             )
-        raise RuntimeError('unexpected code path')  # Satisfy the type checker
+        raise core.UnreachableError()  # Satisfy the type checker
 
     class Source:
         sink: TransportSink
diff --git a/bumble/transport/pyusb.py b/bumble/transport/pyusb.py
index 68a1dfd..26f9991 100644
--- a/bumble/transport/pyusb.py
+++ b/bumble/transport/pyusb.py
@@ -23,13 +23,13 @@
 import usb.core
 import usb.util
 
-from typing import Optional
+from typing import Optional, Set
 from usb.core import Device as UsbDevice
 from usb.core import USBError
 from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
 from usb.legacy import REQ_SET_FEATURE, REQ_CLEAR_FEATURE, CLASS_HUB
 
-from .common import Transport, ParserSource
+from .common import Transport, ParserSource, TransportInitError
 from .. import hci
 from ..colors import color
 
@@ -46,6 +46,11 @@
 # -----------------------------------------------------------------------------
 logger = logging.getLogger(__name__)
 
+# -----------------------------------------------------------------------------
+# Global
+# -----------------------------------------------------------------------------
+devices_in_use: Set[int] = set()
+
 
 # -----------------------------------------------------------------------------
 async def open_pyusb_transport(spec: str) -> Transport:
@@ -216,6 +221,7 @@
         async def close(self):
             await self.source.stop()
             await self.sink.stop()
+            devices_in_use.remove(device.address)
             usb.util.release_interface(self.device, 0)
 
     usb_find = usb.core.find
@@ -233,7 +239,18 @@
         spec = spec[1:]
     if ':' in spec:
         vendor_id, product_id = spec.split(':')
-        device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
+        device = None
+        devices = usb_find(
+            find_all=True, idVendor=int(vendor_id, 16), idProduct=int(product_id, 16)
+        )
+        for d in devices:
+            if d.address in devices_in_use:
+                continue
+            device = d
+            devices_in_use.add(d.address)
+            break
+        if device is None:
+            raise ValueError('device already in use')
     elif '-' in spec:
 
         def device_path(device):
@@ -259,7 +276,7 @@
             device = None
 
     if device is None:
-        raise ValueError('device not found')
+        raise TransportInitError('device not found')
     logger.debug(f'USB Device: {device}')
 
     # Power Cycle the device
diff --git a/bumble/transport/unix.py b/bumble/transport/unix.py
new file mode 100644
index 0000000..973872b
--- /dev/null
+++ b/bumble/transport/unix.py
@@ -0,0 +1,56 @@
+# 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
+
+from .common import Transport, StreamPacketSource, StreamPacketSink
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+async def open_unix_client_transport(spec: str) -> Transport:
+    '''Open a UNIX socket client transport.
+
+    The parameter is the path of unix socket. For abstract socket, the first character
+    needs to be '@'.
+
+    Example:
+        * /tmp/hci.socket
+        * @hci_socket
+    '''
+
+    class UnixPacketSource(StreamPacketSource):
+        def connection_lost(self, exc):
+            logger.debug(f'connection lost: {exc}')
+            self.on_transport_lost()
+
+    # For abstract socket, the first character should be null character.
+    if spec.startswith('@'):
+        spec = '\0' + spec[1:]
+
+    (
+        unix_transport,
+        packet_source,
+    ) = await asyncio.get_running_loop().create_unix_connection(UnixPacketSource, spec)
+    packet_sink = StreamPacketSink(unix_transport)
+
+    return Transport(packet_source, packet_sink)
diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py
index 69e9649..0b865cf 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -15,19 +15,18 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
 import asyncio
 import logging
 import threading
-import collections
 import ctypes
 import platform
 
 import usb1
 
-from bumble.transport.common import Transport, ParserSource
+from bumble.transport.common import Transport, BaseSource, TransportInitError
 from bumble import hci
 from bumble.colors import color
-from bumble.utils import AsyncRunner
 
 
 # -----------------------------------------------------------------------------
@@ -115,13 +114,17 @@
             self.device = device
             self.acl_out = acl_out
             self.acl_out_transfer = device.getTransfer()
-            self.packets = collections.deque()  # Queue of packets waiting to be sent
+            self.acl_out_transfer_ready = asyncio.Semaphore(1)
+            self.packets: asyncio.Queue[bytes] = (
+                asyncio.Queue()
+            )  # Queue of packets waiting to be sent
             self.loop = asyncio.get_running_loop()
+            self.queue_task = None
             self.cancel_done = self.loop.create_future()
             self.closed = False
 
         def start(self):
-            pass
+            self.queue_task = asyncio.create_task(self.process_queue())
 
         def on_packet(self, packet):
             # Ignore packets if we're closed
@@ -133,62 +136,64 @@
                 return
 
             # Queue the packet
-            self.packets.append(packet)
-            if len(self.packets) == 1:
-                # The queue was previously empty, re-prime the pump
-                self.process_queue()
+            self.packets.put_nowait(packet)
 
         def transfer_callback(self, transfer):
+            self.loop.call_soon_threadsafe(self.acl_out_transfer_ready.release)
             status = transfer.getStatus()
 
             # pylint: disable=no-member
-            if status == usb1.TRANSFER_COMPLETED:
-                self.loop.call_soon_threadsafe(self.on_packet_sent)
-            elif status == usb1.TRANSFER_CANCELLED:
+            if status == usb1.TRANSFER_CANCELLED:
                 self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
-            else:
+                return
+
+            if status != usb1.TRANSFER_COMPLETED:
                 logger.warning(
                     color(f'!!! OUT transfer not completed: status={status}', 'red')
                 )
 
-        def on_packet_sent(self):
-            if self.packets:
-                self.packets.popleft()
-                self.process_queue()
+        async def process_queue(self):
+            while True:
+                # Wait for a packet to transfer.
+                packet = await self.packets.get()
 
-        def process_queue(self):
-            if len(self.packets) == 0:
-                return  # Nothing to do
+                # Wait until we can start a transfer.
+                await self.acl_out_transfer_ready.acquire()
 
-            packet = self.packets[0]
-            packet_type = packet[0]
-            if packet_type == hci.HCI_ACL_DATA_PACKET:
-                self.acl_out_transfer.setBulk(
-                    self.acl_out, packet[1:], callback=self.transfer_callback
-                )
-                self.acl_out_transfer.submit()
-            elif packet_type == hci.HCI_COMMAND_PACKET:
-                self.acl_out_transfer.setControl(
-                    USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
-                    0,
-                    0,
-                    0,
-                    packet[1:],
-                    callback=self.transfer_callback,
-                )
-                self.acl_out_transfer.submit()
-            else:
-                logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
+                # Transfer the packet.
+                packet_type = packet[0]
+                if packet_type == hci.HCI_ACL_DATA_PACKET:
+                    self.acl_out_transfer.setBulk(
+                        self.acl_out, packet[1:], callback=self.transfer_callback
+                    )
+                    self.acl_out_transfer.submit()
+                elif packet_type == hci.HCI_COMMAND_PACKET:
+                    self.acl_out_transfer.setControl(
+                        USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
+                        0,
+                        0,
+                        0,
+                        packet[1:],
+                        callback=self.transfer_callback,
+                    )
+                    self.acl_out_transfer.submit()
+                else:
+                    logger.warning(
+                        color(f'unsupported packet type {packet_type}', 'red')
+                    )
 
         def close(self):
             self.closed = True
+            if self.queue_task:
+                self.queue_task.cancel()
 
         async def terminate(self):
             if not self.closed:
                 self.close()
 
             # Empty the packet queue so that we don't send any more data
-            self.packets.clear()
+            while not self.packets.empty():
+                self.packets.get_nowait()
 
             # If we have a transfer in flight, cancel it
             if self.acl_out_transfer.isSubmitted():
@@ -203,7 +208,7 @@
                 except usb1.USBError:
                     logger.debug('OUT transfer likely already completed')
 
-    class UsbPacketSource(asyncio.Protocol, ParserSource):
+    class UsbPacketSource(asyncio.Protocol, BaseSource):
         def __init__(self, device, metadata, acl_in, events_in):
             super().__init__()
             self.device = device
@@ -280,7 +285,13 @@
                     packet = await self.queue.get()
                 except asyncio.CancelledError:
                     return
-                self.parser.feed_data(packet)
+                if self.sink:
+                    try:
+                        self.sink.on_packet(packet)
+                    except Exception as error:
+                        logger.exception(
+                            color(f'!!! Exception in sink.on_packet: {error}', 'red')
+                        )
 
         def close(self):
             self.closed = True
@@ -442,7 +453,7 @@
 
         if found is None:
             context.close()
-            raise ValueError('device not found')
+            raise TransportInitError('device not found')
 
         logger.debug(f'USB Device: {found}')
 
@@ -507,7 +518,7 @@
 
         endpoints = find_endpoints(found)
         if endpoints is None:
-            raise ValueError('no compatible interface found for device')
+            raise TransportInitError('no compatible interface found for device')
         (configuration, interface, setting, acl_in, acl_out, events_in) = endpoints
         logger.debug(
             f'selected endpoints: configuration={configuration}, '
diff --git a/docs/images/favicon.ico b/docs/images/favicon.ico
new file mode 100644
index 0000000..8b83a50
--- /dev/null
+++ b/docs/images/favicon.ico
Binary files differ
diff --git a/examples/device_with_rpa.json b/examples/device_with_rpa.json
new file mode 100644
index 0000000..56f1ec2
--- /dev/null
+++ b/examples/device_with_rpa.json
@@ -0,0 +1,7 @@
+{
+    "name": "Bumble",
+    "address": "F0:F1:F2:F3:F4:F5",
+    "keystore": "JsonKeyStore",
+    "irk": "865F81FF5A8B486EAAE29A27AD9F77DC",
+    "le_privacy_enabled": true
+}
diff --git a/examples/leaudio.json b/examples/leaudio.json
index ad5f6c8..3c48166 100644
--- a/examples/leaudio.json
+++ b/examples/leaudio.json
@@ -3,5 +3,6 @@
     "keystore": "JsonKeyStore",
     "address": "F0:F1:F2:F3:F4:FA",
     "class_of_device": 2376708,
+    "cis_enabled": true,
     "advertising_interval": 100
 }
diff --git a/examples/mcp_server.html b/examples/mcp_server.html
new file mode 100644
index 0000000..b0b98d7
--- /dev/null
+++ b/examples/mcp_server.html
@@ -0,0 +1,83 @@
+<html data-bs-theme="dark">
+
+<head>
+    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
+        integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
+</head>
+
+<body>
+    <nav class="navbar navbar-dark bg-primary">
+        <div class="container">
+            <span class="navbar-brand mb-0 h1">Bumble LEA Media Control Client</span>
+        </div>
+    </nav>
+    <br>
+
+    <div class="container">
+
+        <label 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>
+
+        <button class="btn btn-primary" onclick="send_opcode(0x01)">Play</button>
+        <button class="btn btn-primary" onclick="send_opcode(0x02)">Pause</button>
+        <button class="btn btn-primary" onclick="send_opcode(0x03)">Fast Rewind</button>
+        <button class="btn btn-primary" onclick="send_opcode(0x04)">Fast Forward</button>
+        <button class="btn btn-primary" onclick="send_opcode(0x05)">Stop</button>
+
+        </br></br>
+
+        <button class="btn btn-primary" onclick="send_opcode(0x30)">Previous Track</button>
+        <button class="btn btn-primary" onclick="send_opcode(0x31)">Next Track</button>
+
+        <hr>
+
+        <div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
+            <h3>Log</h3>
+            <code id="log" style="white-space: pre-line;"></code>
+        </div>
+    </div>
+
+
+    <script>
+        let portInput = document.getElementById("port")
+        let log = document.getElementById("log")
+        let socket
+
+        function connect() {
+            socket = new WebSocket(`ws://localhost:${portInput.value}`);
+            socket.onopen = _ => {
+                log.textContent += 'OPEN\n'
+            }
+            socket.onclose = _ => {
+                log.textContent += 'CLOSED\n'
+            }
+            socket.onerror = (error) => {
+                log.textContent += 'ERROR\n'
+                console.log(`ERROR: ${error}`)
+            }
+            socket.onmessage = (event) => {
+                log.textContent += `<-- ${event.data}\n`
+            }
+        }
+
+        function send(message) {
+            if (socket && socket.readyState == WebSocket.OPEN) {
+                let jsonMessage = JSON.stringify(message)
+                log.textContent += `--> ${jsonMessage}\n`
+                socket.send(jsonMessage)
+            } else {
+                log.textContent += 'NOT CONNECTED\n'
+            }
+        }
+
+        function send_opcode(opcode) {
+            send({ 'opcode': opcode })
+        }
+    </script>
+    </div>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/examples/run_hid_device.py b/examples/run_hid_device.py
index 2287be0..160e395 100644
--- a/examples/run_hid_device.py
+++ b/examples/run_hid_device.py
@@ -21,7 +21,7 @@
 import logging
 import json
 import websockets
-from bumble.colors import color
+import struct
 
 from bumble.device import Device
 from bumble.transport import open_transport_or_link
@@ -30,9 +30,7 @@
     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,
@@ -40,20 +38,17 @@
     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
@@ -430,7 +425,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def keyboard_device(hid_device):
+async def keyboard_device(hid_device: HID_Device):
 
     # Start a Websocket server to receive events from a web page
     async def serve(websocket, _path):
@@ -476,9 +471,9 @@
                     # 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
+                    deviceData.mouseData = bytearray([0x02, 0x00]) + struct.pack(
+                        ">bb", x, y
+                    )
                     hid_device.send_data(deviceData.mouseData)
             except websockets.exceptions.ConnectionClosedOK:
                 pass
@@ -515,7 +510,9 @@
     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):
+    def on_get_report_cb(
+        report_id: int, report_type: int, buffer_size: int
+    ) -> HID_Device.GetSetStatus:
         retValue = hid_device.GetSetStatus()
         print(
             "GET_REPORT report_id: "
@@ -555,8 +552,7 @@
 
     def on_set_report_cb(
         report_id: int, report_type: int, report_size: int, data: bytes
-    ):
-        retValue = hid_device.GetSetStatus()
+    ) -> HID_Device.GetSetStatus:
         print(
             "SET_REPORT report_id: "
             + str(report_id)
@@ -568,33 +564,33 @@
             + str(data)
         )
         if report_type == Message.ReportType.FEATURE_REPORT:
-            retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+            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
+                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
+                status = HID_Device.GetSetReturn.ERR_INVALID_PARAMETER
             elif report_id == 3:
-                retValue.status = hid_device.GetSetReturn.REPORT_ID_NOT_FOUND
+                status = HID_Device.GetSetReturn.REPORT_ID_NOT_FOUND
             else:
-                retValue.status = hid_device.GetSetReturn.SUCCESS
+                status = HID_Device.GetSetReturn.SUCCESS
         else:
-            retValue.status = hid_device.GetSetReturn.SUCCESS
+            status = HID_Device.GetSetReturn.SUCCESS
 
-        return retValue
+        return HID_Device.GetSetStatus(status=status)
 
-    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_get_protocol_cb() -> HID_Device.GetSetStatus:
+        return HID_Device.GetSetStatus(
+            data=bytes([protocol_mode]),
+            status=hid_device.GetSetReturn.SUCCESS,
+        )
 
-    def on_set_protocol_cb(protocol: int):
-        retValue = hid_device.GetSetStatus()
+    def on_set_protocol_cb(protocol: int) -> 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
+        return HID_Device.GetSetStatus(
+            status=hid_device.GetSetReturn.ERR_UNSUPPORTED_REQUEST
+        )
 
     def on_virtual_cable_unplug_cb():
         print('Received Virtual Cable Unplug')
diff --git a/examples/run_mcp_client.py b/examples/run_mcp_client.py
new file mode 100644
index 0000000..83dad5b
--- /dev/null
+++ b/examples/run_mcp_client.py
@@ -0,0 +1,194 @@
+# 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 websockets
+import json
+
+from bumble.core import AdvertisingData
+from bumble.device import (
+    Device,
+    AdvertisingParameters,
+    AdvertisingEventProperties,
+    Connection,
+    Peer,
+)
+from bumble.hci import (
+    CodecID,
+    CodingFormat,
+    OwnAddressType,
+)
+from bumble.profiles.ascs import AudioStreamControlService
+from bumble.profiles.bap import (
+    CodecSpecificCapabilities,
+    ContextType,
+    AudioLocation,
+    SupportedSamplingFrequency,
+    SupportedFrameDuration,
+    UnicastServerAdvertisingData,
+)
+from bumble.profiles.mcp import (
+    MediaControlServiceProxy,
+    GenericMediaControlServiceProxy,
+    MediaState,
+    MediaControlPointOpcode,
+)
+from bumble.profiles.pacs import PacRecord, PublishedAudioCapabilitiesService
+from bumble.transport import open_transport_or_link
+
+from typing import Optional
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+    if len(sys.argv) < 3:
+        print('Usage: run_mcp_client.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.
+        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=[
+                    PacRecord(
+                        coding_format=CodingFormat(CodecID.LC3),
+                        codec_specific_capabilities=CodecSpecificCapabilities(
+                            supported_sampling_frequencies=(
+                                SupportedSamplingFrequency.FREQ_16000
+                                | SupportedSamplingFrequency.FREQ_32000
+                                | SupportedSamplingFrequency.FREQ_48000
+                            ),
+                            supported_frame_durations=(
+                                SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+                            ),
+                            supported_audio_channel_count=[1, 2],
+                            min_octets_per_codec_frame=0,
+                            max_octets_per_codec_frame=320,
+                            supported_max_codec_frames_per_sdu=2,
+                        ),
+                    ),
+                ],
+            )
+        )
+        device.add_service(AudioStreamControlService(device, sink_ase_id=[1]))
+
+        ws: Optional[websockets.WebSocketServerProtocol] = None
+        mcp: Optional[MediaControlServiceProxy] = None
+
+        advertising_data = bytes(
+            AdvertisingData(
+                [
+                    (
+                        AdvertisingData.COMPLETE_LOCAL_NAME,
+                        bytes('Bumble LE Audio', 'utf-8'),
+                    ),
+                    (
+                        AdvertisingData.FLAGS,
+                        bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]),
+                    ),
+                    (
+                        AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+                        bytes(PublishedAudioCapabilitiesService.UUID),
+                    ),
+                ]
+            )
+        ) + bytes(UnicastServerAdvertisingData())
+
+        await device.create_advertising_set(
+            advertising_parameters=AdvertisingParameters(
+                advertising_event_properties=AdvertisingEventProperties(),
+                own_address_type=OwnAddressType.RANDOM,
+                primary_advertising_interval_max=100,
+                primary_advertising_interval_min=100,
+            ),
+            advertising_data=advertising_data,
+            auto_restart=True,
+        )
+
+        def on_media_state(media_state: MediaState) -> None:
+            if ws:
+                asyncio.create_task(
+                    ws.send(json.dumps({'media_state': media_state.name}))
+                )
+
+        def on_track_title(title: str) -> None:
+            if ws:
+                asyncio.create_task(ws.send(json.dumps({'title': title})))
+
+        def on_track_duration(duration: int) -> None:
+            if ws:
+                asyncio.create_task(ws.send(json.dumps({'duration': duration})))
+
+        def on_track_position(position: int) -> None:
+            if ws:
+                asyncio.create_task(ws.send(json.dumps({'position': position})))
+
+        def on_connection(connection: Connection) -> None:
+            async def on_connection_async():
+                async with Peer(connection) as peer:
+                    nonlocal mcp
+                    mcp = peer.create_service_proxy(MediaControlServiceProxy)
+                    if not mcp:
+                        mcp = peer.create_service_proxy(GenericMediaControlServiceProxy)
+                    mcp.on('media_state', on_media_state)
+                    mcp.on('track_title', on_track_title)
+                    mcp.on('track_duration', on_track_duration)
+                    mcp.on('track_position', on_track_position)
+                    await mcp.subscribe_characteristics()
+
+            connection.abort_on('disconnection', on_connection_async())
+
+        device.on('connection', on_connection)
+
+        async def serve(websocket: websockets.WebSocketServerProtocol, _path):
+            nonlocal ws
+            ws = websocket
+            async for message in websocket:
+                request = json.loads(message)
+                if mcp:
+                    await mcp.write_control_point(
+                        MediaControlPointOpcode(request['opcode'])
+                    )
+            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/run_unicast_server.py b/examples/run_unicast_server.py
index 95ae551..3ff1c96 100644
--- a/examples/run_unicast_server.py
+++ b/examples/run_unicast_server.py
@@ -34,8 +34,8 @@
     CodingFormat,
     HCI_IsoDataPacket,
 )
+from bumble.profiles.ascs import AseStateMachine, AudioStreamControlService
 from bumble.profiles.bap import (
-    AseStateMachine,
     UnicastServerAdvertisingData,
     CodecSpecificConfiguration,
     CodecSpecificCapabilities,
@@ -43,13 +43,10 @@
     AudioLocation,
     SupportedSamplingFrequency,
     SupportedFrameDuration,
-    PacRecord,
-    PublishedAudioCapabilitiesService,
-    AudioStreamControlService,
 )
 from bumble.profiles.cap import CommonAudioServiceService
 from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
-
+from bumble.profiles.pacs import PacRecord, PublishedAudioCapabilitiesService
 from bumble.transport import open_transport_or_link
 
 
diff --git a/examples/run_vcp_renderer.py b/examples/run_vcp_renderer.py
index 0cffbae..ba9c840 100644
--- a/examples/run_vcp_renderer.py
+++ b/examples/run_vcp_renderer.py
@@ -30,6 +30,7 @@
     CodingFormat,
     OwnAddressType,
 )
+from bumble.profiles.ascs import AudioStreamControlService
 from bumble.profiles.bap import (
     UnicastServerAdvertisingData,
     CodecSpecificCapabilities,
@@ -37,10 +38,8 @@
     AudioLocation,
     SupportedSamplingFrequency,
     SupportedFrameDuration,
-    PacRecord,
-    PublishedAudioCapabilitiesService,
-    AudioStreamControlService,
 )
+from bumble.profiles.pacs import PacRecord, PublishedAudioCapabilitiesService
 from bumble.profiles.cap import CommonAudioServiceService
 from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
 from bumble.profiles.vcp import VolumeControlService
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
index 6081837..dea3e3c 100644
--- 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
@@ -142,7 +142,7 @@
                 ::runRfcommClient,
                 ::runRfcommServer,
                 ::runL2capClient,
-                ::runL2capServer
+                ::runL2capServer,
             )
         }
 
@@ -166,6 +166,8 @@
                 "rfcomm-server" -> runRfcommServer()
                 "l2cap-client" -> runL2capClient()
                 "l2cap-server" -> runL2capServer()
+                "scan-start" -> runScan(true)
+                "stop-start" -> runScan(false)
             }
         }
     }
@@ -190,6 +192,11 @@
         l2capServer?.run()
     }
 
+    private fun runScan(startScan: Boolean) {
+        val scan = bluetoothAdapter?.let { Scan(it) }
+        scan?.run(startScan)
+    }
+
     @SuppressLint("MissingPermission")
     fun becomeDiscoverable() {
         val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
@@ -206,7 +213,7 @@
     runRfcommClient: () -> Unit,
     runRfcommServer: () -> Unit,
     runL2capClient: () -> Unit,
-    runL2capServer: () -> Unit
+    runL2capServer: () -> Unit,
 ) {
     BTBenchTheme {
         val scrollState = rememberScrollState()
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
index 1a8cd6d..66ceb0d 100644
--- 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
@@ -150,7 +150,8 @@
         } else if (senderPacketSizeSlider < 0.5F) {
             512
         } else if (senderPacketSizeSlider < 0.7F) {
-            1024
+            // 970 is a value that works well on Android.
+            970
         } else if (senderPacketSizeSlider < 0.9F) {
             2048
         } else {
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Scan.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Scan.kt
new file mode 100644
index 0000000..7cb8e7a
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Scan.kt
@@ -0,0 +1,38 @@
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanResult
+import java.util.logging.Logger
+
+private val Log = Logger.getLogger("btbench.scan")
+
+class Scan(val bluetoothAdapter: BluetoothAdapter) {
+    @SuppressLint("MissingPermission")
+    fun run(startScan: Boolean) {
+        var bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
+
+        val scanCallback = object : ScanCallback() {
+            override fun onScanResult(callbackType: Int, result: ScanResult?) {
+                super.onScanResult(callbackType, result)
+                val device: BluetoothDevice? = result?.device
+                val deviceName = device?.name ?: "Unknown"
+                val deviceAddress = device?.address ?: "Unknown"
+                Log.info("Device found: $deviceName ($deviceAddress)")
+            }
+
+            override fun onScanFailed(errorCode: Int) {
+                // Handle scan failure
+                Log.warning("Scan failed with error code: $errorCode")
+            }
+        }
+
+        if (startScan) {
+            bluetoothLeScanner?.startScan(scanCallback)
+        } else {
+            bluetoothLeScanner?.stopScan(scanCallback)
+        }
+    }
+}
\ No newline at end of file
diff --git a/tasks.py b/tasks.py
index fab7cf1..ba12765 100644
--- a/tasks.py
+++ b/tasks.py
@@ -20,7 +20,10 @@
 # Imports
 # -----------------------------------------------------------------------------
 import os
-
+import glob
+import shutil
+import urllib
+from pathlib import Path
 from invoke import task, call, Collection
 from invoke.exceptions import Exit, UnexpectedExit
 
@@ -206,4 +209,20 @@
 
 
 # -----------------------------------------------------------------------------
+@task
+def web_build(ctx):
+    # Step 1: build the wheel
+    build(ctx)
+    # Step 2: Copy the wheel to the web folder, so the http server can access it
+    newest_wheel = Path(max(glob.glob('dist/*.whl'), key=lambda f: os.path.getmtime(f)))
+    shutil.copy(newest_wheel, Path('web/'))
+    # Step 3: Write wheel's name to web/packageFile
+    with open(Path('web', 'packageFile'), mode='w') as package_file:
+        package_file.write(str(Path('/') / newest_wheel.name))
+    # Step 4: Success!
+    print('Include ?packageFile=true in your URL!')
+
+
+# -----------------------------------------------------------------------------
 web_tasks.add_task(serve)
+web_tasks.add_task(web_build, name="build")
diff --git a/tests/bap_test.py b/tests/bap_test.py
index 0b6db1a..0b57fcd 100644
--- a/tests/bap_test.py
+++ b/tests/bap_test.py
@@ -23,8 +23,9 @@
 
 from bumble import device
 from bumble.hci import CodecID, CodingFormat
-from bumble.profiles.bap import (
-    AudioLocation,
+from bumble.profiles.ascs import (
+    AudioStreamControlService,
+    AudioStreamControlServiceProxy,
     AseStateMachine,
     ASE_Operation,
     ASE_Config_Codec,
@@ -35,6 +36,9 @@
     ASE_Receiver_Stop_Ready,
     ASE_Release,
     ASE_Update_Metadata,
+)
+from bumble.profiles.bap import (
+    AudioLocation,
     SupportedFrameDuration,
     SupportedSamplingFrequency,
     SamplingFrequency,
@@ -42,12 +46,13 @@
     CodecSpecificCapabilities,
     CodecSpecificConfiguration,
     ContextType,
+)
+from bumble.profiles.pacs import (
     PacRecord,
-    AudioStreamControlService,
-    AudioStreamControlServiceProxy,
     PublishedAudioCapabilitiesService,
     PublishedAudioCapabilitiesServiceProxy,
 )
+from bumble.profiles.le_audio import Metadata
 from tests.test_utils import TwoDevices
 
 
@@ -97,7 +102,7 @@
     pac_record = PacRecord(
         coding_format=CodingFormat(CodecID.LC3),
         codec_specific_capabilities=cap,
-        metadata=b'',
+        metadata=Metadata([Metadata.Entry(tag=Metadata.Tag.VENDOR_SPECIFIC, data=b'')]),
     )
     assert PacRecord.from_bytes(bytes(pac_record)) == pac_record
 
@@ -142,7 +147,7 @@
 def test_ASE_Enable() -> None:
     operation = ASE_Enable(
         ase_id=[1, 2],
-        metadata=[b'foo', b'bar'],
+        metadata=[b'', b''],
     )
     basic_check(operation)
 
@@ -151,7 +156,7 @@
 def test_ASE_Update_Metadata() -> None:
     operation = ASE_Update_Metadata(
         ase_id=[1, 2],
-        metadata=[b'foo', b'bar'],
+        metadata=[b'', b''],
     )
     basic_check(operation)
 
diff --git a/tests/bass_test.py b/tests/bass_test.py
new file mode 100644
index 0000000..b893555
--- /dev/null
+++ b/tests/bass_test.py
@@ -0,0 +1,146 @@
+# 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 os
+import logging
+
+from bumble import hci
+from bumble.profiles import bass
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+def basic_operation_check(operation: bass.ControlPointOperation) -> None:
+    serialized = bytes(operation)
+    parsed = bass.ControlPointOperation.from_bytes(serialized)
+    assert bytes(parsed) == serialized
+
+
+# -----------------------------------------------------------------------------
+def test_operations() -> None:
+    op1 = bass.RemoteScanStoppedOperation()
+    basic_operation_check(op1)
+
+    op2 = bass.RemoteScanStartedOperation()
+    basic_operation_check(op2)
+
+    op3 = bass.AddSourceOperation(
+        hci.Address("AA:BB:CC:DD:EE:FF"),
+        34,
+        123456,
+        bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
+        456,
+        (),
+    )
+    basic_operation_check(op3)
+
+    op4 = bass.AddSourceOperation(
+        hci.Address("AA:BB:CC:DD:EE:FF"),
+        34,
+        123456,
+        bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
+        456,
+        (
+            bass.SubgroupInfo(6677, bytes.fromhex('aabbcc')),
+            bass.SubgroupInfo(8899, bytes.fromhex('ddeeff')),
+        ),
+    )
+    basic_operation_check(op4)
+
+    op5 = bass.ModifySourceOperation(
+        12,
+        bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
+        567,
+        (),
+    )
+    basic_operation_check(op5)
+
+    op6 = bass.ModifySourceOperation(
+        12,
+        bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
+        567,
+        (
+            bass.SubgroupInfo(6677, bytes.fromhex('112233')),
+            bass.SubgroupInfo(8899, bytes.fromhex('4567')),
+        ),
+    )
+    basic_operation_check(op6)
+
+    op7 = bass.SetBroadcastCodeOperation(
+        7, bytes.fromhex('a0a1a2a3a4a5a6a7a8a9aaabacadaeaf')
+    )
+    basic_operation_check(op7)
+
+    op8 = bass.RemoveSourceOperation(7)
+    basic_operation_check(op8)
+
+
+# -----------------------------------------------------------------------------
+def basic_broadcast_receive_state_check(brs: bass.BroadcastReceiveState) -> None:
+    serialized = bytes(brs)
+    parsed = bass.BroadcastReceiveState.from_bytes(serialized)
+    assert parsed is not None
+    assert bytes(parsed) == serialized
+
+
+def test_broadcast_receive_state() -> None:
+    subgroups = [
+        bass.SubgroupInfo(6677, bytes.fromhex('112233')),
+        bass.SubgroupInfo(8899, bytes.fromhex('4567')),
+    ]
+
+    brs1 = bass.BroadcastReceiveState(
+        12,
+        hci.Address("AA:BB:CC:DD:EE:FF"),
+        123,
+        123456,
+        bass.BroadcastReceiveState.PeriodicAdvertisingSyncState.SYNCHRONIZED_TO_PA,
+        bass.BroadcastReceiveState.BigEncryption.DECRYPTING,
+        b'',
+        subgroups,
+    )
+    basic_broadcast_receive_state_check(brs1)
+
+    brs2 = bass.BroadcastReceiveState(
+        12,
+        hci.Address("AA:BB:CC:DD:EE:FF"),
+        123,
+        123456,
+        bass.BroadcastReceiveState.PeriodicAdvertisingSyncState.SYNCHRONIZED_TO_PA,
+        bass.BroadcastReceiveState.BigEncryption.BAD_CODE,
+        bytes.fromhex('a0a1a2a3a4a5a6a7a8a9aaabacadaeaf'),
+        subgroups,
+    )
+    basic_broadcast_receive_state_check(brs2)
+
+
+# -----------------------------------------------------------------------------
+async def run():
+    test_operations()
+    test_broadcast_receive_state()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run())
diff --git a/tests/core_test.py b/tests/core_test.py
index 11afb1c..7592082 100644
--- a/tests/core_test.py
+++ b/tests/core_test.py
@@ -15,7 +15,9 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
-from bumble.core import AdvertisingData, UUID, get_dict_key_by_value
+from enum import IntEnum
+
+from bumble.core import AdvertisingData, Appearance, UUID, get_dict_key_by_value
 
 
 # -----------------------------------------------------------------------------
@@ -67,7 +69,34 @@
 
 
 # -----------------------------------------------------------------------------
+def test_appearance() -> None:
+    a = Appearance(Appearance.Category.COMPUTER, Appearance.ComputerSubcategory.LAPTOP)
+    assert str(a) == 'COMPUTER/LAPTOP'
+    assert int(a) == 0x0083
+
+    a = Appearance(Appearance.Category.HUMAN_INTERFACE_DEVICE, 0x77)
+    assert str(a) == 'HUMAN_INTERFACE_DEVICE/HumanInterfaceDeviceSubcategory[119]'
+    assert int(a) == 0x03C0 | 0x77
+
+    a = Appearance.from_int(0x0381)
+    assert a.category == Appearance.Category.BLOOD_PRESSURE
+    assert a.subcategory == Appearance.BloodPressureSubcategory.ARM_BLOOD_PRESSURE
+    assert int(a) == 0x381
+
+    a = Appearance.from_int(0x038A)
+    assert a.category == Appearance.Category.BLOOD_PRESSURE
+    assert a.subcategory == 0x0A
+    assert int(a) == 0x038A
+
+    a = Appearance.from_int(0x3333)
+    assert a.category == 0xCC
+    assert a.subcategory == 0x33
+    assert int(a) == 0x3333
+
+
+# -----------------------------------------------------------------------------
 if __name__ == '__main__':
     test_ad_data()
     test_get_dict_key_by_value()
     test_uuid_to_hex_str()
+    test_appearance()
diff --git a/tests/device_test.py b/tests/device_test.py
index ac0c96b..45b84ce 100644
--- a/tests/device_test.py
+++ b/tests/device_test.py
@@ -278,36 +278,6 @@
 
 # -----------------------------------------------------------------------------
 @pytest.mark.parametrize(
-    'own_address_type,',
-    (OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
-)
[email protected]
-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)
-
-
-# -----------------------------------------------------------------------------
[email protected](
     'auto_restart,',
     (True, False),
 )
@@ -320,6 +290,8 @@
         0x0001,
         BT_LE_TRANSPORT,
         peer_address,
+        None,
+        None,
         BT_PERIPHERAL_ROLE,
         ConnectionParameters(0, 0, 0),
     )
@@ -369,6 +341,8 @@
         0x0001,
         BT_LE_TRANSPORT,
         peer_address,
+        None,
+        None,
         BT_PERIPHERAL_ROLE,
         ConnectionParameters(0, 0, 0),
     )
@@ -384,9 +358,43 @@
     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)
+    await async_barrier()
+
+
+# -----------------------------------------------------------------------------
[email protected](
+    'own_address_type,',
+    (OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
+)
[email protected]
+async def test_extended_advertising_connection_out_of_order(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_advertising_set_termination(
+        HCI_SUCCESS,
+        advertising_set.advertising_handle,
+        0x0001,
+        0,
+    )
+    device.on_connection(
+        0x0001,
+        BT_LE_TRANSPORT,
+        peer_address,
+        None,
+        None,
+        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
+
+    await async_barrier()
 
 
 # -----------------------------------------------------------------------------
@@ -529,6 +537,16 @@
 
 
 # -----------------------------------------------------------------------------
[email protected]
+async def test_power_on_default_static_address_should_not_be_any():
+    devices = TwoDevices()
+    devices[0].static_address = devices[0].random_address = Address.ANY_RANDOM
+    await devices[0].power_on()
+
+    assert devices[0].static_address != Address.ANY_RANDOM
+
+
+# -----------------------------------------------------------------------------
 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 e3c9209..1cd533f 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -881,6 +881,57 @@
 
 # -----------------------------------------------------------------------------
 @pytest.mark.asyncio
+async def test_discover_all():
+    [client, server] = LinkedDevices().devices[:2]
+
+    characteristic1 = Characteristic(
+        'FDB159DB-036C-49E3-B3DB-6325AC750806',
+        Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
+        Characteristic.READABLE,
+        bytes([1, 2, 3]),
+    )
+
+    descriptor1 = Descriptor('2902', 'READABLE,WRITEABLE')
+    descriptor2 = Descriptor('AAAA', 'READABLE,WRITEABLE')
+    characteristic2 = Characteristic(
+        '3234C4F4-3F34-4616-8935-45A50EE05DEB',
+        Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
+        Characteristic.READABLE,
+        bytes([1, 2, 3]),
+        descriptors=[descriptor1, descriptor2],
+    )
+
+    service1 = Service(
+        '3A657F47-D34F-46B3-B1EC-698E29B6B829',
+        [characteristic1, characteristic2],
+    )
+    service2 = Service('1111', [])
+    server.add_services([service1, service2])
+
+    await client.power_on()
+    await server.power_on()
+    connection = await client.connect(server.random_address)
+    peer = Peer(connection)
+
+    await peer.discover_all()
+    assert len(peer.gatt_client.services) == 3
+    # service 1800 gets added automatically
+    assert peer.gatt_client.services[0].uuid == UUID('1800')
+    assert peer.gatt_client.services[1].uuid == service1.uuid
+    assert peer.gatt_client.services[2].uuid == service2.uuid
+    s = peer.get_services_by_uuid(service1.uuid)
+    assert len(s) == 1
+    assert len(s[0].characteristics) == 2
+    c = peer.get_characteristics_by_uuid(uuid=characteristic2.uuid, service=s[0])
+    assert len(c) == 1
+    assert len(c[0].descriptors) == 2
+    s = peer.get_services_by_uuid(service2.uuid)
+    assert len(s) == 1
+    assert len(s[0].characteristics) == 0
+
+
+# -----------------------------------------------------------------------------
[email protected]
 async def test_mtu_exchange():
     [d1, d2, d3] = LinkedDevices().devices[:3]
 
@@ -1147,6 +1198,56 @@
 
 
 # -----------------------------------------------------------------------------
[email protected]
+async def test_get_characteristics_by_uuid():
+    [client, server] = LinkedDevices().devices[:2]
+
+    characteristic1 = Characteristic(
+        '1234',
+        Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
+        Characteristic.READABLE,
+        bytes([1, 2, 3]),
+    )
+    characteristic2 = Characteristic(
+        '5678',
+        Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
+        Characteristic.READABLE,
+        bytes([1, 2, 3]),
+    )
+    service1 = Service(
+        'ABCD',
+        [characteristic1, characteristic2],
+    )
+    service2 = Service(
+        'FFFF',
+        [characteristic1],
+    )
+
+    server.add_services([service1, service2])
+
+    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(uuid=UUID('1234'))
+    assert len(c) == 2
+    assert isinstance(c[0], CharacteristicProxy)
+    c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=UUID('ABCD'))
+    assert len(c) == 1
+    assert isinstance(c[0], CharacteristicProxy)
+    c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=UUID('AAAA'))
+    assert len(c) == 0
+
+    s = peer.get_services_by_uuid(uuid=UUID('ABCD'))
+    assert len(s) == 1
+    c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=s[0])
+    assert len(s) == 1
+
+
+# -----------------------------------------------------------------------------
 if __name__ == '__main__':
     logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
     asyncio.run(async_main())
diff --git a/tests/import_test.py b/tests/import_test.py
index e0b6e3c..9542511 100644
--- a/tests/import_test.py
+++ b/tests/import_test.py
@@ -27,7 +27,6 @@
         core,
         crypto,
         device,
-        gap,
         hci,
         hfp,
         host,
@@ -41,6 +40,22 @@
         utils,
     )
 
+    from bumble.profiles import (
+        ascs,
+        bap,
+        bass,
+        battery_service,
+        cap,
+        csip,
+        device_information_service,
+        gap,
+        heart_rate_service,
+        le_audio,
+        pacs,
+        pbp,
+        vcp,
+    )
+
     assert att
     assert bridge
     assert company_ids
@@ -48,7 +63,6 @@
     assert core
     assert crypto
     assert device
-    assert gap
     assert hci
     assert hfp
     assert host
@@ -61,6 +75,20 @@
     assert transport
     assert utils
 
+    assert ascs
+    assert bap
+    assert bass
+    assert battery_service
+    assert cap
+    assert csip
+    assert device_information_service
+    assert gap
+    assert heart_rate_service
+    assert le_audio
+    assert pacs
+    assert pbp
+    assert vcp
+
 
 # -----------------------------------------------------------------------------
 def test_app_imports():
diff --git a/tests/le_audio_test.py b/tests/le_audio_test.py
new file mode 100644
index 0000000..264a96d
--- /dev/null
+++ b/tests/le_audio_test.py
@@ -0,0 +1,39 @@
+# 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 bumble.profiles import le_audio
+
+
+def test_parse_metadata():
+    metadata = le_audio.Metadata(
+        entries=[
+            le_audio.Metadata.Entry(
+                tag=le_audio.Metadata.Tag.PROGRAM_INFO,
+                data=b'',
+            ),
+            le_audio.Metadata.Entry(
+                tag=le_audio.Metadata.Tag.STREAMING_AUDIO_CONTEXTS,
+                data=bytes([0, 0]),
+            ),
+            le_audio.Metadata.Entry(
+                tag=le_audio.Metadata.Tag.PREFERRED_AUDIO_CONTEXTS,
+                data=bytes([1, 2]),
+            ),
+        ]
+    )
+
+    assert le_audio.Metadata.from_bytes(bytes(metadata)) == metadata
diff --git a/tests/mcp_test.py b/tests/mcp_test.py
new file mode 100644
index 0000000..c063536
--- /dev/null
+++ b/tests/mcp_test.py
@@ -0,0 +1,132 @@
+# 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 dataclasses
+import pytest
+import pytest_asyncio
+import struct
+import logging
+
+from bumble import device
+from bumble.profiles import mcp
+from tests.test_utils import TwoDevices
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Helpers
+# -----------------------------------------------------------------------------
+TIMEOUT = 0.1
+
+
[email protected]
+class GmcsContext:
+    devices: TwoDevices
+    client: mcp.GenericMediaControlServiceProxy
+    server: mcp.GenericMediaControlService
+
+
+# -----------------------------------------------------------------------------
+@pytest_asyncio.fixture
+async def gmcs_context():
+    devices = TwoDevices()
+    server = mcp.GenericMediaControlService()
+    devices[0].add_service(server)
+
+    await devices.setup_connection()
+    devices.connections[0].encryption = 1
+    devices.connections[1].encryption = 1
+    peer = device.Peer(devices.connections[1])
+    client = await peer.discover_service_and_create_proxy(
+        mcp.GenericMediaControlServiceProxy
+    )
+    await client.subscribe_characteristics()
+
+    return GmcsContext(devices=devices, server=server, client=client)
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_update_media_state(gmcs_context):
+    state = asyncio.Queue()
+    gmcs_context.client.on('media_state', state.put_nowait)
+
+    await gmcs_context.devices[0].notify_subscribers(
+        gmcs_context.server.media_state_characteristic,
+        value=bytes([mcp.MediaState.PLAYING]),
+    )
+
+    assert (await asyncio.wait_for(state.get(), TIMEOUT)) == mcp.MediaState.PLAYING
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_update_track_title(gmcs_context):
+    state = asyncio.Queue()
+    gmcs_context.client.on('track_title', state.put_nowait)
+
+    await gmcs_context.devices[0].notify_subscribers(
+        gmcs_context.server.track_title_characteristic,
+        value="My Song".encode(),
+    )
+
+    assert (await asyncio.wait_for(state.get(), TIMEOUT)) == "My Song"
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_update_track_duration(gmcs_context):
+    state = asyncio.Queue()
+    gmcs_context.client.on('track_duration', state.put_nowait)
+
+    await gmcs_context.devices[0].notify_subscribers(
+        gmcs_context.server.track_duration_characteristic,
+        value=struct.pack("<i", 1000),
+    )
+
+    assert (await asyncio.wait_for(state.get(), TIMEOUT)) == 1000
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_update_track_position(gmcs_context):
+    state = asyncio.Queue()
+    gmcs_context.client.on('track_position', state.put_nowait)
+
+    await gmcs_context.devices[0].notify_subscribers(
+        gmcs_context.server.track_position_characteristic,
+        value=struct.pack("<i", 1000),
+    )
+
+    assert (await asyncio.wait_for(state.get(), TIMEOUT)) == 1000
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_write_media_control_point(gmcs_context):
+    assert (
+        await asyncio.wait_for(
+            gmcs_context.client.write_control_point(mcp.MediaControlPointOpcode.PAUSE),
+            TIMEOUT,
+        )
+    ) == mcp.MediaControlPointResultCode.SUCCESS
diff --git a/tests/smp_test.py b/tests/smp_test.py
index 7a32b23..7f17bc2 100644
--- a/tests/smp_test.py
+++ b/tests/smp_test.py
@@ -17,13 +17,17 @@
 # -----------------------------------------------------------------------------
 
 import pytest
+from unittest import mock
 
 from bumble import smp
+from bumble import pairing
 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
+from bumble.device import Device
 
+from typing import Optional
 
 # -----------------------------------------------------------------------------
 # pylint: disable=invalid-name
@@ -252,6 +256,57 @@
 
 
 # -----------------------------------------------------------------------------
[email protected](
+    'identity_address_type, public_address, random_address, expected_identity_address',
+    [
+        (
+            None,
+            Address("00:11:22:33:44:55", Address.PUBLIC_DEVICE_ADDRESS),
+            Address("EE:EE:EE:EE:EE:EE", Address.RANDOM_DEVICE_ADDRESS),
+            Address("00:11:22:33:44:55", Address.PUBLIC_DEVICE_ADDRESS),
+        ),
+        (
+            None,
+            Address.ANY,
+            Address("EE:EE:EE:EE:EE:EE", Address.RANDOM_DEVICE_ADDRESS),
+            Address("EE:EE:EE:EE:EE:EE", Address.RANDOM_DEVICE_ADDRESS),
+        ),
+        (
+            pairing.PairingConfig.AddressType.PUBLIC,
+            Address("00:11:22:33:44:55", Address.PUBLIC_DEVICE_ADDRESS),
+            Address("EE:EE:EE:EE:EE:EE", Address.RANDOM_DEVICE_ADDRESS),
+            Address("00:11:22:33:44:55", Address.PUBLIC_DEVICE_ADDRESS),
+        ),
+        (
+            pairing.PairingConfig.AddressType.RANDOM,
+            Address("00:11:22:33:44:55", Address.PUBLIC_DEVICE_ADDRESS),
+            Address("EE:EE:EE:EE:EE:EE", Address.RANDOM_DEVICE_ADDRESS),
+            Address("EE:EE:EE:EE:EE:EE", Address.RANDOM_DEVICE_ADDRESS),
+        ),
+    ],
+)
[email protected]
+async def test_send_identity_address_command(
+    identity_address_type: Optional[pairing.PairingConfig.AddressType],
+    public_address: Address,
+    random_address: Address,
+    expected_identity_address: Address,
+):
+    device = Device()
+    device.public_address = public_address
+    device.static_address = random_address
+    pairing_config = pairing.PairingConfig(identity_address_type=identity_address_type)
+    session = smp.Session(device.smp_manager, mock.MagicMock(), pairing_config, True)
+
+    with mock.patch.object(session, 'send_command') as mock_method:
+        session.send_identity_address_command()
+
+    actual_command = mock_method.call_args.args[0]
+    assert actual_command.addr_type == expected_identity_address.address_type
+    assert actual_command.bd_addr == expected_identity_address
+
+
+# -----------------------------------------------------------------------------
 if __name__ == '__main__':
     test_ecc()
     test_c1()
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..1d9b8aa
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,3 @@
+# files created by invoke web.build
+*.whl
+packageFile
diff --git a/web/README.md b/web/README.md
index a8cc89c..532dfd1 100644
--- a/web/README.md
+++ b/web/README.md
@@ -24,9 +24,14 @@
 For HTTP, start an HTTP server with the `web` directory as its
 root. You can use the invoke task `inv web.serve` for convenience.
 
+`inv web.build` will build the local copy of bumble and automatically copy the `.whl` file
+to the web directory. To use this build, include the param `?packageFile=true` to the URL.
+
 In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`.
 You can pass optional query parameters:
 
+  * `packageFile=true` will automatically use the bumble package built via the
+    `inv web.build` command.
   * `package` may be set to point to a local build of Bumble (`.whl` files).
      The filename must be URL-encoded of course, and must be located under
      the `web` directory (the HTTP server won't serve files not under its
@@ -45,4 +50,6 @@
 
 
 NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory. 
-Make a copy of the built `.whl` file in the `web` directory.
\ No newline at end of file
+Make a copy of the built `.whl` file in the `web` directory.
+
+Tip: During web developement, disable caching. [Chrome](https://stackoverflow.com/a/7000899]) / [Firefiox](https://stackoverflow.com/a/289771)
\ No newline at end of file
diff --git a/web/bumble.js b/web/bumble.js
index c554bc2..33b62f6 100644
--- a/web/bumble.js
+++ b/web/bumble.js
@@ -75,7 +75,6 @@
         }
 
         // Load the Bumble module
-        bumblePackage ||= 'bumble';
         console.log('Installing micropip');
         this.log(`Installing ${bumblePackage}`)
         await this.pyodide.loadPackage('micropip');
@@ -166,6 +165,20 @@
     }
 }
 
+async function getBumblePackage() {
+    const params = (new URL(document.location)).searchParams;
+    // First check the packageFile override param
+    if (params.has('packageFile')) {
+        return await (await fetch('/packageFile')).text() 
+    }
+    // Then check the package override param
+    if (params.has('package')) {
+        return params.get('package')
+    }
+    // If no override params, default to the main package
+    return 'bumble'
+}
+
 export async function setupSimpleApp(appUrl, bumbleControls, log) {
     // Load Bumble
     log('Loading Bumble');
@@ -173,8 +186,7 @@
     bumble.addEventListener('log', (event) => {
         log(event.message);
     })
-    const params = (new URL(document.location)).searchParams;
-    await bumble.loadRuntime(params.get('package'));
+    await bumble.loadRuntime(await getBumblePackage());
 
     log('Bumble is ready!')
     const app = await bumble.loadApp(appUrl);
diff --git a/web/favicon.ico b/web/favicon.ico
new file mode 120000
index 0000000..505de13
--- /dev/null
+++ b/web/favicon.ico
@@ -0,0 +1 @@
+../docs/images/favicon.ico
\ No newline at end of file
diff --git a/web/scanner/scanner.py b/web/scanner/scanner.py
index 9ff6aba..69ee43a 100644
--- a/web/scanner/scanner.py
+++ b/web/scanner/scanner.py
@@ -15,12 +15,21 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+import pyee
+
 from bumble.device import Device
 from bumble.hci import HCI_Reset_Command
 
 
 # -----------------------------------------------------------------------------
-class Scanner:
+class Scanner(pyee.EventEmitter):
+    """
+    Scanner web app
+
+    Emitted events:
+        update: Emit when new `ScanEntry` are available.
+    """
+
     class ScanEntry:
         def __init__(self, advertisement):
             self.address = advertisement.address.to_string(False)
@@ -39,13 +48,12 @@
             'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
         )
         self.scan_entries = {}
-        self.listeners = {}
         self.device.on('advertisement', self.on_advertisement)
 
     async def start(self):
         print('### Starting Scanner')
         self.scan_entries = {}
-        self.emit_update()
+        self.emit('update', self.scan_entries)
         await self.device.power_on()
         await self.device.start_scanning()
         print('### Scanner started')
@@ -56,16 +64,9 @@
         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.scan_entries[advertisement.address] = self.ScanEntry(advertisement)
-        self.emit_update()
+        self.emit('update', self.scan_entries)
 
 
 # -----------------------------------------------------------------------------