Merge remote-tracking branch 'aosp/upstream-main' into master am: b74fa15783

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

Change-Id: If42b3697e4df9212e02a8c578ac68bfd1cdc1019
Signed-off-by: Automerger Merge Worker <[email protected]>
diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml
index ca19386..5d955c3 100644
--- a/.github/workflows/python-build-test.yml
+++ b/.github/workflows/python-build-test.yml
@@ -21,7 +21,7 @@
     - name: Get history and tags for SCM versioning to work
       run: |
         git fetch --prune --unshallow
-        git fetch --depth=1 origin +refs/tags/*:refs/tags/*      
+        git fetch --depth=1 origin +refs/tags/*:refs/tags/*
     - name: Set up Python 3.10
       uses: actions/setup-python@v3
       with:
@@ -30,9 +30,9 @@
       run: |
         python -m pip install --upgrade pip
         python -m pip install ".[build,test,development,documentation]"
-    - name: Test with pytest
+    - name: Test
       run: |
-        pytest
+        invoke test
     - name: Build
       run: |
         inv build
diff --git a/apps/console.py b/apps/console.py
index 7ddbae4..e0f93b0 100644
--- a/apps/console.py
+++ b/apps/console.py
@@ -20,19 +20,26 @@
 # Imports
 # -----------------------------------------------------------------------------
 import asyncio
-from bumble.hci import HCI_Constant
-import os
-import os.path
 import logging
-import click
+import os
+import random
+import re
 from collections import OrderedDict
+
+import click
 import colors
 
-from bumble.core import UUID, AdvertisingData
-from bumble.device import Device, Connection, Peer
+from bumble.core import UUID, AdvertisingData, TimeoutError, BT_LE_TRANSPORT
+from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer
 from bumble.utils import AsyncRunner
 from bumble.transport import open_transport_or_link
 from bumble.gatt import Characteristic
+from bumble.hci import (
+    HCI_Constant,
+    HCI_LE_1M_PHY,
+    HCI_LE_2M_PHY,
+    HCI_LE_CODED_PHY,
+)
 
 from prompt_toolkit import Application
 from prompt_toolkit.history import FileHistory
@@ -43,6 +50,7 @@
 from prompt_toolkit.filters import Condition
 from prompt_toolkit.widgets import TextArea, Frame
 from prompt_toolkit.widgets.toolbars import FormattedTextToolbar
+from prompt_toolkit.data_structures import Point
 from prompt_toolkit.layout import (
     Layout,
     HSplit,
@@ -51,17 +59,20 @@
     Float,
     FormattedTextControl,
     FloatContainer,
-    ConditionalContainer
+    ConditionalContainer,
+    Dimension
 )
 
 # -----------------------------------------------------------------------------
 # Constants
 # -----------------------------------------------------------------------------
-BUMBLE_USER_DIR        = os.path.expanduser('~/.bumble')
-DEFAULT_PROMPT_HEIGHT  = 20
-DEFAULT_RSSI_BAR_WIDTH = 20
-DISPLAY_MIN_RSSI       = -100
-DISPLAY_MAX_RSSI       = -30
+BUMBLE_USER_DIR            = os.path.expanduser('~/.bumble')
+DEFAULT_RSSI_BAR_WIDTH     = 20
+DEFAULT_CONNECTION_TIMEOUT = 30.0
+DISPLAY_MIN_RSSI           = -100
+DISPLAY_MAX_RSSI           = -30
+RSSI_MONITOR_INTERVAL      = 5.0  # Seconds
+
 
 # -----------------------------------------------------------------------------
 # Globals
@@ -70,15 +81,56 @@
 
 
 # -----------------------------------------------------------------------------
+# Utils
+# -----------------------------------------------------------------------------
+
+def le_phy_name(phy_id):
+    return {
+        HCI_LE_1M_PHY:    '1M',
+        HCI_LE_2M_PHY:    '2M',
+        HCI_LE_CODED_PHY: 'CODED'
+    }.get(phy_id, HCI_Constant.le_phy_name(phy_id))
+
+
+def rssi_bar(rssi):
+    blocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
+    bar_width = (rssi - DISPLAY_MIN_RSSI) / (DISPLAY_MAX_RSSI - DISPLAY_MIN_RSSI)
+    bar_width = min(max(bar_width, 0), 1)
+    bar_ticks = int(bar_width * DEFAULT_RSSI_BAR_WIDTH * 8)
+    bar_blocks = ('█' * int(bar_ticks / 8)) + blocks[bar_ticks % 8]
+    return f'{rssi:4} {bar_blocks}'
+
+
+def parse_phys(phys):
+    if phys.lower() == '*':
+        return None
+    else:
+        phy_list = []
+        elements = phys.lower().split(',')
+        for element in elements:
+            if element == '1m':
+                phy_list.append(HCI_LE_1M_PHY)
+            elif element == '2m':
+                phy_list.append(HCI_LE_2M_PHY)
+            elif element == 'coded':
+                phy_list.append(HCI_LE_CODED_PHY)
+            else:
+                raise ValueError('invalid PHY name')
+        return phy_list
+
+
+# -----------------------------------------------------------------------------
 # Console App
 # -----------------------------------------------------------------------------
 class ConsoleApp:
     def __init__(self):
-        self.known_addresses = set()
+        self.known_addresses  = set()
         self.known_attributes = []
-        self.device = None
-        self.connected_peer = None
-        self.top_tab = 'scan'
+        self.device           = None
+        self.connected_peer   = None
+        self.top_tab          = 'device'
+        self.monitor_rssi     = False
+        self.connection_rssi  = None
 
         style = Style.from_dict({
             'output-field': 'bg:#000044 #ffffff',
@@ -100,17 +152,26 @@
             return NestedCompleter.from_nested_dict({
                 'scan': {
                     'on': None,
-                    'off': None
+                    'off': None,
+                    'clear': None
                 },
                 'advertise': {
                     'on': None,
                     'off': None
                 },
+                'rssi': {
+                    'on': None,
+                    'off': None
+                },
                 'show': {
                     'scan': None,
                     'services': None,
                     'attributes': None,
-                    'log': None
+                    'log': None,
+                    'device': None
+                },
+                'filter': {
+                    'address': None,
                 },
                 'connect': LiveCompleter(self.known_addresses),
                 'update-parameters': None,
@@ -120,10 +181,17 @@
                     'services': None,
                     'attributes': None
                 },
+                'request-mtu': None,
                 'read': LiveCompleter(self.known_attributes),
                 'write': LiveCompleter(self.known_attributes),
                 'subscribe': LiveCompleter(self.known_attributes),
                 'unsubscribe': LiveCompleter(self.known_attributes),
+                'set-phy': {
+                    '1m': None,
+                    '2m': None,
+                    'coded': None
+                },
+                'set-default-phy': None,
                 'quit': None,
                 'exit': None
             })
@@ -139,14 +207,17 @@
 
         self.input_field.accept_handler = self.accept_input
 
-        self.output_height = 7
+        self.output_height = Dimension(min=7, max=7, weight=1)
         self.output_lines = []
-        self.output = FormattedTextControl()
+        self.output = FormattedTextControl(get_cursor_position=lambda: Point(0, max(0, len(self.output_lines) - 1)))
+        self.output_max_lines = 20
         self.scan_results_text = FormattedTextControl()
         self.services_text = FormattedTextControl()
         self.attributes_text = FormattedTextControl()
-        self.log_text = FormattedTextControl()
-        self.log_height = 20
+        self.device_text = FormattedTextControl()
+        self.log_text = FormattedTextControl(get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1)))
+        self.log_height = Dimension(min=7, weight=4)
+        self.log_max_lines = 100
         self.log_lines = []
 
         container = HSplit([
@@ -163,11 +234,14 @@
                 filter=Condition(lambda: self.top_tab == 'attributes')
             ),
             ConditionalContainer(
-                Frame(Window(self.log_text), title='Log'),
+                Frame(Window(self.log_text, height=self.log_height), title='Log'),
                 filter=Condition(lambda: self.top_tab == 'log')
             ),
-            Frame(Window(self.output), height=self.output_height),
-            # HorizontalLine(),
+            ConditionalContainer(
+                Frame(Window(self.device_text), title='Device'),
+                filter=Condition(lambda: self.top_tab == 'device')
+            ),
+            Frame(Window(self.output, height=self.output_height)),
             FormattedTextToolbar(text=self.get_status_bar_text, style='reverse'),
             self.input_field
         ])
@@ -199,17 +273,26 @@
         )
 
     async def run_async(self, device_config, transport):
+        rssi_monitoring_task = asyncio.create_task(self.rssi_monitor_loop())
+
         async with await open_transport_or_link(transport) as (hci_source, hci_sink):
             if device_config:
                 self.device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
             else:
-                self.device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
+                random_address = f"{random.randint(192,255):02X}"  # address is static random
+                for c in random.sample(range(255), 5):
+                    random_address += f":{c:02X}"
+                self.append_to_log(f"Setting random address: {random_address}")
+                self.device = Device.with_hci('Bumble', random_address, hci_source, hci_sink)
             self.device.listener = DeviceListener(self)
             await self.device.power_on()
+            self.show_device(self.device)
 
             # Run the UI
             await self.ui.run_async()
 
+        rssi_monitoring_task.cancel()
+
     def add_known_address(self, address):
         self.known_addresses.add(address)
 
@@ -224,22 +307,33 @@
 
         connection_state = 'NONE'
         encryption_state = ''
+        att_mtu = ''
+        rssi = '' if self.connection_rssi is None else rssi_bar(self.connection_rssi)
 
         if self.device:
-            if self.device.is_connecting:
+            if self.device.is_le_connecting:
                 connection_state = 'CONNECTING'
             elif self.connected_peer:
                 connection = self.connected_peer.connection
-                connection_parameters = f'{connection.parameters.connection_interval}/{connection.parameters.connection_latency}/{connection.parameters.supervision_timeout}'
-                connection_state = f'{connection.peer_address} {connection_parameters} {connection.data_length}'
+                connection_parameters = f'{connection.parameters.connection_interval}/{connection.parameters.peripheral_latency}/{connection.parameters.supervision_timeout}'
+                if connection.transport == BT_LE_TRANSPORT:
+                    phy_state = f' RX={le_phy_name(connection.phy.rx_phy)}/TX={le_phy_name(connection.phy.tx_phy)}'
+                else:
+                    phy_state = ''
+                connection_state = f'{connection.peer_address} {connection_parameters} {connection.data_length}{phy_state}'
                 encryption_state = 'ENCRYPTED' if connection.is_encrypted else 'NOT ENCRYPTED'
+                att_mtu = f'ATT_MTU: {connection.att_mtu}'
 
         return [
             ('ansigreen', f' SCAN: {scanning} '),
             ('', '  '),
             ('ansiblue', f' CONNECTION: {connection_state} '),
             ('', '  '),
-            ('ansimagenta', f' {encryption_state} ')
+            ('ansimagenta', f' {encryption_state} '),
+            ('', '  '),
+            ('ansicyan', f' {att_mtu} '),
+            ('', '  '),
+            ('ansiyellow', f' {rssi} ')
         ]
 
     def show_error(self, title, details = None):
@@ -274,7 +368,7 @@
         self.services_text.text = lines
         self.ui.invalidate()
 
-    async def show_attributes(self, attributes):
+    def show_attributes(self, attributes):
         lines = []
 
         for attribute in attributes:
@@ -283,10 +377,48 @@
         self.attributes_text.text = lines
         self.ui.invalidate()
 
+    def show_device(self, device):
+        lines = []
+
+        lines.append(('ansicyan', 'Name:                 '))
+        lines.append(('', f'{device.name}\n'))
+        lines.append(('ansicyan', 'Public Address:       '))
+        lines.append(('', f'{device.public_address}\n'))
+        lines.append(('ansicyan', 'Random Address:       '))
+        lines.append(('', f'{device.random_address}\n'))
+        lines.append(('ansicyan', 'LE Enabled:           '))
+        lines.append(('', f'{device.le_enabled}\n'))
+        lines.append(('ansicyan', 'Classic Enabled:      '))
+        lines.append(('', f'{device.classic_enabled}\n'))
+        lines.append(('ansicyan', 'Classic SC Enabled:   '))
+        lines.append(('', f'{device.classic_sc_enabled}\n'))
+        lines.append(('ansicyan', 'Classic SSP Enabled:  '))
+        lines.append(('', f'{device.classic_ssp_enabled}\n'))
+        lines.append(('ansicyan', 'Classic Class:        '))
+        lines.append(('', f'{device.class_of_device}\n'))
+        lines.append(('ansicyan', 'Discoverable:         '))
+        lines.append(('', f'{device.discoverable}\n'))
+        lines.append(('ansicyan', 'Connectable:          '))
+        lines.append(('', f'{device.connectable}\n'))
+        lines.append(('ansicyan', 'Advertising Data:     '))
+        lines.append(('', f'{device.advertising_data}\n'))
+        lines.append(('ansicyan', 'Scan Response Data:   '))
+        lines.append(('', f'{device.scan_response_data}\n'))
+        advertising_interval = (
+            device.advertising_interval_min
+            if device.advertising_interval_min == device.advertising_interval_max
+            else f"{device.advertising_interval_min} to {device.advertising_interval_max}"
+        )
+        lines.append(('ansicyan', 'Advertising Interval: '))
+        lines.append(('', f'{advertising_interval}\n'))
+
+        self.device_text.text = lines
+        self.ui.invalidate()
+
     def append_to_output(self, line, invalidate=True):
         if type(line) is str:
             line = [('', line)]
-        self.output_lines = self.output_lines[-(self.output_height - 3):]
+        self.output_lines = self.output_lines[-self.output_max_lines:]
         self.output_lines.append(line)
         formatted_text = []
         for line in self.output_lines:
@@ -298,7 +430,7 @@
 
     def append_to_log(self, lines, invalidate=True):
         self.log_lines.extend(lines.split('\n'))
-        self.log_lines = self.log_lines[-(self.log_height - 3):]
+        self.log_lines = self.log_lines[-self.log_max_lines:]
         self.log_text.text = ANSI('\n'.join(self.log_lines))
         if invalidate:
             self.ui.invalidate()
@@ -311,7 +443,10 @@
         # 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 charateristics...')
+        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:
@@ -331,7 +466,7 @@
         attributes = await self.connected_peer.discover_attributes()
         self.append_to_output(f'discovered {len(attributes)} attributes...')
 
-        await self.show_attributes(attributes)
+        self.show_attributes(attributes)
 
     def find_characteristic(self, param):
         parts = param.split('.')
@@ -351,6 +486,12 @@
                         if characteristic.handle == attribute_handle:
                             return characteristic
 
+    async def rssi_monitor_loop(self):
+        while True:
+            if self.monitor_rssi and self.connected_peer:
+                self.connection_rssi = await self.connected_peer.connection.get_rssi()
+            await asyncio.sleep(RSSI_MONITOR_INTERVAL)
+
     async def command(self, command):
         try:
             (keyword, *params) = command.strip().split(' ')
@@ -372,46 +513,96 @@
             else:
                 await self.device.start_scanning()
         elif params[0] == 'on':
+            if len(params) == 2:
+                if not params[1].startswith("filter="):
+                    self.show_error('invalid syntax', 'expected address filter=key1:value1,key2:value,... available filters: address')
+                # regex: (word):(any char except ,)
+                matches = re.findall(r"(\w+):([^,]+)", params[1])
+                for match in matches:
+                    if match[0] == "address":
+                        self.device.listener.address_filter = match[1]
+
             await self.device.start_scanning()
             self.top_tab = 'scan'
         elif params[0] == 'off':
             await self.device.stop_scanning()
+        elif params[0] == 'clear':
+            self.device.listener.scan_results.clear()
+            self.known_addresses.clear()
+            self.show_scan_results(self.device.listener.scan_results)
         else:
             self.show_error('unsupported arguments for scan command')
 
+    async def do_rssi(self, params):
+        if len(params) == 0:
+            # Toggle monitoring
+            self.monitor_rssi = not self.monitor_rssi
+        elif params[0] == 'on':
+            self.monitor_rssi = True
+        elif params[0] == 'off':
+            self.monitor_rssi = False
+        else:
+            self.show_error('unsupported arguments for rssi command')
+
     async def do_connect(self, params):
-        if len(params) != 1:
-            self.show_error('invalid syntax', 'expected connect <address>')
+        if len(params) != 1 and len(params) != 2:
+            self.show_error('invalid syntax', 'expected connect <address> [phys]')
             return
 
+        if len(params) == 1:
+            phys = None
+        else:
+            phys = parse_phys(params[1])
+        if phys is None:
+            connection_parameters_preferences = None
+        else:
+            connection_parameters_preferences = {
+                phy: ConnectionParametersPreferences()
+                for phy in phys
+            }
+
+        if self.device.is_scanning:
+            await self.device.stop_scanning()
+
         self.append_to_output('connecting...')
-        await self.device.connect(params[0])
-        self.top_tab = 'services'
+
+        try:
+            await self.device.connect(
+                params[0],
+                connection_parameters_preferences=connection_parameters_preferences,
+                timeout=DEFAULT_CONNECTION_TIMEOUT
+            )
+            self.top_tab = 'services'
+        except TimeoutError:
+            self.show_error('connection timed out')
 
     async def do_disconnect(self, params):
-        if not self.connected_peer:
-            self.show_error('not connected')
-            return
+        if self.device.is_le_connecting:
+            await self.device.cancel_connection()
+        else:
+            if not self.connected_peer:
+                self.show_error('not connected')
+                return
 
-        await self.connected_peer.connection.disconnect()
+            await self.connected_peer.connection.disconnect()
 
     async def do_update_parameters(self, params):
         if len(params) != 1 or len(params[0].split('/')) != 3:
-            self.show_error('invalid syntax', 'expected update-parameters <interval-min>-<interval-max>/<latency>/<supervision>')
+            self.show_error('invalid syntax', 'expected update-parameters <interval-min>-<interval-max>/<max-latency>/<supervision>')
             return
 
         if not self.connected_peer:
             self.show_error('not connected')
             return
 
-        connection_intervals, connection_latency, supervision_timeout = params[0].split('/')
+        connection_intervals, max_latency, supervision_timeout = params[0].split('/')
         connection_interval_min, connection_interval_max = [int(x) for x in connection_intervals.split('-')]
-        connection_latency = int(connection_latency)
+        max_latency = int(max_latency)
         supervision_timeout = int(supervision_timeout)
         await self.connected_peer.connection.update_parameters(
             connection_interval_min,
             connection_interval_max,
-            connection_latency,
+            max_latency,
             supervision_timeout
         )
 
@@ -438,10 +629,29 @@
 
     async def do_show(self, params):
         if params:
-            if params[0] in {'scan', 'services', 'attributes', 'log'}:
+            if params[0] in {'scan', 'services', 'attributes', 'log', 'device'}:
                 self.top_tab = params[0]
                 self.ui.invalidate()
 
+    async def do_get_phy(self, params):
+        if not self.connected_peer:
+            self.show_error('not connected')
+            return
+
+        phy = await self.connected_peer.connection.get_phy()
+        self.append_to_output(f'PHY: RX={HCI_Constant.le_phy_name(phy[0])}, TX={HCI_Constant.le_phy_name(phy[1])}')
+
+    async def do_request_mtu(self, params):
+        if len(params) != 1:
+            self.show_error('invalid syntax', 'expected request-mtu <mtu>')
+            return
+
+        if not self.connected_peer:
+            self.show_error('not connected')
+            return
+
+        await self.connected_peer.request_mtu(int(params[0]))
+
     async def do_discover(self, params):
         if not params:
             self.show_error('invalid syntax', 'expected discover services|attributes')
@@ -454,14 +664,14 @@
             await self.discover_attributes()
 
     async def do_read(self, params):
-        if not self.connected_peer:
-            self.show_error('not connected')
-            return
-
         if len(params) != 1:
             self.show_error('invalid syntax', 'expected read <attribute>')
             return
 
+        if not self.connected_peer:
+            self.show_error('not connected')
+            return
+
         characteristic = self.find_characteristic(params[0])
         if characteristic is None:
             self.show_error('no such characteristic')
@@ -530,12 +740,54 @@
 
         await characteristic.unsubscribe()
 
+    async def do_set_phy(self, params):
+        if len(params) != 1:
+            self.show_error('invalid syntax', 'expected set-phy <tx_rx_phys>|<tx_phys>/<rx_phys>')
+            return
+
+        if not self.connected_peer:
+            self.show_error('not connected')
+            return
+
+        if '/' in params[0]:
+            tx_phys, rx_phys = params[0].split('/')
+        else:
+            tx_phys = params[0]
+            rx_phys = tx_phys
+
+        await self.connected_peer.connection.set_phy(
+            tx_phys=parse_phys(tx_phys),
+            rx_phys=parse_phys(rx_phys)
+        )
+
+    async def do_set_default_phy(self, params):
+        if len(params) != 1:
+            self.show_error('invalid syntax', 'expected set-default-phy <tx_rx_phys>|<tx_phys>/<rx_phys>')
+            return
+
+        if '/' in params[0]:
+            tx_phys, rx_phys = params[0].split('/')
+        else:
+            tx_phys = params[0]
+            rx_phys = tx_phys
+
+        await self.device.set_default_phy(
+            tx_phys=parse_phys(tx_phys),
+            rx_phys=parse_phys(rx_phys)
+        )
+
     async def do_exit(self, params):
         self.ui.exit()
 
     async def do_quit(self, params):
         self.ui.exit()
 
+    async def do_filter(self, params):
+        if params[0] == "address":
+            if len(params) != 2:
+                self.show_error('invalid syntax', 'expected filter address <pattern>')
+                return
+            self.device.listener.address_filter = params[1]
 
 # -----------------------------------------------------------------------------
 # Device and Connection Listener
@@ -544,16 +796,38 @@
     def __init__(self, app):
         self.app = app
         self.scan_results = OrderedDict()
+        self.address_filter = None
+
+    @property
+    def address_filter(self):
+        return self._address_filter
+
+    @address_filter.setter
+    def address_filter(self, filter_addr):
+        if filter_addr is None:
+            self._address_filter = re.compile(r".*")
+        else:
+            self._address_filter = re.compile(filter_addr)
+        self.scan_results = OrderedDict(filter(lambda x: self.filter_address_match(x), self.scan_results))
+        self.app.show_scan_results(self.scan_results)
+
+    def filter_address_match(self, address):
+        """
+        Returns true if an address matches the filter
+        """
+        return bool(self.address_filter.match(address))
 
     @AsyncRunner.run_in_task()
     async def on_connection(self, connection):
         self.app.connected_peer = Peer(connection)
+        self.app.connection_rssi = None
         self.app.append_to_output(f'connected to {self.app.connected_peer}')
         connection.listener = self
 
     def on_disconnection(self, reason):
         self.app.append_to_output(f'disconnected from {self.app.connected_peer}, reason: {HCI_Constant.error_name(reason)}')
         self.app.connected_peer = None
+        self.app.connection_rssi = None
 
     def on_connection_parameters_update(self):
         self.app.append_to_output(f'connection parameters update: {self.app.connected_peer.connection.parameters}')
@@ -570,16 +844,19 @@
     def on_connection_data_length_change(self):
         self.app.append_to_output(f'connection data length change: {self.app.connected_peer.connection.data_length}')
 
-    def on_advertisement(self, address, ad_data, rssi, connectable):
-        entry_key = f'{address}/{address.address_type}'
+    def on_advertisement(self, advertisement):
+        if not self.filter_address_match(str(advertisement.address)):
+            return
+
+        entry_key = f'{advertisement.address}/{advertisement.address.address_type}'
         entry = self.scan_results.get(entry_key)
         if entry:
-            entry.ad_data     = ad_data
-            entry.rssi        = rssi
-            entry.connectable = connectable
+            entry.ad_data     = advertisement.data
+            entry.rssi        = advertisement.rssi
+            entry.connectable = advertisement.is_connectable
         else:
-            self.app.add_known_address(str(address))
-            self.scan_results[entry_key] = ScanResult(address, address.address_type, ad_data, rssi, connectable)
+            self.app.add_known_address(str(advertisement.address))
+            self.scan_results[entry_key] = ScanResult(advertisement.address, advertisement.address.address_type, advertisement.data, advertisement.rssi, advertisement.is_connectable)
 
         self.app.show_scan_results(self.scan_results)
 
@@ -603,9 +880,9 @@
         else:
             type_color = colors.cyan
 
-        name = self.ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME)
+        name = self.ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True)
         if name is None:
-            name = self.ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME)
+            name = self.ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME, raw=True)
         if name:
             # Convert to string
             try:
@@ -616,12 +893,7 @@
             name = ''
 
         # RSSI bar
-        blocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
-        bar_width = (self.rssi - DISPLAY_MIN_RSSI) / (DISPLAY_MAX_RSSI - DISPLAY_MIN_RSSI)
-        bar_width = min(max(bar_width, 0), 1)
-        bar_ticks = int(bar_width * DEFAULT_RSSI_BAR_WIDTH * 8)
-        bar_blocks = ('█' * int(bar_ticks / 8)) + blocks[bar_ticks % 8]
-        bar_string = f'{self.rssi} {bar_blocks}'
+        bar_string = rssi_bar(self.rssi)
         bar_padding = ' ' * (DEFAULT_RSSI_BAR_WIDTH + 5 - len(bar_string))
         return f'{address_color(str(self.address))} [{type_color(address_type_string)}] {bar_string} {bar_padding} {name}'
 
@@ -633,6 +905,7 @@
     def __init__(self, app):
         super().__init__()
         self.app = app
+        self.setFormatter(logging.Formatter('[%(asctime)s][%(levelname)s] %(message)s'))
 
     def emit(self, record):
         message = self.format(record)
@@ -657,6 +930,7 @@
     # logging.basicConfig(level = 'FATAL')
     # logging.basicConfig(level = 'DEBUG')
     root_logger = logging.getLogger()
+
     root_logger.addHandler(LogHandler(app))
     root_logger.setLevel(logging.DEBUG)
 
diff --git a/apps/controller_info.py b/apps/controller_info.py
index 74d4550..b65caab 100644
--- a/apps/controller_info.py
+++ b/apps/controller_info.py
@@ -25,15 +25,21 @@
 from bumble.core import name_or_number
 from bumble.hci import (
     map_null_terminated_utf8_string,
-    HCI_LE_SUPPORTED_FEATURES_NAMES,
     HCI_SUCCESS,
+    HCI_LE_SUPPORTED_FEATURES_NAMES,
     HCI_VERSION_NAMES,
     LMP_VERSION_NAMES,
     HCI_Command,
-    HCI_Read_BD_ADDR_Command,
     HCI_READ_BD_ADDR_COMMAND,
+    HCI_Read_BD_ADDR_Command,
+    HCI_READ_LOCAL_NAME_COMMAND,
     HCI_Read_Local_Name_Command,
-    HCI_READ_LOCAL_NAME_COMMAND
+    HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
+    HCI_LE_Read_Maximum_Data_Length_Command,
+    HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
+    HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
+    HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
+    HCI_LE_Read_Maximum_Advertising_Data_Length_Command
 )
 from bumble.host import Host
 from bumble.transport import open_transport_or_link
@@ -57,6 +63,39 @@
 # -----------------------------------------------------------------------------
 async def get_le_info(host):
     print()
+
+    if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
+        response = await host.send_command(HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command())
+        if response.return_parameters.status == HCI_SUCCESS:
+            print(
+                color('LE Number Of Supported Advertising Sets:', 'yellow'),
+                response.return_parameters.num_supported_advertising_sets,
+                '\n'
+            )
+
+    if host.supports_command(HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND):
+        response = await host.send_command(HCI_LE_Read_Maximum_Advertising_Data_Length_Command())
+        if response.return_parameters.status == HCI_SUCCESS:
+            print(
+                color('LE Maximum Advertising Data Length:', 'yellow'),
+                response.return_parameters.max_advertising_data_length,
+                '\n'
+            )
+
+    if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND):
+        response = await host.send_command(HCI_LE_Read_Maximum_Data_Length_Command())
+        if response.return_parameters.status == HCI_SUCCESS:
+            print(
+                color('Maximum Data Length:', 'yellow'),
+                (
+                    f'tx:{response.return_parameters.supported_max_tx_octets}/'
+                    f'{response.return_parameters.supported_max_tx_time}, '
+                    f'rx:{response.return_parameters.supported_max_rx_octets}/'
+                    f'{response.return_parameters.supported_max_rx_time}'
+                ),
+                '\n'
+            )
+
     print(color('LE Features:', 'yellow'))
     for feature in host.supported_le_features:
         print('  ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
diff --git a/apps/gg_bridge.py b/apps/gg_bridge.py
index d524913..ac3df8d 100644
--- a/apps/gg_bridge.py
+++ b/apps/gg_bridge.py
@@ -17,13 +17,14 @@
 # -----------------------------------------------------------------------------
 import asyncio
 import os
+import struct
 import logging
 import click
 from colors import color
 
 from bumble.device import Device, Peer
 from bumble.core import AdvertisingData
-from bumble.gatt import Service, Characteristic
+from bumble.gatt import Service, Characteristic, CharacteristicValue
 from bumble.utils import AsyncRunner
 from bumble.transport import open_transport_or_link
 from bumble.hci import HCI_Constant
@@ -41,13 +42,59 @@
 
 
 # -----------------------------------------------------------------------------
-class GattlinkHubBridge(Device.Listener):
+class GattlinkL2capEndpoint:
     def __init__(self):
-        self.peer              = None
-        self.rx_socket         = None
-        self.tx_socket         = None
-        self.rx_characteristic = None
-        self.tx_characteristic = None
+        self.l2cap_channel     = None
+        self.l2cap_packet      = b''
+        self.l2cap_packet_size = 0
+
+    # Called when an L2CAP SDU has been received
+    def on_coc_sdu(self, sdu):
+        print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
+        while len(sdu):
+            if self.l2cap_packet_size == 0:
+                # Expect a new packet
+                self.l2cap_packet_size = sdu[0] + 1
+                sdu = sdu[1:]
+            else:
+                bytes_needed = self.l2cap_packet_size - len(self.l2cap_packet)
+                chunk = min(bytes_needed, len(sdu))
+                self.l2cap_packet += sdu[:chunk]
+                sdu = sdu[chunk:]
+                if len(self.l2cap_packet) == self.l2cap_packet_size:
+                    self.on_l2cap_packet(self.l2cap_packet)
+                    self.l2cap_packet = b''
+                    self.l2cap_packet_size = 0
+
+
+# -----------------------------------------------------------------------------
+class GattlinkHubBridge(GattlinkL2capEndpoint, Device.Listener):
+    def __init__(self, device, peer_address):
+        super().__init__()
+        self.device                   = device
+        self.peer_address             = peer_address
+        self.peer                     = None
+        self.tx_socket                = None
+        self.rx_characteristic        = None
+        self.tx_characteristic        = None
+        self.l2cap_psm_characteristic = None
+
+        device.listener = self
+
+    async def start(self):
+        # Connect to the peer
+        print(f'=== Connecting to {self.peer_address}...')
+        await self.device.connect(self.peer_address)
+
+    async def connect_l2cap(self, psm):
+        print(color(f'### Connecting with L2CAP on PSM = {psm}', 'yellow'))
+        try:
+            self.l2cap_channel = await self.peer.connection.open_l2cap_channel(psm)
+            print(color('*** Connected', 'yellow'), self.l2cap_channel)
+            self.l2cap_channel.sink = self.on_coc_sdu
+
+        except Exception as error:
+            print(color(f'!!! Connection failed: {error}', 'red'))
 
     @AsyncRunner.run_in_task()
     async def on_connection(self, connection):
@@ -80,15 +127,24 @@
                 self.rx_characteristic = characteristic
             elif characteristic.uuid == GG_GATTLINK_TX_CHARACTERISTIC_UUID:
                 self.tx_characteristic = characteristic
+            elif characteristic.uuid == GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID:
+                self.l2cap_psm_characteristic = characteristic
         print('RX:', self.rx_characteristic)
         print('TX:', self.tx_characteristic)
+        print('PSM:', self.l2cap_psm_characteristic)
 
-        # Subscribe to TX
-        if self.tx_characteristic:
+        if self.l2cap_psm_characteristic:
+            # Subscribe to and then read the PSM value
+            await self.peer.subscribe(self.l2cap_psm_characteristic, self.on_l2cap_psm_received)
+            psm_bytes = await self.peer.read_value(self.l2cap_psm_characteristic)
+            psm = struct.unpack('<H', psm_bytes)[0]
+            await self.connect_l2cap(psm)
+        elif self.tx_characteristic:
+            # Subscribe to TX
             await self.peer.subscribe(self.tx_characteristic, self.on_tx_received)
             print(color('=== Subscribed to Gattlink TX', 'yellow'))
         else:
-            print(color('!!! Gattlink TX not found', 'red'))
+            print(color('!!! No Gattlink TX or PSM found', 'red'))
 
     def on_connection_failure(self, error):
         print(color(f'!!! Connection failed: {error}'))
@@ -99,31 +155,23 @@
         self.rx_characteristic = None
         self.peer = None
 
+    # Called when an L2CAP packet has been received
+    def on_l2cap_packet(self, packet):
+        print(color(f'<<< [L2CAP PACKET]: {len(packet)} bytes', 'cyan'))
+        print(color('>>> [UDP]', 'magenta'))
+        self.tx_socket.sendto(packet)
+
     # Called by the GATT client when a notification is received
     def on_tx_received(self, value):
-        print(color('>>> TX:', 'magenta'), value.hex())
+        print(color(f'<<< [GATT TX]: {len(value)} bytes', 'cyan'))
         if self.tx_socket:
+            print(color('>>> [UDP]', 'magenta'))
             self.tx_socket.sendto(value)
 
     # Called by asyncio when the UDP socket is created
-    def connection_made(self, transport):
-        pass
-
-    # Called by asyncio when a UDP datagram is received
-    def datagram_received(self, data, address):
-        print(color('<<< RX:', 'magenta'), data.hex())
-
-        # TODO: use a queue instead of creating a task everytime
-        if self.peer and self.rx_characteristic:
-            asyncio.create_task(self.peer.write_value(self.rx_characteristic, data))
-
-
-# -----------------------------------------------------------------------------
-class GattlinkNodeBridge(Device.Listener):
-    def __init__(self):
-        self.peer      = None
-        self.rx_socket = None
-        self.tx_socket = None
+    def on_l2cap_psm_received(self, value):
+        psm = struct.unpack('<H', value)[0]
+        asyncio.create_task(self.connect_l2cap(psm))
 
     # Called by asyncio when the UDP socket is created
     def connection_made(self, transport):
@@ -131,21 +179,130 @@
 
     # Called by asyncio when a UDP datagram is received
     def datagram_received(self, data, address):
-        print(color('<<< RX:', 'magenta'), data.hex())
+        print(color(f'<<< [UDP]: {len(data)} bytes', 'green'))
 
-        # TODO: use a queue instead of creating a task everytime
-        if self.peer and self.rx_characteristic:
+        if self.l2cap_channel:
+            print(color('>>> [L2CAP]', 'yellow'))
+            self.l2cap_channel.write(bytes([len(data) - 1]) + data)
+        elif self.peer and self.rx_characteristic:
+            print(color('>>> [GATT RX]', 'yellow'))
             asyncio.create_task(self.peer.write_value(self.rx_characteristic, data))
 
 
 # -----------------------------------------------------------------------------
-async def run(hci_transport, device_address, send_host, send_port, receive_host, receive_port):
+class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
+    def __init__(self, device):
+        super().__init__()
+        self.device            = device
+        self.peer              = None
+        self.tx_socket         = None
+        self.tx_subscriber     = None
+        self.rx_characteristic = None
+
+        # Register as a listener
+        device.listener = self
+
+        # Listen for incoming L2CAP CoC connections
+        psm = 0xFB
+        device.register_l2cap_channel_server(0xFB, self.on_coc)
+        print(f'### Listening for CoC connection on PSM {psm}')
+
+        # Setup the Gattlink service
+        self.rx_characteristic = Characteristic(
+            GG_GATTLINK_RX_CHARACTERISTIC_UUID,
+            Characteristic.WRITE_WITHOUT_RESPONSE,
+            Characteristic.WRITEABLE,
+            CharacteristicValue(write=self.on_rx_write)
+        )
+        self.tx_characteristic = Characteristic(
+            GG_GATTLINK_TX_CHARACTERISTIC_UUID,
+            Characteristic.NOTIFY,
+            Characteristic.READABLE
+        )
+        self.tx_characteristic.on('subscription', self.on_tx_subscription)
+        self.psm_characteristic = Characteristic(
+            GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
+            Characteristic.READ | Characteristic.NOTIFY,
+            Characteristic.READABLE,
+            bytes([psm, 0])
+        )
+        gattlink_service = Service(
+            GG_GATTLINK_SERVICE_UUID,
+            [
+                self.rx_characteristic,
+                self.tx_characteristic,
+                self.psm_characteristic
+            ]
+        )
+        device.add_services([gattlink_service])
+        device.advertising_data = bytes(
+            AdvertisingData([
+                (AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble GG', 'utf-8')),
+                (AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
+                 bytes(reversed(bytes.fromhex('ABBAFF00E56A484CB8328B17CF6CBFE8'))))
+            ])
+        )
+
+    async def start(self):
+        await self.device.start_advertising()
+
+    # Called by asyncio when the UDP socket is created
+    def connection_made(self, transport):
+        self.transport = transport
+
+    # Called by asyncio when a UDP datagram is received
+    def datagram_received(self, data, address):
+        print(color(f'<<< [UDP]: {len(data)} bytes', 'green'))
+
+        if self.l2cap_channel:
+            print(color('>>> [L2CAP]', 'yellow'))
+            self.l2cap_channel.write(bytes([len(data) - 1]) + data)
+        elif self.tx_subscriber:
+            print(color('>>> [GATT TX]', 'yellow'))
+            self.tx_characteristic.value = data
+            asyncio.create_task(self.device.notify_subscribers(self.tx_characteristic))
+
+    # Called when a write to the RX characteristic has been received
+    def on_rx_write(self, connection, data):
+        print(color(f'<<< [GATT RX]: {len(data)} bytes', 'cyan'))
+        print(color('>>> [UDP]', 'magenta'))
+        self.tx_socket.sendto(data)
+
+    # Called when the subscription to the TX characteristic has changed
+    def on_tx_subscription(self, peer, enabled):
+        print(f'### [GATT TX] subscription from {peer}: {"enabled" if enabled else "disabled"}')
+        if enabled:
+            self.tx_subscriber = peer
+        else:
+            self.tx_subscriber = None
+
+    # Called when an L2CAP packet is received
+    def on_l2cap_packet(self, packet):
+        print(color(f'<<< [L2CAP PACKET]: {len(packet)} bytes', 'cyan'))
+        print(color('>>> [UDP]', 'magenta'))
+        self.tx_socket.sendto(packet)
+
+    # Called when a new connection is established
+    def on_coc(self, channel):
+        print('*** CoC Connection', channel)
+        self.l2cap_channel = channel
+        channel.sink = self.on_coc_sdu
+
+
+# -----------------------------------------------------------------------------
+async def run(hci_transport, device_address, role_or_peer_address, send_host, send_port, receive_host, receive_port):
     print('<<< connecting to HCI...')
     async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
         print('<<< connected')
 
         # Instantiate a bridge object
-        bridge = GattlinkNodeBridge()
+        device = Device.with_hci('Bumble GG', device_address, hci_source, hci_sink)
+
+        # Instantiate a bridge object
+        if role_or_peer_address == 'node':
+            bridge = GattlinkNodeBridge(device)
+        else:
+            bridge = GattlinkHubBridge(device, role_or_peer_address)
 
         # Create a UDP to RX bridge (receive from UDP, send to RX)
         loop = asyncio.get_running_loop()
@@ -160,35 +317,8 @@
             remote_addr=(send_host, send_port)
         )
 
-        # Create a device to manage the host, with a custom listener
-        device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
-        device.listener = bridge
         await device.power_on()
-
-        # Connect to the peer
-        # print(f'=== Connecting to {device_address}...')
-        # await device.connect(device_address)
-
-        # TODO move to class
-        gattlink_service = Service(
-            GG_GATTLINK_SERVICE_UUID,
-            [
-                Characteristic(
-                    GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
-                    Characteristic.READ,
-                    Characteristic.READABLE,
-                    bytes([193, 0])
-                )
-            ]
-        )
-        device.add_services([gattlink_service])
-        device.advertising_data = bytes(
-            AdvertisingData([
-                (AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble GG', 'utf-8')),
-                (AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, bytes(reversed(bytes.fromhex('ABBAFF00E56A484CB8328B17CF6CBFE8'))))
-            ])
-        )
-        await device.start_advertising()
+        await bridge.start()
 
         # Wait until the source terminates
         await hci_source.wait_for_termination()
@@ -197,15 +327,16 @@
 @click.command()
 @click.argument('hci_transport')
 @click.argument('device_address')
[email protected]('role_or_peer_address')
 @click.option('-sh', '--send-host', type=str, default='127.0.0.1', help='UDP host to send to')
 @click.option('-sp', '--send-port', type=int, default=9001, help='UDP port to send to')
 @click.option('-rh', '--receive-host', type=str, default='127.0.0.1', help='UDP host to receive on')
 @click.option('-rp', '--receive-port', type=int, default=9000, help='UDP port to receive on')
-def main(hci_transport, device_address, send_host, send_port, receive_host, receive_port):
-    logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
-    asyncio.run(run(hci_transport, device_address, send_host, send_port, receive_host, receive_port))
+def main(hci_transport, device_address, role_or_peer_address, send_host, send_port, receive_host, receive_port):
+    asyncio.run(run(hci_transport, device_address, role_or_peer_address, send_host, send_port, receive_host, receive_port))
 
 
 # -----------------------------------------------------------------------------
+logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
 if __name__ == '__main__':
     main()
diff --git a/apps/l2cap_bridge.py b/apps/l2cap_bridge.py
new file mode 100644
index 0000000..ba658c2
--- /dev/null
+++ b/apps/l2cap_bridge.py
@@ -0,0 +1,331 @@
+# 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 click
+import logging
+import os
+from colors import color
+
+from bumble.transport import open_transport_or_link
+from bumble.device import Device
+from bumble.utils import FlowControlAsyncPipe
+from bumble.hci import HCI_Constant
+
+
+# -----------------------------------------------------------------------------
+class ServerBridge:
+    """
+    L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
+    on a specified PSM. When the connection is made, the bridge connects a TCP
+    socket to a remote host and bridges the data in both directions, with flow
+    control.
+    When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
+    and waits for a new L2CAP CoC channel to be connected.
+    When the TCP connection is closed by the TCP server, XXXX
+    """
+    def __init__(
+        self,
+        psm,
+        max_credits,
+        mtu,
+        mps,
+        tcp_host,
+        tcp_port
+    ):
+        self.psm         = psm
+        self.max_credits = max_credits
+        self.mtu         = mtu
+        self.mps         = mps
+        self.tcp_host    = tcp_host
+        self.tcp_port    = tcp_port
+
+    async def start(self, device):
+        # Listen for incoming L2CAP CoC connections
+        device.register_l2cap_channel_server(
+            psm         = self.psm,
+            server      = self.on_coc,
+            max_credits = self.max_credits,
+            mtu         = self.mtu,
+            mps         = self.mps
+        )
+        print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
+
+        def on_ble_connection(connection):
+            def on_ble_disconnection(reason):
+                print(color('@@@ Bluetooth disconnection:', 'red'), HCI_Constant.error_name(reason))
+
+            print(color('@@@ Bluetooth connection:', 'green'), connection)
+            connection.on('disconnection', on_ble_disconnection)
+
+        device.on('connection', on_ble_connection)
+
+        await device.start_advertising(auto_restart=True)
+
+    # Called when a new L2CAP connection is established
+    def on_coc(self, l2cap_channel):
+        print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
+
+        class Pipe:
+            def __init__(self, bridge, l2cap_channel):
+                self.bridge        = bridge
+                self.tcp_transport = None
+                self.l2cap_channel = l2cap_channel
+
+                l2cap_channel.on('close', self.on_l2cap_close)
+                l2cap_channel.sink = self.on_coc_sdu
+
+            async def connect_to_tcp(self):
+                # Connect to the TCP server
+                print(color(f'### Connecting to TCP {self.bridge.tcp_host}:{self.bridge.tcp_port}...', 'yellow'))
+
+                class TcpClientProtocol(asyncio.Protocol):
+                    def __init__(self, pipe):
+                        self.pipe = pipe
+
+                    def connection_lost(self, error):
+                        print(color(f'!!! TCP connection lost: {error}', 'red'))
+                        if self.pipe.l2cap_channel is not None:
+                            asyncio.create_task(self.pipe.l2cap_channel.disconnect())
+
+                    def data_received(self, data):
+                        print(f'<<< Received on TCP: {len(data)}')
+                        self.pipe.l2cap_channel.write(data)
+
+                try:
+                    self.tcp_transport, _ = await asyncio.get_running_loop().create_connection(
+                        lambda: TcpClientProtocol(self),
+                        host=self.bridge.tcp_host,
+                        port=self.bridge.tcp_port,
+                    )
+                    print(color('### Connected', 'green'))
+                except Exception as error:
+                    print(color(f'!!! Connection failed: {error}', 'red'))
+                    await self.l2cap_channel.disconnect()
+
+            def on_l2cap_close(self):
+                self.l2cap_channel = None
+                if self.tcp_transport is not None:
+                    self.tcp_transport.close()
+
+            def on_coc_sdu(self, sdu):
+                print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
+                if self.tcp_transport is None:
+                    print(color('!!! TCP socket not open, dropping', 'red'))
+                    return
+                self.tcp_transport.write(sdu)
+
+        pipe = Pipe(self, l2cap_channel)
+
+        asyncio.create_task(pipe.connect_to_tcp())
+
+
+# -----------------------------------------------------------------------------
+class ClientBridge:
+    """
+    L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
+    TCP connection on a specified port number. When a TCP client connects, an
+    L2CAP CoC channel connection to the BLE device is established, and the data
+    is bridged in both directions, with flow control.
+    When the TCP connection is closed by the client, the L2CAP CoC channel is
+    disconnected, but the connection to the BLE device remains, ready for a new
+    TCP client to connect.
+    When the L2CAP CoC channel is closed, XXXX
+    """
+
+    READ_CHUNK_SIZE = 4096
+
+    def __init__(
+        self,
+        psm,
+        max_credits,
+        mtu,
+        mps,
+        address,
+        tcp_host,
+        tcp_port
+    ):
+        self.psm         = psm
+        self.max_credits = max_credits
+        self.mtu         = mtu
+        self.mps         = mps
+        self.address     = address
+        self.tcp_host    = tcp_host
+        self.tcp_port    = tcp_port
+
+    async def start(self, device):
+        print(color(f'### Connecting to {self.address}...', 'yellow'))
+        connection = await device.connect(self.address)
+        print(color('### Connected', 'green'))
+
+        # Called when the BLE connection is disconnected
+        def on_ble_disconnection(reason):
+            print(color('@@@ Bluetooth disconnection:', 'red'), HCI_Constant.error_name(reason))
+
+        connection.on('disconnection', on_ble_disconnection)
+
+        # Called when a TCP connection is established
+        async def on_tcp_connection(reader, writer):
+            peername = writer.get_extra_info('peername')
+            print(color(f'<<< TCP connection from {peername}', 'magenta'))
+
+            def on_coc_sdu(sdu):
+                print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
+                l2cap_to_tcp_pipe.write(sdu)
+
+            def on_l2cap_close():
+                print(color('*** L2CAP channel closed', 'red'))
+                l2cap_to_tcp_pipe.stop()
+                writer.close()
+
+            # Connect a new L2CAP channel
+            print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
+            try:
+                l2cap_channel = await connection.open_l2cap_channel(
+                    psm         = self.psm,
+                    max_credits = self.max_credits,
+                    mtu         = self.mtu,
+                    mps         = self.mps
+                )
+                print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
+            except Exception as error:
+                print(color(f'!!! Connection failed: {error}', 'red'))
+                writer.close()
+                return
+
+            l2cap_channel.sink = on_coc_sdu
+            l2cap_channel.on('close', on_l2cap_close)
+
+            # Start a flow control pipe from L2CAP to TCP
+            l2cap_to_tcp_pipe = FlowControlAsyncPipe(
+                l2cap_channel.pause_reading,
+                l2cap_channel.resume_reading,
+                writer.write,
+                writer.drain
+            )
+            l2cap_to_tcp_pipe.start()
+
+            # Pipe data from TCP to L2CAP
+            while True:
+                try:
+                    data = await reader.read(self.READ_CHUNK_SIZE)
+
+                    if len(data) == 0:
+                        print(color('!!! End of stream', 'red'))
+                        await l2cap_channel.disconnect()
+                        return
+
+                    print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
+                    l2cap_channel.write(data)
+                    await l2cap_channel.drain()
+                except Exception as error:
+                    print(f'!!! Exception: {error}')
+                    break
+
+            writer.close()
+            print(color('~~~ Bye bye', 'magenta'))
+
+        await asyncio.start_server(
+            on_tcp_connection,
+            host=self.tcp_host if self.tcp_host != '_' else None,
+            port=self.tcp_port
+        )
+        print(color(f'### Listening for TCP connections on port {self.tcp_port}', 'magenta'))
+
+
+# -----------------------------------------------------------------------------
+async def run(device_config, hci_transport, bridge):
+    print('<<< connecting to HCI...')
+    async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
+        print('<<< connected')
+
+        device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
+
+        # Let's go
+        await device.power_on()
+        await bridge.start(device)
+
+        # Wait until the transport terminates
+        await hci_source.wait_for_termination()
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]_context
[email protected]('--device-config',         help='Device configuration file', required=True)
[email protected]('--hci-transport',         help='HCI transport', required=True)
[email protected]('--psm',                   help='PSM for L2CAP CoC', type=int, default=1234)
[email protected]('--l2cap-coc-max-credits', help='Maximum L2CAP CoC Credits', type=click.IntRange(1, 65535), default=128)
[email protected]('--l2cap-coc-mtu',         help='L2CAP CoC MTU', type=click.IntRange(23, 65535), default=1022)
[email protected]('--l2cap-coc-mps',         help='L2CAP CoC MPS', type=click.IntRange(23, 65533), default=1024)
+def cli(context, device_config, hci_transport, psm, l2cap_coc_max_credits, l2cap_coc_mtu, l2cap_coc_mps):
+    context.ensure_object(dict)
+    context.obj['device_config'] = device_config
+    context.obj['hci_transport'] = hci_transport
+    context.obj['psm']           = psm
+    context.obj['max_credits']   = l2cap_coc_max_credits
+    context.obj['mtu']           = l2cap_coc_mtu
+    context.obj['mps']           = l2cap_coc_mps
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]_context
[email protected]('--tcp-host', help='TCP host', default='localhost')
[email protected]('--tcp-port', help='TCP port', default=9544)
+def server(context, tcp_host, tcp_port):
+    bridge = ServerBridge(
+        context.obj['psm'],
+        context.obj['max_credits'],
+        context.obj['mtu'],
+        context.obj['mps'],
+        tcp_host,
+        tcp_port)
+    asyncio.run(run(
+        context.obj['device_config'],
+        context.obj['hci_transport'],
+        bridge
+    ))
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]_context
[email protected]('bluetooth-address')
[email protected]('--tcp-host', help='TCP host', default='_')
[email protected]('--tcp-port', help='TCP port', default=9543)
+def client(context, bluetooth_address, tcp_host, tcp_port):
+    bridge = ClientBridge(
+        context.obj['psm'],
+        context.obj['max_credits'],
+        context.obj['mtu'],
+        context.obj['mps'],
+        bluetooth_address,
+        tcp_host,
+        tcp_port
+    )
+    asyncio.run(run(
+        context.obj['device_config'],
+        context.obj['hci_transport'],
+        bridge
+    ))
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
+if __name__ == '__main__':
+    cli(obj={})
diff --git a/apps/scan.py b/apps/scan.py
index 045cb57..d6c1092 100644
--- a/apps/scan.py
+++ b/apps/scan.py
@@ -25,8 +25,8 @@
 from bumble.transport import open_transport_or_link
 from bumble.keys import JsonKeyStore
 from bumble.smp import AddressResolver
-from bumble.hci import HCI_LE_Advertising_Report_Event
-from bumble.core import AdvertisingData
+from bumble.device import Advertisement
+from bumble.hci import HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
 
 
 # -----------------------------------------------------------------------------
@@ -48,16 +48,19 @@
         self.min_rssi = min_rssi
         self.resolver = resolver
 
-    def print_advertisement(self, address, address_color, ad_data, rssi):
-        if self.min_rssi is not None and rssi < self.min_rssi:
+    def print_advertisement(self, advertisement):
+        address = advertisement.address
+        address_color = 'yellow' if advertisement.is_connectable else 'red'
+
+        if self.min_rssi is not None and advertisement.rssi < self.min_rssi:
             return
 
         address_qualifier = ''
         resolution_qualifier = ''
-        if self.resolver and address.is_resolvable:
-            resolved = self.resolver.resolve(address)
+        if self.resolver and advertisement.address.is_resolvable:
+            resolved = self.resolver.resolve(advertisement.address)
             if resolved is not None:
-                resolution_qualifier = f'(resolved from {address})'
+                resolution_qualifier = f'(resolved from {advertisement.address})'
                 address = resolved
 
         address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[address.address_type]
@@ -74,18 +77,30 @@
                 type_color = 'blue'
                 address_qualifier = '(non-resolvable)'
 
-        rssi_bar = make_rssi_bar(rssi)
         separator = '\n  '
-        print(f'>>> {color(address, address_color)} [{color(address_type_string, type_color)}]{address_qualifier}{resolution_qualifier}:{separator}RSSI:{rssi:4} {rssi_bar}{separator}{ad_data.to_string(separator)}\n')
+        rssi_bar = make_rssi_bar(advertisement.rssi)
+        if not advertisement.is_legacy:
+            phy_info = (
+                f'PHY: {HCI_Constant.le_phy_name(advertisement.primary_phy)}/'
+                f'{HCI_Constant.le_phy_name(advertisement.secondary_phy)} '
+                f'{separator}'
+            )
+        else:
+            phy_info = ''
 
-    def on_advertisement(self, address, ad_data, rssi, connectable):
-        address_color = 'yellow' if connectable else 'red'
-        self.print_advertisement(address, address_color, ad_data, rssi)
+        print(
+            f'>>> {color(address, address_color)} '
+            f'[{color(address_type_string, type_color)}]{address_qualifier}{resolution_qualifier}:{separator}'
+            f'{phy_info}'
+            f'RSSI:{advertisement.rssi:4} {rssi_bar}{separator}'
+            f'{advertisement.data.to_string(separator)}\n')
 
-    def on_advertising_report(self, address, ad_data, rssi, event_type):
-        print(f'{color("EVENT", "green")}: {HCI_LE_Advertising_Report_Event.event_type_name(event_type)}')
-        ad_data = AdvertisingData.from_bytes(ad_data)
-        self.print_advertisement(address, 'yellow', ad_data, rssi)
+    def on_advertisement(self, advertisement):
+        self.print_advertisement(advertisement)
+
+    def on_advertising_report(self, report):
+        print(f'{color("EVENT", "green")}: {report.event_type_string()}')
+        self.print_advertisement(Advertisement.from_advertising_report(report))
 
 
 # -----------------------------------------------------------------------------
@@ -94,6 +109,7 @@
     passive,
     scan_interval,
     scan_window,
+    phy,
     filter_duplicates,
     raw,
     keystore_file,
@@ -126,11 +142,18 @@
             device.on('advertisement', printer.on_advertisement)
 
         await device.power_on()
+
+        if phy is None:
+            scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY]
+        else:
+            scanning_phys = [{'1m': HCI_LE_1M_PHY, 'coded': HCI_LE_CODED_PHY}[phy]]
+
         await device.start_scanning(
             active=(not passive),
             scan_interval=scan_interval,
             scan_window=scan_window,
-            filter_duplicates=filter_duplicates
+            filter_duplicates=filter_duplicates,
+            scanning_phys=scanning_phys
         )
 
         await hci_source.wait_for_termination()
@@ -142,14 +165,15 @@
 @click.option('--passive', is_flag=True, default=False, help='Perform passive scanning')
 @click.option('--scan-interval', type=int, default=60, help='Scan interval')
 @click.option('--scan-window', type=int, default=60, help='Scan window')
[email protected]('--phy', type=click.Choice(['1m', 'coded']), help='Only scan on the specified PHY')
 @click.option('--filter-duplicates', type=bool, default=True, help='Filter duplicates at the controller level')
 @click.option('--raw', is_flag=True, default=False, help='Listen for raw advertising reports instead of processed ones')
 @click.option('--keystore-file', help='Keystore file to use when resolving addresses')
 @click.option('--device-config', help='Device config file for the scanning device')
 @click.argument('transport')
-def main(min_rssi, passive, scan_interval, scan_window, filter_duplicates, raw, keystore_file, device_config, transport):
+def main(min_rssi, passive, scan_interval, scan_window, phy, filter_duplicates, raw, keystore_file, device_config, transport):
     logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
-    asyncio.run(scan(min_rssi, passive, scan_interval, scan_window, filter_duplicates, raw, keystore_file, device_config, transport))
+    asyncio.run(scan(min_rssi, passive, scan_interval, scan_window, phy, filter_duplicates, raw, keystore_file, device_config, transport))
 
 
 # -----------------------------------------------------------------------------
diff --git a/apps/show.py b/apps/show.py
index bba6c66..a4efe04 100644
--- a/apps/show.py
+++ b/apps/show.py
@@ -90,7 +90,7 @@
 @click.command()
 @click.option('--format', type=click.Choice(['h4', 'snoop']), default='h4', help='Format of the input file')
 @click.argument('filename')
-def show(format, filename):
+def main(format, filename):
     input = open(filename, 'rb')
     if format == 'h4':
         packet_reader = PacketReader(input)
@@ -117,4 +117,4 @@
 
 # -----------------------------------------------------------------------------
 if __name__ == '__main__':
-    show()
+    main()
diff --git a/apps/usb_probe.py b/apps/usb_probe.py
index 9cded2b..26b7f40 100644
--- a/apps/usb_probe.py
+++ b/apps/usb_probe.py
@@ -28,6 +28,8 @@
 # -----------------------------------------------------------------------------
 import os
 import logging
+import sys
+import click
 import usb1
 from colors import color
 
@@ -35,6 +37,7 @@
 # -----------------------------------------------------------------------------
 # Constants
 # -----------------------------------------------------------------------------
+USB_DEVICE_CLASS_DEVICE                          = 0x00
 USB_DEVICE_CLASS_WIRELESS_CONTROLLER             = 0xE0
 USB_DEVICE_SUBCLASS_RF_CONTROLLER                = 0x01
 USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
@@ -75,9 +78,81 @@
     0xFF: 'Vendor Specific'
 }
 
+USB_ENDPOINT_IN    = 0x80
+USB_ENDPOINT_TYPES = ['CONTROL', 'ISOCHRONOUS', 'BULK', 'INTERRUPT']
+
+USB_BT_HCI_CLASS_TUPLE = (
+    USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
+    USB_DEVICE_SUBCLASS_RF_CONTROLLER,
+    USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+)
+
 
 # -----------------------------------------------------------------------------
-def main():
+def show_device_details(device):
+    for configuration in device:
+        print(f'  Configuration {configuration.getConfigurationValue()}')
+        for interface in configuration:
+            for setting in interface:
+                alternateSetting = setting.getAlternateSetting()
+                suffix = f'/{alternateSetting}' if interface.getNumSettings() > 1 else ''
+                (class_string, subclass_string) = get_class_info(
+                    setting.getClass(),
+                    setting.getSubClass(),
+                    setting.getProtocol()
+                )
+                details = f'({class_string}, {subclass_string})'
+                print(f'      Interface: {setting.getNumber()}{suffix} {details}')
+                for endpoint in setting:
+                    endpoint_type = USB_ENDPOINT_TYPES[endpoint.getAttributes() & 3]
+                    endpoint_direction = 'OUT' if (endpoint.getAddress() & USB_ENDPOINT_IN == 0) else 'IN'
+                    print(f'        Endpoint 0x{endpoint.getAddress():02X}: {endpoint_type} {endpoint_direction}')
+
+
+# -----------------------------------------------------------------------------
+def get_class_info(cls, subclass, protocol):
+    class_info = USB_DEVICE_CLASSES.get(cls)
+    protocol_string = ''
+    if class_info is None:
+        class_string = f'0x{cls:02X}'
+    else:
+        if type(class_info) is tuple:
+            class_string = class_info[0]
+            subclass_info = class_info[1].get(subclass)
+            if subclass_info:
+                protocol_string = subclass_info.get(protocol)
+                if protocol_string is not None:
+                    protocol_string = f' [{protocol_string}]'
+
+        else:
+            class_string = class_info
+
+    subclass_string = f'{subclass}/{protocol}{protocol_string}'
+
+    return (class_string, subclass_string)
+
+
+# -----------------------------------------------------------------------------
+def is_bluetooth_hci(device):
+    # Check if the device class indicates a match
+    if (device.getDeviceClass(), device.getDeviceSubClass(), device.getDeviceProtocol()) == USB_BT_HCI_CLASS_TUPLE:
+        return True
+
+    # If the device class is 'Device', look for a matching interface
+    if device.getDeviceClass() == USB_DEVICE_CLASS_DEVICE:
+        for configuration in device:
+            for interface in configuration:
+                for setting in interface:
+                    if (setting.getClass(), setting.getSubClass(), setting.getProtocol()) == USB_BT_HCI_CLASS_TUPLE:
+                        return True
+
+    return False
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]('--verbose', is_flag=True, default=False, help='Print more details')
+def main(verbose):
     logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
 
     with usb1.USBContext() as context:
@@ -91,23 +166,28 @@
 
             device_id = (device.getVendorID(), device.getProductID())
 
-            device_is_bluetooth_hci = (
-                device_class    == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and
-                device_subclass == USB_DEVICE_SUBCLASS_RF_CONTROLLER and
-                device_protocol == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+            (device_class_string, device_subclass_string) = get_class_info(
+                device_class,
+                device_subclass,
+                device_protocol
             )
 
-            device_class_details = ''
-            device_class_info    = USB_DEVICE_CLASSES.get(device_class)
-            if device_class_info is not None:
-                if type(device_class_info) is tuple:
-                    device_class = device_class_info[0]
-                    device_subclass_info = device_class_info[1].get(device_subclass)
-                    if device_subclass_info:
-                        device_class_details = f' [{device_subclass_info.get(device_protocol)}]'
-                else:
-                    device_class = device_class_info
+            try:
+                device_serial_number = device.getSerialNumber()
+            except usb1.USBError:
+                device_serial_number = None
 
+            try:
+                device_manufacturer = device.getManufacturer()
+            except usb1.USBError:
+                device_manufacturer = None
+
+            try:
+                device_product = device.getProduct()
+            except usb1.USBError:
+                device_product = None
+
+            device_is_bluetooth_hci = is_bluetooth_hci(device)
             if device_is_bluetooth_hci:
                 bluetooth_device_count += 1
                 fg_color = 'black'
@@ -123,33 +203,35 @@
             if device_is_bluetooth_hci:
                 bumble_transport_names.append(f'usb:{bluetooth_device_count - 1}')
 
-            serial_number_collision = False
-            if device_id in devices:
-                for device_serial in devices[device_id]:
-                    if device_serial == device.getSerialNumber():
-                        serial_number_collision = True
-
             if device_id not in devices:
                 bumble_transport_names.append(basic_transport_name)
             else:
                 bumble_transport_names.append(f'{basic_transport_name}#{len(devices[device_id])}')
 
-            if device.getSerialNumber() and not serial_number_collision:
-                bumble_transport_names.append(f'{basic_transport_name}/{device.getSerialNumber()}')
+            if device_serial_number is not None:
+                if device_id not in devices or device_serial_number not in devices[device_id]:
+                    bumble_transport_names.append(f'{basic_transport_name}/{device_serial_number}')
 
+            # Print the results
             print(color(f'ID {device.getVendorID():04X}:{device.getProductID():04X}', fg=fg_color, bg=bg_color))
             if bumble_transport_names:
                 print(color('  Bumble Transport Names:', 'blue'), ' or '.join(color(x, 'cyan' if device_is_bluetooth_hci else 'red') for x in bumble_transport_names))
             print(color('  Bus/Device:            ', 'green'), f'{device.getBusNumber():03}/{device.getDeviceAddress():03}')
-            if device.getSerialNumber():
-                print(color('  Serial:                ', 'green'), device.getSerialNumber())
-            print(color('  Class:                 ', 'green'), device_class)
-            print(color('  Subclass/Protocol:     ', 'green'), f'{device_subclass}/{device_protocol}{device_class_details}')
-            print(color('  Manufacturer:          ', 'green'), device.getManufacturer())
-            print(color('  Product:               ', 'green'), device.getProduct())
+            print(color('  Class:                 ', 'green'), device_class_string)
+            print(color('  Subclass/Protocol:     ', 'green'), device_subclass_string)
+            if device_serial_number is not None:
+                print(color('  Serial:                ', 'green'), device_serial_number)
+            if device_manufacturer is not None:
+                print(color('  Manufacturer:          ', 'green'), device_manufacturer)
+            if device_product is not None:
+                print(color('  Product:               ', 'green'), device_product)
+
+            if verbose:
+                show_device_details(device)
+
             print()
 
-            devices.setdefault(device_id, []).append(device.getSerialNumber())
+            devices.setdefault(device_id, []).append(device_serial_number)
 
 
 # -----------------------------------------------------------------------------
diff --git a/bumble/att.py b/bumble/att.py
index 22b683e..d83b3d1 100644
--- a/bumble/att.py
+++ b/bumble/att.py
@@ -700,16 +700,26 @@
         else:
             self.value = value
 
+    def encode_value(self, value):
+        return value
+
+    def decode_value(self, value_bytes):
+        return value_bytes
+
     def read_value(self, connection):
         if read := getattr(self.value, 'read', None):
             try:
-                return read(connection)
+                value = read(connection)
             except ATT_Error as error:
                 raise ATT_Error(error_code=error.error_code, att_handle=self.handle)
         else:
-            return self.value
+            value = self.value
 
-    def write_value(self, connection, value):
+        return self.encode_value(value)
+
+    def write_value(self, connection, value_bytes):
+        value = self.decode_value(value_bytes)
+
         if write := getattr(self.value, 'write', None):
             try:
                 write(connection, value)
@@ -721,7 +731,11 @@
         self.emit('write', connection, value)
 
     def __repr__(self):
-        if len(self.value) > 0:
+        if type(self.value) is bytes:
+            value_str = self.value.hex()
+        else:
+            value_str = str(self.value)
+        if value_str:
             value_string = f', value={self.value.hex()}'
         else:
             value_string = ''
diff --git a/bumble/avdtp.py b/bumble/avdtp.py
index 759e38c..7fe4fbb 100644
--- a/bumble/avdtp.py
+++ b/bumble/avdtp.py
@@ -351,7 +351,7 @@
                 logger.debug('pump canceled')
 
         # Pump packets
-        self.pump_task = asyncio.get_running_loop().create_task(pump_packets())
+        self.pump_task = asyncio.create_task(pump_packets())
 
     async def stop(self):
         # Stop the pump
@@ -1890,10 +1890,10 @@
         self.configuration = configuration
 
     def on_start_command(self):
-        asyncio.get_running_loop().create_task(self.start())
+        asyncio.create_task(self.start())
 
     def on_suspend_command(self):
-        asyncio.get_running_loop().create_task(self.stop())
+        asyncio.create_task(self.stop())
 
 
 # -----------------------------------------------------------------------------
diff --git a/bumble/controller.py b/bumble/controller.py
index c282416..7982e9b 100644
--- a/bumble/controller.py
+++ b/bumble/controller.py
@@ -76,7 +76,7 @@
         self.supported_commands           = bytes.fromhex('2000800000c000000000e40000002822000000000000040000f7ffff7f00000030f0f9ff01008004000000000000000000000000000000000000000000000000')
         self.le_features                  = bytes.fromhex('ff49010000000000')
         self.le_states                    = bytes.fromhex('ffff3fffff030000')
-        self.avertising_channel_tx_power  = 0
+        self.advertising_channel_tx_power = 0
         self.filter_accept_list_size      = 8
         self.resolving_list_size          = 8
         self.supported_max_tx_octets      = 27
@@ -259,15 +259,15 @@
 
         # Then say that the connection has completed
         self.send_hci_packet(HCI_LE_Connection_Complete_Event(
-            status                = HCI_SUCCESS,
-            connection_handle     = connection.handle,
-            role                  = connection.role,
-            peer_address_type     = peer_address_type,
-            peer_address          = peer_address,
-            conn_interval         = 10,  # FIXME
-            conn_latency          = 0,   # FIXME
-            supervision_timeout   = 10,  # FIXME
-            master_clock_accuracy = 7    # FIXME
+            status                 = HCI_SUCCESS,
+            connection_handle      = connection.handle,
+            role                   = connection.role,
+            peer_address_type      = peer_address_type,
+            peer_address           = peer_address,
+            connection_interval    = 10,  # FIXME
+            peripheral_latency     = 0,   # FIXME
+            supervision_timeout    = 10,  # FIXME
+            central_clock_accuracy = 7    # FIXME
         ))
 
     def on_link_central_disconnected(self, peer_address, reason):
@@ -313,15 +313,15 @@
 
         # Say that the connection has completed
         self.send_hci_packet(HCI_LE_Connection_Complete_Event(
-            status                = status,
-            connection_handle     = connection.handle if connection else 0,
-            role                  = BT_CENTRAL_ROLE,
-            peer_address_type     = le_create_connection_command.peer_address_type,
-            peer_address          = le_create_connection_command.peer_address,
-            conn_interval         = le_create_connection_command.conn_interval_min,
-            conn_latency          = le_create_connection_command.conn_latency,
-            supervision_timeout   = le_create_connection_command.supervision_timeout,
-            master_clock_accuracy = 0
+            status                 = status,
+            connection_handle      = connection.handle if connection else 0,
+            role                   = BT_CENTRAL_ROLE,
+            peer_address_type      = le_create_connection_command.peer_address_type,
+            peer_address           = le_create_connection_command.peer_address,
+            connection_interval    = le_create_connection_command.connection_interval_min,
+            peripheral_latency     = le_create_connection_command.max_latency,
+            supervision_timeout    = le_create_connection_command.supervision_timeout,
+            central_clock_accuracy = 0
         ))
 
     def on_link_peripheral_disconnection_complete(self, disconnection_command, status):
@@ -583,13 +583,15 @@
         '''
         See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
         '''
-        return struct.pack('<BBHBHH',
-                           HCI_SUCCESS,
-                           self.hci_version,
-                           self.hci_revision,
-                           self.lmp_version,
-                           self.manufacturer_name,
-                           self.lmp_subversion)
+        return struct.pack(
+            '<BBHBHH',
+            HCI_SUCCESS,
+            self.hci_version,
+            self.hci_revision,
+            self.lmp_version,
+            self.manufacturer_name,
+            self.lmp_subversion
+        )
 
     def on_hci_read_local_supported_commands_command(self, command):
         '''
@@ -650,7 +652,7 @@
         '''
         See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Channel Tx Power Command
         '''
-        return bytes([HCI_SUCCESS, self.avertising_channel_tx_power])
+        return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
 
     def on_hci_le_set_advertising_data_command(self, command):
         '''
@@ -857,9 +859,9 @@
         See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable Command
         '''
         ret = HCI_SUCCESS
-        if command.address_resolution == 1:
+        if command.address_resolution_enable == 1:
             self.le_address_resolution = True
-        elif command.address_resolution == 0:
+        elif command.address_resolution_enable == 0:
             self.le_address_resolution = False
         else:
             ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
@@ -876,12 +878,26 @@
         '''
         See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
         '''
-        return struct.pack('<BHHHH',
-                           HCI_SUCCESS,
-                           self.supported_max_tx_octets,
-                           self.supported_max_tx_time,
-                           self.supported_max_rx_octets,
-                           self.supported_max_rx_time)
+        return struct.pack(
+            '<BHHHH',
+            HCI_SUCCESS,
+            self.supported_max_tx_octets,
+            self.supported_max_tx_time,
+            self.supported_max_rx_octets,
+            self.supported_max_rx_time
+        )
+
+    def on_hci_le_read_phy_command(self, command):
+        '''
+        See Bluetooth spec Vol 2, Part E - 7.8.47 LE Read PHY command
+        '''
+        return struct.pack(
+            '<BHBB',
+            HCI_SUCCESS,
+            command.connection_handle,
+            HCI_LE_1M_PHY,
+            HCI_LE_1M_PHY
+        )
 
     def on_hci_le_set_default_phy_command(self, command):
         '''
@@ -893,3 +909,4 @@
             'rx_phys':  command.rx_phys
         }
         return bytes([HCI_SUCCESS])
+
diff --git a/bumble/core.py b/bumble/core.py
index 746a601..302ee6a 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -91,6 +91,10 @@
     """ Timeout Error """
 
 
+class CommandTimeoutError(Exception):
+    """ Command Timeout Error """
+
+
 class InvalidStateError(Exception):
     """ Invalid State Error """
 
@@ -100,6 +104,11 @@
     FAILURE            = 0x01
     CONNECTION_REFUSED = 0x02
 
+    def __init__(self, error_code, transport, peer_address, error_namespace='', error_name='', details=''):
+        super().__init__(error_code, error_namespace, error_name, details)
+        self.transport = transport
+        self.peer_address = peer_address
+
 
 # -----------------------------------------------------------------------------
 # UUID
@@ -760,17 +769,20 @@
     def ad_data_to_object(ad_type, ad_data):
         if ad_type in {
             AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
-            AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS
+            AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+            AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS
         }:
             return AdvertisingData.uuid_list_to_objects(ad_data, 2)
         elif ad_type in {
             AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
-            AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS
+            AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
+            AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS
         }:
             return AdvertisingData.uuid_list_to_objects(ad_data, 4)
         elif ad_type in {
             AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
-            AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS
+            AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
+            AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS
         }:
             return AdvertisingData.uuid_list_to_objects(ad_data, 16)
         elif ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID:
@@ -781,11 +793,24 @@
             return (UUID.from_bytes(ad_data[:16]), ad_data[16:])
         elif ad_type in {
             AdvertisingData.SHORTENED_LOCAL_NAME,
-            AdvertisingData.COMPLETE_LOCAL_NAME
+            AdvertisingData.COMPLETE_LOCAL_NAME,
+            AdvertisingData.URI
         }:
             return ad_data.decode("utf-8")
-        elif ad_type == AdvertisingData.TX_POWER_LEVEL:
+        elif ad_type in {
+            AdvertisingData.TX_POWER_LEVEL,
+            AdvertisingData.FLAGS
+        }:
             return ad_data[0]
+        elif ad_type in {
+            AdvertisingData.APPEARANCE,
+            AdvertisingData.ADVERTISING_INTERVAL
+        }:
+            return struct.unpack('<H', ad_data)[0]
+        elif ad_type == AdvertisingData.CLASS_OF_DEVICE:
+            return struct.unpack('<I', bytes([*ad_data, 0]))[0]
+        elif ad_type == AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE:
+            return struct.unpack('<HH', ad_data)
         elif ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
             return (struct.unpack_from('<H', ad_data, 0)[0], ad_data[2:])
         else:
@@ -802,7 +827,7 @@
                 self.ad_structures.append((ad_type, ad_data))
             offset += length
 
-    def get(self, type_id, return_all=False, raw=True):
+    def get(self, type_id, return_all=False, raw=False):
         '''
         Get Advertising Data Structure(s) with a given type
 
@@ -831,13 +856,17 @@
 # Connection Parameters
 # -----------------------------------------------------------------------------
 class ConnectionParameters:
-    def __init__(self, connection_interval, connection_latency, supervision_timeout):
+    def __init__(self, connection_interval, peripheral_latency, supervision_timeout):
         self.connection_interval = connection_interval
-        self.connection_latency  = connection_latency
+        self.peripheral_latency  = peripheral_latency
         self.supervision_timeout = supervision_timeout
 
     def __str__(self):
-        return f'ConnectionParameters(connection_interval={self.connection_interval}, connection_latency={self.connection_latency}, supervision_timeout={self.supervision_timeout}'
+        return (
+            f'ConnectionParameters(connection_interval={self.connection_interval}, '
+            f'peripheral_latency={self.peripheral_latency}, '
+            f'supervision_timeout={self.supervision_timeout}'
+        )
 
 
 # -----------------------------------------------------------------------------
diff --git a/bumble/device.py b/bumble/device.py
index 230a1cf..300afd2 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -15,10 +15,12 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from enum import IntEnum
 import json
 import asyncio
 import logging
-from  contextlib import asynccontextmanager, AsyncExitStack
+from contextlib import asynccontextmanager, AsyncExitStack
+from dataclasses import dataclass
 
 from .hci import *
 from .host import Host
@@ -41,49 +43,219 @@
 # -----------------------------------------------------------------------------
 # Constants
 # -----------------------------------------------------------------------------
-DEVICE_DEFAULT_ADDRESS              = '00:00:00:00:00:00'
-DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000  # ms
-DEVICE_DEFAULT_ADVERTISING_DATA     = ''
-DEVICE_DEFAULT_NAME                 = 'Bumble'
-DEVICE_DEFAULT_INQUIRY_LENGTH       = 8  # 10.24 seconds
-DEVICE_DEFAULT_CLASS_OF_DEVICE      = 0
-DEVICE_DEFAULT_SCAN_RESPONSE_DATA   = b''
-DEVICE_DEFAULT_DATA_LENGTH          = (27, 328, 27, 328)
-DEVICE_DEFAULT_SCAN_INTERVAL        = 60  # ms
-DEVICE_DEFAULT_SCAN_WINDOW          = 60  # ms
-DEVICE_MIN_SCAN_INTERVAL            = 25
-DEVICE_MAX_SCAN_INTERVAL            = 10240
-DEVICE_MIN_SCAN_WINDOW              = 25
-DEVICE_MAX_SCAN_WINDOW              = 10240
+DEVICE_MIN_SCAN_INTERVAL                      = 25
+DEVICE_MAX_SCAN_INTERVAL                      = 10240
+DEVICE_MIN_SCAN_WINDOW                        = 25
+DEVICE_MAX_SCAN_WINDOW                        = 10240
+DEVICE_MIN_LE_RSSI                            = -127
+DEVICE_MAX_LE_RSSI                            = 20
+
+DEVICE_DEFAULT_ADDRESS                        = '00:00:00:00:00:00'
+DEVICE_DEFAULT_ADVERTISING_INTERVAL           = 1000  # ms
+DEVICE_DEFAULT_ADVERTISING_DATA               = ''
+DEVICE_DEFAULT_NAME                           = 'Bumble'
+DEVICE_DEFAULT_INQUIRY_LENGTH                 = 8  # 10.24 seconds
+DEVICE_DEFAULT_CLASS_OF_DEVICE                = 0
+DEVICE_DEFAULT_SCAN_RESPONSE_DATA             = b''
+DEVICE_DEFAULT_DATA_LENGTH                    = (27, 328, 27, 328)
+DEVICE_DEFAULT_SCAN_INTERVAL                  = 60  # ms
+DEVICE_DEFAULT_SCAN_WINDOW                    = 60  # ms
+DEVICE_DEFAULT_CONNECT_TIMEOUT                = None  # No timeout
+DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL          = 60  # ms
+DEVICE_DEFAULT_CONNECT_SCAN_WINDOW            = 60  # ms
+DEVICE_DEFAULT_CONNECTION_INTERVAL_MIN        = 15  # ms
+DEVICE_DEFAULT_CONNECTION_INTERVAL_MAX        = 30  # ms
+DEVICE_DEFAULT_CONNECTION_MAX_LATENCY         = 0
+DEVICE_DEFAULT_CONNECTION_SUPERVISION_TIMEOUT = 720  # ms
+DEVICE_DEFAULT_CONNECTION_MIN_CE_LENGTH       = 0   # ms
+DEVICE_DEFAULT_CONNECTION_MAX_CE_LENGTH       = 0   # ms
+DEVICE_DEFAULT_L2CAP_COC_MTU                  = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU
+DEVICE_DEFAULT_L2CAP_COC_MPS                  = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
+DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS          = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS
+
 
 # -----------------------------------------------------------------------------
 # Classes
 # -----------------------------------------------------------------------------
 
+# -----------------------------------------------------------------------------
+class Advertisement:
+    TX_POWER_NOT_AVAILABLE = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
+    RSSI_NOT_AVAILABLE     = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+
+    @classmethod
+    def from_advertising_report(cls, report):
+        if isinstance(report, HCI_LE_Advertising_Report_Event.Report):
+            return LegacyAdvertisement.from_advertising_report(report)
+        elif isinstance(report, HCI_LE_Extended_Advertising_Report_Event.Report):
+            return ExtendedAdvertisement.from_advertising_report(report)
+
+    def __init__(
+        self,
+        address,
+        rssi             = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE,
+        is_legacy        = False,
+        is_anonymous     = False,
+        is_connectable   = False,
+        is_directed      = False,
+        is_scannable     = False,
+        is_scan_response = False,
+        is_complete      = True,
+        is_truncated     = False,
+        primary_phy      = 0,
+        secondary_phy    = 0,
+        tx_power         = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE,
+        sid              = 0,
+        data             = b''
+    ):
+        self.address          = address
+        self.rssi             = rssi
+        self.is_legacy        = is_legacy
+        self.is_anonymous     = is_anonymous
+        self.is_connectable   = is_connectable
+        self.is_directed      = is_directed
+        self.is_scannable     = is_scannable
+        self.is_scan_response = is_scan_response
+        self.is_complete      = is_complete
+        self.is_truncated     = is_truncated
+        self.primary_phy      = primary_phy
+        self.secondary_phy    = secondary_phy
+        self.tx_power         = tx_power
+        self.sid              = sid
+        self.data             = AdvertisingData.from_bytes(data)
+
+
+# -----------------------------------------------------------------------------
+class LegacyAdvertisement(Advertisement):
+    @classmethod
+    def from_advertising_report(cls, report):
+        return cls(
+            address          = report.address,
+            rssi             = report.rssi,
+            is_legacy        = True,
+            is_connectable   = report.event_type in {
+                HCI_LE_Advertising_Report_Event.ADV_IND,
+                HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND
+            },
+            is_directed      = report.event_type == HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND,
+            is_scannable     = report.event_type in {
+                HCI_LE_Advertising_Report_Event.ADV_IND,
+                HCI_LE_Advertising_Report_Event.ADV_SCAN_IND
+            },
+            is_scan_response = report.event_type == HCI_LE_Advertising_Report_Event.SCAN_RSP,
+            data             = report.data
+        )
+
+
+# -----------------------------------------------------------------------------
+class ExtendedAdvertisement(Advertisement):
+    @classmethod
+    def from_advertising_report(cls, report):
+        return cls(
+            address          = report.address,
+            rssi             = report.rssi,
+            is_legacy        = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.LEGACY_ADVERTISING_PDU_USED) != 0,
+            is_anonymous     = report.address.address_type == HCI_LE_Extended_Advertising_Report_Event.ANONYMOUS_ADDRESS_TYPE,
+            is_connectable   = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.CONNECTABLE_ADVERTISING) != 0,
+            is_directed      = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.DIRECTED_ADVERTISING) != 0,
+            is_scannable     = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.SCANNABLE_ADVERTISING) != 0,
+            is_scan_response = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.SCAN_RESPONSE) != 0,
+            is_complete      = (report.event_type >> 5 & 3)  == HCI_LE_Extended_Advertising_Report_Event.DATA_COMPLETE,
+            is_truncated     = (report.event_type >> 5 & 3)  == HCI_LE_Extended_Advertising_Report_Event.DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME,
+            primary_phy      = report.primary_phy,
+            secondary_phy    = report.secondary_phy,
+            tx_power         = report.tx_power,
+            sid              = report.advertising_sid,
+            data             = report.data
+        )
+
 
 # -----------------------------------------------------------------------------
 class AdvertisementDataAccumulator:
-    def __init__(self):
-        self.advertising_data = AdvertisingData()
-        self.last_advertisement_type = None
-        self.connectable = False
-        self.flushable = False
+    def __init__(self, passive=False):
+        self.passive            = passive
+        self.last_advertisement = None
+        self.last_data          = b''
 
-    def update(self, data, advertisement_type):
-        if advertisement_type == HCI_LE_Advertising_Report_Event.SCAN_RSP:
-            if self.last_advertisement_type != HCI_LE_Advertising_Report_Event.SCAN_RSP:
-                self.advertising_data.append(data)
-                self.flushable = True
+    def update(self, report):
+        advertisement = Advertisement.from_advertising_report(report)
+        result = None
+
+        if advertisement.is_scan_response:
+            if self.last_advertisement is not None and not self.last_advertisement.is_scan_response:
+                # This is the response to a scannable advertisement
+                result                = Advertisement.from_advertising_report(report)
+                result.is_connectable = self.last_advertisement.is_connectable
+                result.is_scannable   = True
+                result.data           = AdvertisingData.from_bytes(self.last_data + report.data)
+            self.last_data = b''
         else:
-            self.advertising_data = AdvertisingData.from_bytes(data)
-            self.flushable = self.last_advertisement_type != HCI_LE_Advertising_Report_Event.SCAN_RSP
+            if (
+                self.passive or
+                (not advertisement.is_scannable) or
+                (self.last_advertisement is not None and not self.last_advertisement.is_scan_response)
+            ):
+                # Don't wait for a scan response
+                result = Advertisement.from_advertising_report(report)
 
-        if advertisement_type == HCI_LE_Advertising_Report_Event.ADV_IND or advertisement_type == HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND:
-            self.connectable = True
-        elif advertisement_type == HCI_LE_Advertising_Report_Event.ADV_SCAN_IND or advertisement_type == HCI_LE_Advertising_Report_Event.ADV_NONCONN_IND:
-            self.connectable = False
+            self.last_data = report.data
 
-        self.last_advertisement_type = advertisement_type
+        self.last_advertisement = advertisement
+
+        return result
+
+
+# -----------------------------------------------------------------------------
+class AdvertisingType(IntEnum):
+    UNDIRECTED_CONNECTABLE_SCANNABLE = 0x00  # Undirected, connectable,     scannable
+    DIRECTED_CONNECTABLE_HIGH_DUTY   = 0x01  # Directed,   connectable,     non-scannable
+    UNDIRECTED_SCANNABLE             = 0x02  # Undirected, non-connectable, scannable
+    UNDIRECTED                       = 0x03  # Undirected, non-connectable, non-scannable
+    DIRECTED_CONNECTABLE_LOW_DUTY    = 0x04  # Directed,   connectable,     non-scannable
+
+    @property
+    def has_data(self):
+        return self in {
+            AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
+            AdvertisingType.UNDIRECTED_SCANNABLE,
+            AdvertisingType.UNDIRECTED
+        }
+
+    @property
+    def is_connectable(self):
+        return self in {
+            AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
+            AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
+            AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
+        }
+
+    @property
+    def is_scannable(self):
+        return self in {
+            AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
+            AdvertisingType.UNDIRECTED_SCANNABLE
+        }
+
+    @property
+    def is_directed(self):
+        return self in {
+            AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
+            AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
+        }
+
+
+# -----------------------------------------------------------------------------
+class LePhyOptions:
+    # Coded PHY preference
+    ANY_CODED_PHY        = 0
+    PREFER_S_2_CODED_PHY = 1
+    PREFER_S_8_CODED_PHY = 2
+
+    def __init__(self, coded_phy_preference=0):
+        self.coded_phy_preference = coded_phy_preference
+
+    def __int__(self):
+        return self.coded_phy_preference & 3
 
 
 # -----------------------------------------------------------------------------
@@ -100,7 +272,9 @@
         return self.gatt_client.services
 
     async def request_mtu(self, mtu):
-        return await self.gatt_client.request_mtu(mtu)
+        mtu = await self.gatt_client.request_mtu(mtu)
+        self.connection.emit('connection_att_mtu_update')
+        return mtu
 
     async def discover_service(self, uuid):
         return await self.gatt_client.discover_service(uuid)
@@ -120,8 +294,8 @@
     async def discover_attributes(self):
         return await self.gatt_client.discover_attributes()
 
-    async def subscribe(self, characteristic, subscriber=None):
-        return await self.gatt_client.subscribe(characteristic, subscriber)
+    async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
+        return await self.gatt_client.subscribe(characteristic, subscriber, prefer_notify)
 
     async def unsubscribe(self, characteristic, subscriber=None):
         return await self.gatt_client.unsubscribe(characteristic, subscriber)
@@ -169,12 +343,25 @@
     async def __aexit__(self, exc_type, exc_value, traceback):
         pass
 
-
     def __str__(self):
         return f'{self.connection.peer_address} as {self.connection.role_name}'
 
 
 # -----------------------------------------------------------------------------
+@dataclass
+class ConnectionParametersPreferences:
+    connection_interval_min: int = DEVICE_DEFAULT_CONNECTION_INTERVAL_MIN
+    connection_interval_max: int = DEVICE_DEFAULT_CONNECTION_INTERVAL_MAX
+    max_latency:             int = DEVICE_DEFAULT_CONNECTION_MAX_LATENCY
+    supervision_timeout:     int = DEVICE_DEFAULT_CONNECTION_SUPERVISION_TIMEOUT
+    min_ce_length:           int = DEVICE_DEFAULT_CONNECTION_MIN_CE_LENGTH
+    max_ce_length:           int = DEVICE_DEFAULT_CONNECTION_MAX_CE_LENGTH
+
+
+ConnectionParametersPreferences.default = ConnectionParametersPreferences()
+
+
+# -----------------------------------------------------------------------------
 class Connection(CompositeEventEmitter):
     @composite_listener
     class Listener:
@@ -202,11 +389,23 @@
         def on_connection_encryption_key_refresh(self):
             pass
 
-    def __init__(self, device, handle, transport, peer_address, peer_resolvable_address, role, parameters):
+    def __init__(
+        self,
+        device,
+        handle,
+        transport,
+        self_address,
+        peer_address,
+        peer_resolvable_address,
+        role,
+        parameters,
+        phy
+    ):
         super().__init__()
         self.device                  = device
         self.handle                  = handle
         self.transport               = transport
+        self.self_address            = self_address
         self.peer_address            = peer_address
         self.peer_resolvable_address = peer_resolvable_address
         self.peer_name               = None  # Classic only
@@ -214,7 +413,7 @@
         self.parameters              = parameters
         self.encryption              = 0
         self.authenticated           = False
-        self.phy                     = ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY)
+        self.phy                     = phy
         self.att_mtu                 = ATT_DEFAULT_MTU
         self.data_length             = DEVICE_DEFAULT_DATA_LENGTH
         self.gatt_client             = None  # Per-connection client
@@ -234,7 +433,16 @@
     def create_l2cap_connector(self, psm):
         return self.device.create_l2cap_connector(self, psm)
 
-    async def disconnect(self, reason = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR):
+    async def open_l2cap_channel(
+        self,
+        psm,
+        max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS,
+        mtu=DEVICE_DEFAULT_L2CAP_COC_MTU,
+        mps=DEVICE_DEFAULT_L2CAP_COC_MPS
+    ):
+        return await self.device.open_l2cap_channel(self, psm, max_credits, mtu, mps)
+
+    async def disconnect(self, reason=HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR):
         return await self.device.disconnect(self, reason)
 
     async def pair(self):
@@ -267,19 +475,28 @@
 
     async def update_parameters(
         self,
-        conn_interval_min,
-        conn_interval_max,
-        conn_latency,
+        connection_interval_min,
+        connection_interval_max,
+        max_latency,
         supervision_timeout
     ):
         return await self.device.update_connection_parameters(
             self,
-            conn_interval_min,
-            conn_interval_max,
-            conn_latency,
+            connection_interval_min,
+            connection_interval_max,
+            max_latency,
             supervision_timeout
         )
 
+    async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
+        return await self.device.set_connection_phy(self, tx_phys, rx_phys, phy_options)
+
+    async def get_rssi(self):
+        return await self.device.get_connection_rssi(self)
+
+    async def get_phy(self):
+        return await self.device.get_connection_phy(self)
+
     # [Classic only]
     async def request_remote_name(self):
         return await self.device.request_remote_name(self)
@@ -315,6 +532,7 @@
         self.le_simultaneous_enabled  = True
         self.classic_sc_enabled       = True
         self.classic_ssp_enabled      = True
+        self.classic_accept_any       = True
         self.connectable              = True
         self.discoverable             = True
         self.advertising_data = bytes(
@@ -335,6 +553,7 @@
         self.le_simultaneous_enabled  = config.get('le_simultaneous_enabled', self.le_simultaneous_enabled)
         self.classic_sc_enabled       = config.get('classic_sc_enabled', self.classic_sc_enabled)
         self.classic_ssp_enabled      = config.get('classic_ssp_enabled', self.classic_ssp_enabled)
+        self.classic_accept_any       = config.get('classic_accept_any', self.classic_accept_any)
         self.connectable              = config.get('connectable', self.connectable)
         self.discoverable             = config.get('discoverable', self.discoverable)
 
@@ -357,6 +576,7 @@
         with open(filename, 'r') as file:
             self.load_from_dict(json.load(file))
 
+
 # -----------------------------------------------------------------------------
 # Decorators used with the following Device class
 # (we define them outside of the Device class, because defining decorators
@@ -385,6 +605,17 @@
     return wrapper
 
 
+# Decorator that tries to convert the first argument from a bluetooth address to a connection
+def try_with_connection_from_address(function):
+    @functools.wraps(function)
+    def wrapper(self, address, *args, **kwargs):
+        for connection in self.connections.values():
+            if connection.peer_address == address:
+                return function(self, connection, address, *args, **kwargs)
+        return function(self, None, address, *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):
@@ -403,7 +634,7 @@
 
     @composite_listener
     class Listener:
-        def on_advertisement(self, address, data, rssi, advertisement_type):
+        def on_advertisement(self, advertisement):
             pass
 
         def on_inquiry_result(self, address, class_of_device, data, rssi):
@@ -415,6 +646,9 @@
         def on_connection_failure(self, error):
             pass
 
+        def on_connection_request(self, bd_addr, class_of_device, link_type):
+            pass
+
         def on_characteristic_subscription(self, connection, characteristic, notify_enabled, indicate_enabled):
             pass
 
@@ -443,24 +677,33 @@
     def __init__(self, name = None, address = None, config = None, host = None, generic_access_service = True):
         super().__init__()
 
-        self._host                    = None
-        self.powered_on               = False
-        self.advertising              = False
-        self.auto_restart_advertising = False
-        self.command_timeout          = 10  # seconds
-        self.gatt_server              = gatt_server.Server(self)
-        self.sdp_server               = sdp.Server(self)
-        self.l2cap_channel_manager    = l2cap.ChannelManager(
-            [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS])
-        self.advertisement_data       = {}
-        self.scanning                 = False
-        self.discovering              = False
-        self.connecting               = False
-        self.disconnecting            = False
-        self.connections              = {}  # Connections, by connection handle
-        self.classic_enabled          = False
-        self.inquiry_response         = None
-        self.address_resolver         = None
+        self._host                      = None
+        self.powered_on                 = False
+        self.advertising                = False
+        self.advertising_type           = None
+        self.auto_restart_inquiry       = True
+        self.auto_restart_advertising   = False
+        self.command_timeout            = 10  # seconds
+        self.gatt_server                = gatt_server.Server(self)
+        self.sdp_server                 = sdp.Server(self)
+        self.l2cap_channel_manager      = l2cap.ChannelManager(
+            [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]
+        )
+        self.advertisement_accumulators = {}  # Accumulators, by address
+        self.scanning                   = False
+        self.scanning_is_passive        = False
+        self.discovering                = False
+        self.le_connecting              = False
+        self.disconnecting              = False
+        self.connections                = {}  # Connections, by connection handle
+        self.classic_enabled            = False
+        self.inquiry_response           = None
+        self.address_resolver           = None
+        self.classic_pending_accepts    = {Address.ANY: []}  # Futures, by BD address OR [Futures] for Address.ANY
+
+        # Own address type cache
+        self.advertising_own_address_type = None
+        self.connect_own_address_type     = None
 
         # Use the initial config or a default
         self.public_address = Address('00:00:00:00:00:00')
@@ -481,6 +724,7 @@
         self.classic_sc_enabled       = config.classic_sc_enabled
         self.discoverable             = config.discoverable
         self.connectable              = config.connectable
+        self.classic_accept_any       = config.classic_accept_any
 
         # If a name is passed, override the name from the config
         if name:
@@ -493,8 +737,7 @@
             self.random_address = address
 
         # Setup SMP
-        # TODO: allow using a public address
-        self.smp_manager = smp.Manager(self, self.random_address)
+        self.smp_manager = smp.Manager(self)
         self.l2cap_channel_manager.register_fixed_channel(
             smp.SMP_CID, self.on_smp_pdu)
         self.l2cap_channel_manager.register_fixed_channel(
@@ -551,29 +794,55 @@
         if connection := self.connections.get(connection_handle):
             return connection
 
-    def find_connection_by_bd_addr(self, bd_addr, transport=None):
+    def find_connection_by_bd_addr(self, bd_addr, transport=None, check_address_type=False):
         for connection in self.connections.values():
             if connection.peer_address.get_bytes() == bd_addr.get_bytes():
+                if check_address_type and connection.peer_address.address_type != bd_addr.address_type:
+                    continue
                 if transport is None or connection.transport == transport:
                     return connection
 
-    def register_l2cap_server(self, psm, server):
-        self.l2cap_channel_manager.register_server(psm, server)
-
     def create_l2cap_connector(self, connection, psm):
         return lambda: self.l2cap_channel_manager.connect(connection, psm)
 
     def create_l2cap_registrar(self, psm):
         return lambda handler: self.register_l2cap_server(psm, handler)
 
+    def register_l2cap_server(self, psm, server):
+        self.l2cap_channel_manager.register_server(psm, server)
+
+    def register_l2cap_channel_server(
+        self,
+        psm,
+        server,
+        max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS,
+        mtu=DEVICE_DEFAULT_L2CAP_COC_MTU,
+        mps=DEVICE_DEFAULT_L2CAP_COC_MPS
+    ):
+        return self.l2cap_channel_manager.register_le_coc_server(psm, server, max_credits, mtu, mps)
+
+    async def open_l2cap_channel(
+        self,
+        connection,
+        psm,
+        max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS,
+        mtu=DEVICE_DEFAULT_L2CAP_COC_MTU,
+        mps=DEVICE_DEFAULT_L2CAP_COC_MPS
+    ):
+        return await self.l2cap_channel_manager.open_le_coc(connection, psm, max_credits, mtu, mps)
+
     def send_l2cap_pdu(self, connection_handle, cid, pdu):
         self.host.send_l2cap_pdu(connection_handle, cid, pdu)
 
-    async def send_command(self, command):
+    async def send_command(self, command, check_result=False):
         try:
-            return await asyncio.wait_for(self.host.send_command(command), self.command_timeout)
+            return await asyncio.wait_for(
+                self.host.send_command(command, check_result),
+                self.command_timeout
+            )
         except asyncio.TimeoutError:
             logger.warning('!!! Command timed out')
+            raise CommandTimeoutError()
 
     async def power_on(self):
         # Reset the controller
@@ -594,10 +863,10 @@
             # Set the controller address
             await self.send_command(HCI_LE_Set_Random_Address_Command(
                 random_address = self.random_address
-            ))
+            ), check_result=True)
 
             # Load the address resolving list
-            if self.keystore:
+            if self.keystore and self.host.supports_command(HCI_LE_CLEAR_RESOLVING_LIST_COMMAND):
                 await self.send_command(HCI_LE_Clear_Resolving_List_Command())
 
                 resolving_keys = await self.keystore.get_resolving_keys()
@@ -644,51 +913,89 @@
         # Done
         self.powered_on = True
 
-    async def start_advertising(self, auto_restart=False):
-        self.auto_restart_advertising = auto_restart
+    def supports_le_feature(self, feature):
+        return self.host.supports_le_feature(feature)
 
+    def supports_le_phy(self, phy):
+        if phy == HCI_LE_1M_PHY:
+            return True
+
+        feature_map = {
+            HCI_LE_2M_PHY:    HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE,
+            HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE
+        }
+        if phy not in feature_map:
+            raise ValueError('invalid PHY')
+
+        return self.host.supports_le_feature(feature_map[phy])
+
+    async def start_advertising(
+        self,
+        advertising_type=AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
+        target=None,
+        own_address_type=OwnAddressType.RANDOM,
+        auto_restart=False
+    ):
         # If we're advertising, stop first
         if self.advertising:
             await self.stop_advertising()
 
-        # Set/update the advertising data
-        await self.send_command(HCI_LE_Set_Advertising_Data_Command(
-            advertising_data = self.advertising_data
-        ))
+        # Set/update the advertising data if the advertising type allows it
+        if advertising_type.has_data:
+            await self.send_command(HCI_LE_Set_Advertising_Data_Command(
+                advertising_data = self.advertising_data
+            ), check_result=True)
 
-        # Set/update the scan response data
-        await self.send_command(HCI_LE_Set_Scan_Response_Data_Command(
-            scan_response_data = self.scan_response_data
-        ))
+        # Set/update the scan response data if the advertising is scannable
+        if advertising_type.is_scannable:
+            await self.send_command(HCI_LE_Set_Scan_Response_Data_Command(
+                scan_response_data = self.scan_response_data
+            ), check_result=True)
+
+        # Decide what peer address to use
+        if advertising_type.is_directed:
+            if target is None:
+                raise ValueError('directed advertising requires a target address')
+
+            peer_address      = target
+            peer_address_type = target.address_type
+        else:
+            peer_address      = Address('00:00:00:00:00:00')
+            peer_address_type = Address.PUBLIC_DEVICE_ADDRESS
 
         # Set the advertising parameters
         await self.send_command(HCI_LE_Set_Advertising_Parameters_Command(
-            # TODO: use real values, not fixed ones
             advertising_interval_min  = self.advertising_interval_min,
             advertising_interval_max  = self.advertising_interval_max,
-            advertising_type          = HCI_LE_Set_Advertising_Parameters_Command.ADV_IND,
-            own_address_type          = Address.RANDOM_DEVICE_ADDRESS,  # TODO: allow using the public address
-            peer_address_type         = Address.PUBLIC_DEVICE_ADDRESS,
-            peer_address              = Address('00:00:00:00:00:00'),
+            advertising_type          = int(advertising_type),
+            own_address_type          = own_address_type,
+            peer_address_type         = peer_address_type,
+            peer_address              = peer_address,
             advertising_channel_map   = 7,
             advertising_filter_policy = 0
-        ))
+        ), check_result=True)
 
         # Enable advertising
         await self.send_command(HCI_LE_Set_Advertising_Enable_Command(
             advertising_enable = 1
-        ))
+        ), check_result=True)
 
-        self.advertising = True
+        self.advertising_own_address_type = own_address_type
+        self.auto_restart_advertising     = auto_restart
+        self.advertising_type             = advertising_type
+        self.advertising                  = True
 
     async def stop_advertising(self):
         # Disable advertising
         if self.advertising:
             await self.send_command(HCI_LE_Set_Advertising_Enable_Command(
                 advertising_enable = 0
-            ))
+            ), check_result=True)
 
-            self.advertising = False
+            self.advertising_own_address_type = None
+            self.advertising                  = False
+            self.advertising_type             = None
+            self.auto_restart_advertising     = False
 
     @property
     def is_advertising(self):
@@ -696,11 +1003,13 @@
 
     async def start_scanning(
         self,
+        legacy=False,
         active=True,
         scan_interval=DEVICE_DEFAULT_SCAN_INTERVAL,  # Scan interval in ms
         scan_window=DEVICE_DEFAULT_SCAN_WINDOW,      # Scan window in ms
-        own_address_type=Address.RANDOM_DEVICE_ADDRESS,
-        filter_duplicates=False
+        own_address_type=OwnAddressType.RANDOM,
+        filter_duplicates=False,
+        scanning_phys=(HCI_LE_1M_PHY, HCI_LE_CODED_PHY)
     ):
         # Check that the arguments are legal
         if scan_interval < scan_window:
@@ -710,28 +1019,79 @@
         if scan_window < DEVICE_MIN_SCAN_WINDOW or scan_window > DEVICE_MAX_SCAN_WINDOW:
             raise ValueError('scan_interval out of range')
 
-        # Set the scanning parameters
-        scan_type = HCI_LE_Set_Scan_Parameters_Command.ACTIVE_SCANNING if active else HCI_LE_Set_Scan_Parameters_Command.PASSIVE_SCANNING
-        await self.send_command(HCI_LE_Set_Scan_Parameters_Command(
-            le_scan_type           = scan_type,
-            le_scan_interval       = int(scan_window / 0.625),
-            le_scan_window         = int(scan_window / 0.625),
-            own_address_type       = own_address_type,
-            scanning_filter_policy = HCI_LE_Set_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY
-        ))
+        # Reset the accumulators
+        self.advertisement_accumulator = {}
 
         # Enable scanning
-        await self.send_command(HCI_LE_Set_Scan_Enable_Command(
-            le_scan_enable    = 1,
-            filter_duplicates = 1 if filter_duplicates else 0
-        ))
-        self.scanning = True
+        if not legacy and self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE):
+            # Set the scanning parameters
+            scan_type = HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING if active else HCI_LE_Set_Extended_Scan_Parameters_Command.PASSIVE_SCANNING
+            scanning_filter_policy = HCI_LE_Set_Extended_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY  # TODO: support other types
+
+            scanning_phy_count = 0
+            scanning_phys_bits = 0
+            if HCI_LE_1M_PHY in scanning_phys:
+                scanning_phys_bits |= 1 << HCI_LE_1M_PHY_BIT
+                scanning_phy_count += 1
+            if HCI_LE_CODED_PHY in scanning_phys:
+                if self.supports_le_feature(HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE):
+                    scanning_phys_bits |= 1 << HCI_LE_CODED_PHY_BIT
+                    scanning_phy_count += 1
+
+            if scanning_phy_count == 0:
+                raise ValueError('at least one scanning PHY must be enabled')
+
+            await self.send_command(HCI_LE_Set_Extended_Scan_Parameters_Command(
+                own_address_type       = own_address_type,
+                scanning_filter_policy = scanning_filter_policy,
+                scanning_phys          = scanning_phys_bits,
+                scan_types             = [scan_type] * scanning_phy_count,
+                scan_intervals         = [int(scan_window / 0.625)] * scanning_phy_count,
+                scan_windows           = [int(scan_window / 0.625)] * scanning_phy_count
+            ), check_result=True)
+
+            # Enable scanning
+            await self.send_command(HCI_LE_Set_Extended_Scan_Enable_Command(
+                enable            = 1,
+                filter_duplicates = 1 if filter_duplicates else 0,
+                duration          = 0,  # TODO allow other values
+                period            = 0   # TODO allow other values
+            ), check_result=True)
+        else:
+            # Set the scanning parameters
+            scan_type = HCI_LE_Set_Scan_Parameters_Command.ACTIVE_SCANNING if active else HCI_LE_Set_Scan_Parameters_Command.PASSIVE_SCANNING
+            await self.send_command(HCI_LE_Set_Scan_Parameters_Command(
+                le_scan_type           = scan_type,
+                le_scan_interval       = int(scan_window / 0.625),
+                le_scan_window         = int(scan_window / 0.625),
+                own_address_type       = own_address_type,
+                scanning_filter_policy = HCI_LE_Set_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY
+            ), check_result=True)
+
+            # Enable scanning
+            await self.send_command(HCI_LE_Set_Scan_Enable_Command(
+                le_scan_enable    = 1,
+                filter_duplicates = 1 if filter_duplicates else 0
+            ), check_result=True)
+
+        self.scanning_is_passive = not active
+        self.scanning            = True
 
     async def stop_scanning(self):
-        await self.send_command(HCI_LE_Set_Scan_Enable_Command(
-            le_scan_enable    = 0,
-            filter_duplicates = 0
-        ))
+        # Disable scanning
+        if self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE):
+            await self.send_command(HCI_LE_Set_Extended_Scan_Enable_Command(
+                enable            = 0,
+                filter_duplicates = 0,
+                duration          = 0,
+                period            = 0
+            ), check_result=True)
+        else:
+            await self.send_command(HCI_LE_Set_Scan_Enable_Command(
+                le_scan_enable    = 0,
+                filter_duplicates = 0
+            ), check_result=True)
+
         self.scanning = False
 
     @property
@@ -739,22 +1099,17 @@
         return self.scanning
 
     @host_event_handler
-    def on_advertising_report(self, address, data, rssi, advertisement_type):
-        if not (accumulator := self.advertisement_data.get(address)):
-            accumulator = AdvertisementDataAccumulator()
-            self.advertisement_data[address] = accumulator
-        accumulator.update(data, advertisement_type)
-        if accumulator.flushable:
-            self.emit(
-                'advertisement',
-                address,
-                accumulator.advertising_data,
-                rssi,
-                accumulator.connectable
-            )
+    def on_advertising_report(self, report):
+        if not (accumulator := self.advertisement_accumulators.get(report.address)):
+            accumulator = AdvertisementDataAccumulator(passive=self.scanning_is_passive)
+            self.advertisement_accumulators[report.address] = accumulator
+        if advertisement := accumulator.update(report):
+            self.emit('advertisement', advertisement)
 
-    async def start_discovery(self):
-        await self.host.send_command(HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE))
+    async def start_discovery(self, auto_restart=True):
+        await self.send_command(HCI_Write_Inquiry_Mode_Command(
+            inquiry_mode=HCI_EXTENDED_INQUIRY_MODE
+        ), check_result=True)
 
         response = await self.send_command(HCI_Inquiry_Command(
             lap            = HCI_GENERAL_INQUIRY_LAP,
@@ -765,11 +1120,14 @@
             self.discovering = False
             raise HCI_StatusError(response)
 
-        self.discovering = True
+        self.auto_restart_inquiry = auto_restart
+        self.discovering          = True
 
     async def stop_discovery(self):
-        await self.send_command(HCI_Inquiry_Cancel_Command())
-        self.discovering = False
+        if self.discovering:
+            await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True)
+        self.auto_restart_inquiry = True
+        self.discovering          = False
 
     @host_event_handler
     def on_inquiry_result(self, address, class_of_device, data, rssi):
@@ -805,11 +1163,12 @@
                 )
 
             # Update the controller
-            await self.host.send_command(
+            await self.send_command(
                 HCI_Write_Extended_Inquiry_Response_Command(
                     fec_required              = 0,
                     extended_inquiry_response = self.inquiry_response
-                )
+                ),
+                check_result=True
             )
             await self.set_scan_enable(
                 inquiry_scan_enabled = self.discoverable,
@@ -824,12 +1183,29 @@
                 page_scan_enabled    = self.connectable
             )
 
-    async def connect(self, peer_address, transport=BT_LE_TRANSPORT):
+    async def connect(
+        self,
+        peer_address,
+        transport=BT_LE_TRANSPORT,
+        connection_parameters_preferences=None,
+        own_address_type=OwnAddressType.RANDOM,
+        timeout=DEVICE_DEFAULT_CONNECT_TIMEOUT
+    ):
         '''
         Request a connection to a peer.
-        This method cannot be called if there is already a pending connection.
+        When 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 all PHYs with default parameters
+          * map: each entry has a PHY as key and a ConnectionParametersPreferences object as value
+
+        own_address_type: (BLE only)
         '''
 
+        # Check parameters
+        if transport not in {BT_LE_TRANSPORT, BT_BR_EDR_TRANSPORT}:
+            raise ValueError('invalid transport')
+
         # Adjust the transport automatically if we need to
         if transport == BT_LE_TRANSPORT and not self.le_enabled:
             transport = BT_BR_EDR_TRANSPORT
@@ -837,62 +1213,235 @@
             transport = BT_LE_TRANSPORT
 
         # Check that there isn't already a pending connection
-        if self.is_connecting:
+        if transport == BT_LE_TRANSPORT and self.is_le_connecting:
             raise InvalidStateError('connection already pending')
 
         if type(peer_address) is str:
             try:
+                peer_address = Address.from_string_for_transport(peer_address, transport)
+            except ValueError:
+                # 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(peer_address, transport)  # TODO: timeout
+        else:
+            # All BR/EDR addresses should be public addresses
+            if transport == BT_BR_EDR_TRANSPORT and peer_address.address_type != Address.PUBLIC_DEVICE_ADDRESS:
+                raise ValueError('BR/EDR addresses must be PUBLIC')
+
+        def on_connection(connection):
+            if transport == BT_LE_TRANSPORT or (
+                # match BR/EDR connection event against peer address
+                connection.transport == transport and connection.peer_address == peer_address
+            ):
+                pending_connection.set_result(connection)
+
+        def on_connection_failure(error):
+            if transport == BT_LE_TRANSPORT or (
+                # match BR/EDR connection failure event against peer address
+                error.transport == transport and error.peer_address == peer_address
+            ):
+                pending_connection.set_exception(error)
+
+        # Create a future so that we can wait for the connection's result
+        pending_connection = asyncio.get_running_loop().create_future()
+        self.on('connection', on_connection)
+        self.on('connection_failure', on_connection_failure)
+
+        try:
+            # Tell the controller to connect
+            if transport == BT_LE_TRANSPORT:
+                if connection_parameters_preferences is None:
+                    if connection_parameters_preferences is None:
+                        connection_parameters_preferences = {
+                            HCI_LE_1M_PHY:    ConnectionParametersPreferences.default,
+                            HCI_LE_2M_PHY:    ConnectionParametersPreferences.default,
+                            HCI_LE_CODED_PHY: ConnectionParametersPreferences.default
+                        }
+
+                self.connect_own_address_type = own_address_type
+
+                if self.host.supports_command(HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND):
+                    # Only keep supported PHYs
+                    phys = sorted(list(set(filter(self.supports_le_phy, connection_parameters_preferences.keys()))))
+                    if not phys:
+                        raise ValueError('least one supported PHY needed')
+
+                    phy_count = len(phys)
+                    initiating_phys = phy_list_to_bits(phys)
+
+                    connection_interval_mins = [
+                        int(connection_parameters_preferences[phy].connection_interval_min / 1.25) for phy in phys
+                    ]
+                    connection_interval_maxs = [
+                        int(connection_parameters_preferences[phy].connection_interval_max / 1.25) for phy in phys
+                    ]
+                    max_latencies = [
+                        connection_parameters_preferences[phy].max_latency for phy in phys
+                    ]
+                    supervision_timeouts = [
+                        int(connection_parameters_preferences[phy].supervision_timeout / 10) for phy in phys
+                    ]
+                    min_ce_lengths = [
+                        int(connection_parameters_preferences[phy].min_ce_length / 0.625) for phy in phys
+                    ]
+                    max_ce_lengths = [
+                        int(connection_parameters_preferences[phy].max_ce_length / 0.625) for phy in phys
+                    ]
+
+                    result = await self.send_command(HCI_LE_Extended_Create_Connection_Command(
+                        initiator_filter_policy  = 0,
+                        own_address_type         = own_address_type,
+                        peer_address_type        = peer_address.address_type,
+                        peer_address             = peer_address,
+                        initiating_phys          = initiating_phys,
+                        scan_intervals           = (int(DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL / 0.625),) * phy_count,
+                        scan_windows             = (int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625),) * phy_count,
+                        connection_interval_mins = connection_interval_mins,
+                        connection_interval_maxs = connection_interval_maxs,
+                        max_latencies            = max_latencies,
+                        supervision_timeouts     = supervision_timeouts,
+                        min_ce_lengths           = min_ce_lengths,
+                        max_ce_lengths           = max_ce_lengths
+                    ))
+                else:
+                    if HCI_LE_1M_PHY not in connection_parameters_preferences:
+                        raise ValueError('1M PHY preferences required')
+
+                    prefs = connection_parameters_preferences[HCI_LE_1M_PHY]
+                    result = await self.send_command(HCI_LE_Create_Connection_Command(
+                        le_scan_interval        = int(DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL / 0.625),
+                        le_scan_window          = int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625),
+                        initiator_filter_policy = 0,
+                        peer_address_type       = peer_address.address_type,
+                        peer_address            = peer_address,
+                        own_address_type        = own_address_type,
+                        connection_interval_min = int(prefs.connection_interval_min / 1.25),
+                        connection_interval_max = int(prefs.connection_interval_max / 1.25),
+                        max_latency             = prefs.max_latency,
+                        supervision_timeout     = int(prefs.supervision_timeout / 10),
+                        min_ce_length           = int(prefs.min_ce_length / 0.625),
+                        max_ce_length           = int(prefs.max_ce_length / 0.625),
+                    ))
+            else:
+                # TODO: allow passing other settings
+                result = await self.send_command(HCI_Create_Connection_Command(
+                    bd_addr                   = peer_address,
+                    packet_type               = 0xCC18,  # FIXME: change
+                    page_scan_repetition_mode = HCI_R2_PAGE_SCAN_REPETITION_MODE,
+                    clock_offset              = 0x0000,
+                    allow_role_switch         = 0x01,
+                    reserved                  = 0
+                ))
+
+            if result.status != HCI_Command_Status_Event.PENDING:
+                raise HCI_StatusError(result)
+
+            # Wait for the connection process to complete
+            if transport == BT_LE_TRANSPORT:
+                self.le_connecting = True
+            if timeout is None:
+                return await pending_connection
+            else:
+                try:
+                    return await asyncio.wait_for(asyncio.shield(pending_connection), timeout)
+                except asyncio.TimeoutError:
+                    if transport == BT_LE_TRANSPORT:
+                        await self.send_command(HCI_LE_Create_Connection_Cancel_Command())
+                    else:
+                        await self.send_command(HCI_Create_Connection_Cancel_Command(bd_addr=peer_address))
+
+                    try:
+                        return await pending_connection
+                    except ConnectionError:
+                        raise TimeoutError()
+        finally:
+            self.remove_listener('connection', on_connection)
+            self.remove_listener('connection_failure', on_connection_failure)
+            if transport == BT_LE_TRANSPORT:
+                self.le_connecting = False
+                self.connect_own_address_type = None
+
+    async def accept(
+        self,
+        peer_address=Address.ANY,
+        role=BT_PERIPHERAL_ROLE,
+        timeout=DEVICE_DEFAULT_CONNECT_TIMEOUT
+    ):
+        '''
+        Wait and accept any incoming connection or a connection from `peer_address` when set.
+
+        Notes:
+          * A `connect` to the same peer will also complete this call.
+          * The `timeout` parameter is only handled while waiting for the connection request,
+            once received and accepeted, the controller shall issue a connection complete event.
+        '''
+
+        if type(peer_address) is str:
+            try:
                 peer_address = Address(peer_address)
             except ValueError:
                 # 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(peer_address, transport)
+                peer_address = await self.find_peer_by_name(peer_address, BT_BR_EDR_TRANSPORT)  # TODO: timeout
+
+        if peer_address == Address.NIL:
+            raise ValueError('accept on nil address')
+
+        # Create a future so that we can wait for the request
+        pending_request = asyncio.get_running_loop().create_future()
+
+        if peer_address == Address.ANY:
+            self.classic_pending_accepts[Address.ANY].append(pending_request)
+        elif peer_address in self.classic_pending_accepts:
+            raise InvalidStateError('accept connection already pending')
+        else:
+            self.classic_pending_accepts[peer_address] = pending_request
+
+        try:
+            # Wait for a request or a completed connection
+            result = await (asyncio.wait_for(pending_request, timeout) if timeout else pending_request)
+        except Exception:
+            # Remove future from device context
+            if peer_address == Address.ANY:
+                self.classic_pending_accepts[Address.ANY].remove(pending_request)
+            else:
+                self.classic_pending_accepts.pop(peer_address)
+            raise
+
+        # Result may already be a completed connection,
+        # see `on_connection` for details
+        if isinstance(result, Connection):
+            return result
+
+        # Otherwise, result came from `on_connection_request`
+        peer_address, class_of_device, link_type = result
+
+        def on_connection(connection):
+            if connection.transport == BT_BR_EDR_TRANSPORT and connection.peer_address == peer_address:
+                pending_connection.set_result(connection)
+
+        def on_connection_failure(error):
+            if error.transport == BT_BR_EDR_TRANSPORT and error.peer_address == peer_address:
+                pending_connection.set_exception(error)
 
         # Create a future so that we can wait for the connection's result
         pending_connection = asyncio.get_running_loop().create_future()
-        self.on('connection', pending_connection.set_result)
-        self.on('connection_failure', pending_connection.set_exception)
-
-        # Tell the controller to connect
-        if transport == BT_LE_TRANSPORT:
-            # TODO: use real values, not fixed ones
-            result = await self.send_command(HCI_LE_Create_Connection_Command(
-                le_scan_interval        = 96,
-                le_scan_window          = 96,
-                initiator_filter_policy = 0,
-                peer_address_type       = peer_address.address_type,
-                peer_address            = peer_address,
-                own_address_type        = Address.RANDOM_DEVICE_ADDRESS,
-                conn_interval_min       = 12,
-                conn_interval_max       = 24,
-                conn_latency            = 0,
-                supervision_timeout     = 72,
-                minimum_ce_length       = 0,
-                maximum_ce_length       = 0
-            ))
-        else:
-            # TODO: use real values, not fixed ones
-            result = await self.send_command(HCI_Create_Connection_Command(
-                bd_addr                   = peer_address,
-                packet_type               = 0xCC18,  # FIXME: change
-                page_scan_repetition_mode = HCI_R2_PAGE_SCAN_REPETITION_MODE,
-                clock_offset              = 0x0000,
-                allow_role_switch         = 0x01,
-                reserved                  = 0
-            ))
+        self.on('connection', on_connection)
+        self.on('connection_failure', on_connection_failure)
 
         try:
-            if result.status != HCI_Command_Status_Event.PENDING:
-                raise HCI_StatusError(result)
+            # Accept connection request
+            await self.send_command(HCI_Accept_Connection_Request_Command(
+                bd_addr = peer_address,
+                role    = role
+            ))
 
-            # Wait for the connection process to complete
-            self.connecting = True
+            # Wait for connection complete
             return await pending_connection
 
         finally:
-            self.remove_listener('connection', pending_connection.set_result)
-            self.remove_listener('connection_failure', pending_connection.set_exception)
-            self.connecting = False
+            self.remove_listener('connection', on_connection)
+            self.remove_listener('connection_failure', on_connection_failure)
 
     @asynccontextmanager
     async def connect_as_gatt(self, peer_address):
@@ -903,17 +1452,32 @@
             yield peer
 
     @property
-    def is_connecting(self):
-        return self.connecting
+    def is_le_connecting(self):
+        return self.le_connecting
 
     @property
     def is_disconnecting(self):
         return self.disconnecting
 
-    async def cancel_connection(self):
-        if not self.is_connecting:
-            return
-        await self.send_command(HCI_LE_Create_Connection_Cancel_Command())
+    async def cancel_connection(self, peer_address=None):
+        # Low-energy: cancel ongoing connection
+        if peer_address is None:
+            if not self.is_le_connecting:
+                return
+            await self.send_command(HCI_LE_Create_Connection_Cancel_Command(), check_result=True)
+
+        # BR/EDR: try to cancel to ongoing connection
+        # NOTE: This API does not prevent from trying to cancel a connection which is not currently being created
+        else:
+            if type(peer_address) is str:
+                try:
+                    peer_address = Address(peer_address)
+                except ValueError:
+                    # 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(peer_address, BT_BR_EDR_TRANSPORT)  # TODO: timeout
+
+            await self.send_command(HCI_Create_Connection_Cancel_Command(bd_addr=peer_address), check_result=True)
 
     async def disconnect(self, connection, reason):
         # Create a future so that we can wait for the disconnection's result
@@ -922,7 +1486,9 @@
         connection.on('disconnection_failure', pending_disconnection.set_exception)
 
         # Request a disconnection
-        result = await self.send_command(HCI_Disconnect_Command(connection_handle = connection.handle, reason = reason))
+        result = await self.send_command(HCI_Disconnect_Command(
+            connection_handle = connection.handle, reason = reason
+        ))
 
         try:
             if result.status != HCI_Command_Status_Event.PENDING:
@@ -939,26 +1505,66 @@
     async def update_connection_parameters(
         self,
         connection,
-        conn_interval_min,
-        conn_interval_max,
-        conn_latency,
+        connection_interval_min,
+        connection_interval_max,
+        max_latency,
         supervision_timeout,
-        minimum_ce_length = 0,
-        maximum_ce_length = 0
+        min_ce_length = 0,
+        max_ce_length = 0
     ):
         '''
         NOTE: the name of the parameters may look odd, but it just follows the names used in the Bluetooth spec.
         '''
         await self.send_command(HCI_LE_Connection_Update_Command(
-            connection_handle   = connection.handle,
-            conn_interval_min   = conn_interval_min,
-            conn_interval_max   = conn_interval_max,
-            conn_latency        = conn_latency,
-            supervision_timeout = supervision_timeout,
-            minimum_ce_length   = minimum_ce_length,
-            maximum_ce_length   = maximum_ce_length
-        ))
-        # TODO: check result
+            connection_handle       = connection.handle,
+            connection_interval_min = connection_interval_min,
+            connection_interval_max = connection_interval_max,
+            max_latency             = max_latency,
+            supervision_timeout     = supervision_timeout,
+            min_ce_length           = min_ce_length,
+            max_ce_length           = max_ce_length
+        ), check_result=True)
+
+    async def get_connection_rssi(self, connection):
+        result = await self.send_command(HCI_Read_RSSI_Command(handle = connection.handle), check_result=True)
+        return result.return_parameters.rssi
+
+    async def get_connection_phy(self, connection):
+        result = await self.send_command(
+            HCI_LE_Read_PHY_Command(connection_handle = connection.handle),
+            check_result=True
+        )
+        return (result.return_parameters.tx_phy, result.return_parameters.rx_phy)
+
+    async def set_connection_phy(
+        self,
+        connection,
+        tx_phys=None,
+        rx_phys=None,
+        phy_options=None
+    ):
+        all_phys_bits = (1 if tx_phys is None else 0) | ((1 if rx_phys is None else 0) << 1)
+
+        return await self.send_command(
+            HCI_LE_Set_PHY_Command(
+                connection_handle = connection.handle,
+                all_phys          = all_phys_bits,
+                tx_phys           = phy_list_to_bits(tx_phys),
+                rx_phys           = phy_list_to_bits(rx_phys),
+                phy_options       = 0 if phy_options is None else int(phy_options)
+            ), check_result=True
+        )
+
+    async def set_default_phy(self, tx_phys=None, rx_phys=None):
+        all_phys_bits = (1 if tx_phys is None else 0) | ((1 if rx_phys is None else 0) << 1)
+
+        return await self.send_command(
+            HCI_LE_Set_Default_PHY_Command(
+                all_phys = all_phys_bits,
+                tx_phys  = phy_list_to_bits(tx_phys),
+                rx_phys  = phy_list_to_bits(rx_phys)
+            ), check_result=True
+        )
 
     async def find_peer_by_name(self, name, transport=BT_LE_TRANSPORT):
         """
@@ -970,9 +1576,9 @@
 
         # 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)
+            local_name = ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True)
             if local_name is None:
-                local_name = ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME)
+                local_name = ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME, raw=True)
             if local_name is not None:
                 if local_name.decode('utf-8') == name:
                     peer_address.set_result(address)
@@ -982,8 +1588,7 @@
                 event_name = 'advertisement'
                 handler = self.on(
                     event_name,
-                    lambda address, ad_data, rssi, connectable:
-                        on_peer_found(address, ad_data)
+                    lambda advertisement: on_peer_found(advertisement.address, advertisement.data)
                 )
 
                 was_scanning = self.scanning
@@ -1155,23 +1760,37 @@
             connection.remove_listener('connection_encryption_failure', on_encryption_failure)
 
     # [Classic only]
-    async def request_remote_name(self, connection):
+    async def request_remote_name(self, remote):  # remote: Connection | Address
         # Set up event handlers
         pending_name = asyncio.get_running_loop().create_future()
 
-        def on_remote_name():
-            pending_name.set_result(connection.peer_name)
-
-        def on_remote_name_failure(error_code):
-            pending_name.set_exception(HCI_Error(error_code))
-
-        connection.on('remote_name', on_remote_name)
-        connection.on('remote_name_failure', on_remote_name_failure)
+        if type(remote) == Address:
+            peer_address = remote
+            handler = self.on(
+                'remote_name',
+                lambda address, remote_name:
+                    pending_name.set_result(remote_name) if address == remote else None
+            )
+            failure_handler = self.on(
+                'remote_name_failure',
+                lambda address, error_code:
+                    pending_name.set_exception(HCI_Error(error_code)) if address == remote else None
+            )
+        else:
+            peer_address = remote.peer_address
+            handler = remote.on(
+                'remote_name',
+                lambda: pending_name.set_result(remote.peer_name)
+            )
+            failure_handler = remote.on(
+                'remote_name_failure',
+                lambda error_code: pending_name.set_exception(HCI_Error(error_code))
+            )
 
         try:
             result = await self.send_command(
                 HCI_Remote_Name_Request_Command(
-                    bd_addr                   = connection.peer_address,
+                    bd_addr                   = peer_address,
                     page_scan_repetition_mode = HCI_Remote_Name_Request_Command.R0,  # TODO investigate other options
                     reserved                  = 0,
                     clock_offset              = 0  # TODO investigate non-0 values
@@ -1185,8 +1804,12 @@
             # Wait for the result
             return await pending_name
         finally:
-            connection.remove_listener('remote_name', on_remote_name)
-            connection.remove_listener('remote_name_failure', on_remote_name_failure)
+            if type(remote) == Address:
+                self.remove_listener('remote_name', handler)
+                self.remove_listener('remote_name_failure', failure_handler)
+            else:
+                remote.remove_listener('remote_name', handler)
+                remote.remove_listener('remote_name_failure', failure_handler)
 
     # [Classic only]
     @host_event_handler
@@ -1210,17 +1833,17 @@
     def add_services(self, services):
         self.gatt_server.add_services(services)
 
-    async def notify_subscriber(self, connection, attribute, force=False):
-        await self.gatt_server.notify_subscriber(connection, attribute, force)
+    async def notify_subscriber(self, connection, attribute, value=None, force=False):
+        await self.gatt_server.notify_subscriber(connection, attribute, value, force)
 
-    async def notify_subscribers(self, attribute, force=False):
-        await self.gatt_server.notify_subscribers(attribute, force)
+    async def notify_subscribers(self, attribute, value=None, force=False):
+        await self.gatt_server.notify_subscribers(attribute, value, force)
 
-    async def indicate_subscriber(self, connection, attribute, force=False):
-        await self.gatt_server.indicate_subscriber(connection, attribute, force)
+    async def indicate_subscriber(self, connection, attribute, value=None, force=False):
+        await self.gatt_server.indicate_subscriber(connection, attribute, value, force)
 
-    async def indicate_subscribers(self, attribute):
-        await self.gatt_server.indicate_subscribers(attribute)
+    async def indicate_subscribers(self, attribute, value=None, force=False):
+        await self.gatt_server.indicate_subscribers(attribute, value, force)
 
     @host_event_handler
     def on_connection(self, connection_handle, transport, peer_address, peer_resolvable_address, role, connection_parameters):
@@ -1228,43 +1851,140 @@
         if connection_handle in self.connections:
             logger.warn('new connection reuses the same handle as a previous connection')
 
-        # 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
+        if transport == BT_BR_EDR_TRANSPORT:
+            # Create a new connection
+            connection = Connection(
+                self,
+                connection_handle,
+                transport,
+                self.public_address,
+                peer_address,
+                peer_resolvable_address,
+                role,
+                connection_parameters,
+                phy=None
+            )
+            self.connections[connection_handle] = connection
 
-        # Create a new connection
-        connection = Connection(
-            self,
-            connection_handle,
-            transport,
-            peer_address,
-            peer_resolvable_address,
-            role,
-            connection_parameters
-        )
-        self.connections[connection_handle] = connection
+            # We may have an accept ongoing waiting for a connection request for `peer_address`.
+            # Typicaly happen when using `connect` to the same `peer_address` we are waiting with
+            # an `accept` for.
+            # In this case, set the completed `connection` to the `accept` future result.
+            if peer_address in self.classic_pending_accepts:
+                future = self.classic_pending_accepts.pop(peer_address)
+                future.set_result(connection)
 
-        # We are no longer advertising
-        self.advertising = False
+            # Emit an event to notify listeners of the new connection
+            self.emit('connection', connection)
+        else:
+            # Resolve the peer address if we can
+            if self.address_resolver:
+                if peer_address.is_resolvable:
+                    resolved_address = self.address_resolver.resolve(peer_address)
+                    if resolved_address is not None:
+                        logger.debug(f'*** Address resolved as {resolved_address}')
+                        peer_resolvable_address = peer_address
+                        peer_address = resolved_address
 
-        # Emit an event to notify listeners of the new connection
-        self.emit('connection', connection)
+            # Guess which own address type is used for this connection.
+            # This logic is somewhat correct but may need to be improved
+            # when multiple advertising are run simultaneously.
+            if self.connect_own_address_type is not None:
+                own_address_type = self.connect_own_address_type
+            else:
+                own_address_type = self.advertising_own_address_type
+
+            # We are no longer advertising
+            self.advertising_own_address_type = None
+            self.advertising                  = False
+
+            # Create and notify of the new connection asynchronously
+            async def new_connection():
+                # Figure out which PHY we're connected with
+                if self.host.supports_command(HCI_LE_READ_PHY_COMMAND):
+                    result = await self.send_command(
+                        HCI_LE_Read_PHY_Command(connection_handle=connection_handle),
+                        check_result=True
+                    )
+                    phy = ConnectionPHY(result.return_parameters.tx_phy, result.return_parameters.rx_phy)
+                else:
+                    phy = ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY)
+
+                self_address = self.random_address
+                if own_address_type in (OwnAddressType.PUBLIC, OwnAddressType.RESOLVABLE_OR_PUBLIC):
+                     self_address = self.public_address
+
+                # Create a new connection
+                connection = Connection(
+                    self,
+                    connection_handle,
+                    transport,
+                    self_address,
+                    peer_address,
+                    peer_resolvable_address,
+                    role,
+                    connection_parameters,
+                    phy
+                )
+                self.connections[connection_handle] = connection
+
+                # Emit an event to notify listeners of the new connection
+                self.emit('connection', connection)
+
+            asyncio.create_task(new_connection())
 
     @host_event_handler
-    def on_connection_failure(self, error_code):
-        logger.debug(f'*** Connection failed: {error_code}')
+    def on_connection_failure(self, transport, peer_address, error_code):
+        logger.debug(f'*** Connection failed: {HCI_Constant.error_name(error_code)}')
+
+        # For directed advertising, this means a timeout
+        if transport == BT_LE_TRANSPORT and self.advertising and self.advertising_type.is_directed:
+            self.advertising_own_address_type = None
+            self.advertising                  = False
+
+        # Notify listeners
         error = ConnectionError(
             error_code,
+            transport,
+            peer_address,
             'hci',
             HCI_Constant.error_name(error_code)
         )
         self.emit('connection_failure', error)
 
+    # FIXME: Explore a delegate-model for BR/EDR wait connection #56.
+    @host_event_handler
+    def on_connection_request(self, bd_addr, class_of_device, link_type):
+        logger.debug(f'*** Connection request: {bd_addr}')
+
+        # match a pending future using `bd_addr`
+        if bd_addr in self.classic_pending_accepts:
+            future = self.classic_pending_accepts.pop(bd_addr)
+            future.set_result((bd_addr, class_of_device, link_type))
+
+        # match first pending future for ANY address
+        elif len(self.classic_pending_accepts[Address.ANY]) > 0:
+            future = self.classic_pending_accepts[Address.ANY].pop(0)
+            future.set_result((bd_addr, class_of_device, link_type))
+
+        # device configuration is set to accept any incoming connection
+        elif self.classic_accept_any:
+            self.host.send_command_sync(
+                HCI_Accept_Connection_Request_Command(
+                    bd_addr = bd_addr,
+                    role    = 0x01  # Remain the peripheral
+                )
+            )
+
+        # reject incoming connection
+        else:
+            self.host.send_command_sync(
+                HCI_Reject_Connection_Request_Command(
+                    bd_addr = bd_addr,
+                    reason  = HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR
+                )
+            )
+
     @host_event_handler
     @with_connection_from_handle
     def on_disconnection(self, connection, reason):
@@ -1280,7 +2000,10 @@
         # Restart advertising if auto-restart is enabled
         if self.auto_restart_advertising:
             logger.debug('restarting advertising')
-            asyncio.create_task(self.start_advertising(auto_restart=self.auto_restart_advertising))
+            asyncio.create_task(self.start_advertising(
+                advertising_type = self.advertising_type,
+                auto_restart     = True
+            ))
 
     @host_event_handler
     @with_connection_from_handle
@@ -1288,6 +2011,8 @@
         logger.debug(f'*** Disconnection failed: {error_code}')
         error = ConnectionError(
             error_code,
+            connection.transport,
+            connection.peer_address,
             'hci',
             HCI_Constant.error_name(error_code)
         )
@@ -1296,9 +2021,13 @@
     @host_event_handler
     @AsyncRunner.run_in_task()
     async def on_inquiry_complete(self):
-        if self.discovering:
+        if self.auto_restart_inquiry:
             # Inquire again
-            await self.start_discovery()
+            await self.start_discovery(auto_restart=True)
+        else:
+            self.auto_restart_inquiry = True
+            self.discovering          = False
+            self.emit('inquiry_complete')
 
     @host_event_handler
     @with_connection_from_handle
@@ -1364,13 +2093,13 @@
         # Ask what the pairing config should be for this connection
         pairing_config = self.pairing_config_factory(connection)
 
-        can_confirm = pairing_config.delegate.io_capability not in {
+        can_compare = pairing_config.delegate.io_capability not in {
             smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
             smp.SMP_DISPLAY_ONLY_IO_CAPABILITY
         }
 
         # Respond
-        if can_confirm and pairing_config.delegate:
+        if can_compare:
             async def compare_numbers():
                 numbers_match = await pairing_config.delegate.compare_numbers(code, digits=6)
                 if numbers_match:
@@ -1384,9 +2113,18 @@
 
             asyncio.create_task(compare_numbers())
         else:
-            self.host.send_command_sync(
-                HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
-            )
+            async def confirm():
+                confirm = await pairing_config.delegate.confirm()
+                if confirm:
+                    self.host.send_command_sync(
+                        HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
+                    )
+                else:
+                    self.host.send_command_sync(
+                        HCI_User_Confirmation_Request_Negative_Reply_Command(bd_addr=connection.peer_address)
+                    )
+
+            asyncio.create_task(confirm())
 
     # [Classic only]
     @host_event_handler
@@ -1401,7 +2139,7 @@
         }
 
         # Respond
-        if can_input and pairing_config.delegate:
+        if can_input:
             async def get_number():
                 number = await pairing_config.delegate.get_number()
                 if number is not None:
@@ -1424,20 +2162,39 @@
     # [Classic only]
     @host_event_handler
     @with_connection_from_address
-    def on_remote_name(self, connection, remote_name):
-        # Try to decode the name
-        try:
-            connection.peer_name = remote_name.decode('utf-8')
-            connection.emit('remote_name')
-        except UnicodeDecodeError as error:
-            logger.warning('peer name is not valid UTF-8')
-            connection.emit('remote_name_failure', error)
+    def on_authentication_user_passkey_notification(self, connection, passkey):
+        # Ask what the pairing config should be for this connection
+        pairing_config = self.pairing_config_factory(connection)
+
+        asyncio.create_task(pairing_config.delegate.display_number(passkey))
 
     # [Classic only]
     @host_event_handler
-    @with_connection_from_address
-    def on_remote_name_failure(self, connection, error):
-        connection.emit('remote_name_failure', error)
+    @try_with_connection_from_address
+    def on_remote_name(self, connection, address, remote_name):
+        # Try to decode the name
+        try:
+            remote_name = remote_name.decode('utf-8')
+            if connection:
+                connection.peer_name = remote_name
+                connection.emit('remote_name')
+            else:
+                self.emit('remote_name', address, remote_name)
+        except UnicodeDecodeError as error:
+            logger.warning('peer name is not valid UTF-8')
+            if connection:
+                connection.emit('remote_name_failure', error)
+            else:
+                self.emit('remote_name_failure', address, error)
+
+    # [Classic only]
+    @host_event_handler
+    @try_with_connection_from_address
+    def on_remote_name_failure(self, connection, address, error):
+        if connection:
+            connection.emit('remote_name_failure', error)
+        else:
+            self.emit('remote_name_failure', address, error)
 
     @host_event_handler
     @with_connection_from_handle
diff --git a/bumble/gatt.py b/bumble/gatt.py
index 90867cd..889eaa4 100644
--- a/bumble/gatt.py
+++ b/bumble/gatt.py
@@ -23,8 +23,10 @@
 # Imports
 # -----------------------------------------------------------------------------
 import asyncio
+import enum
 import types
 import logging
+from pyee import EventEmitter
 from colors import color
 
 from .core import *
@@ -262,7 +264,7 @@
 
     def get_descriptor(self, descriptor_type):
         for descriptor in self.descriptors:
-            if descriptor.uuid == descriptor_type:
+            if descriptor.type == descriptor_type:
                 return descriptor
 
     def __str__(self):
@@ -303,6 +305,7 @@
     '''
     def __init__(self, characteristic):
         self.wrapped_characteristic = characteristic
+        self.subscribers = {}  # Map from subscriber to proxy subscriber
 
         if (
             asyncio.iscoroutinefunction(characteristic.read_value) and
@@ -317,11 +320,21 @@
         if hasattr(self.wrapped_characteristic, 'subscribe'):
             self.subscribe = self.wrapped_subscribe
 
+        if hasattr(self.wrapped_characteristic, 'unsubscribe'):
+            self.unsubscribe = self.wrapped_unsubscribe
+
     def __getattr__(self, name):
         return getattr(self.wrapped_characteristic, name)
 
     def __setattr__(self, name, value):
-        if name in {'wrapped_characteristic', 'read_value', 'write_value', 'subscribe'}:
+        if name in {
+            'wrapped_characteristic',
+            'subscribers',
+            'read_value',
+            'write_value',
+            'subscribe',
+            'unsubscribe'
+        }:
             super().__setattr__(name, value)
         else:
             setattr(self.wrapped_characteristic, name, value)
@@ -335,8 +348,11 @@
     async def read_decoded_value(self):
         return self.decode_value(await self.wrapped_characteristic.read_value())
 
-    async def write_decoded_value(self, value):
-        return await self.wrapped_characteristic.write_value(self.encode_value(value))
+    async def write_decoded_value(self, value, with_response=False):
+        return await self.wrapped_characteristic.write_value(
+            self.encode_value(value),
+            with_response
+        )
 
     def encode_value(self, value):
         return value
@@ -345,9 +361,26 @@
         return value
 
     def wrapped_subscribe(self, subscriber=None):
-        return self.wrapped_characteristic.subscribe(
-            None if subscriber is None else lambda value: subscriber(self.decode_value(value))
-        )
+        if subscriber is not None:
+            if subscriber in self.subscribers:
+                # We already have a proxy subscriber
+                subscriber = self.subscribers[subscriber]
+            else:
+                # Create and register a proxy that will decode the value
+                original_subscriber = subscriber
+
+                def on_change(value):
+                    original_subscriber(self.decode_value(value))
+                self.subscribers[subscriber] = on_change
+                subscriber = on_change
+
+        return self.wrapped_characteristic.subscribe(subscriber)
+
+    def wrapped_unsubscribe(self, subscriber=None):
+        if subscriber in self.subscribers:
+            subscriber = self.subscribers.pop(subscriber)
+
+        return self.wrapped_characteristic.unsubscribe(subscriber)
 
     def __str__(self):
         wrapped = str(self.wrapped_characteristic)
@@ -442,3 +475,12 @@
 
     def __str__(self):
         return f'Descriptor(handle=0x{self.handle:04X}, type={self.type}, value={self.read_value(None).hex()})'
+
+
+class ClientCharacteristicConfigurationBits(enum.IntFlag):
+    '''
+    See Vol 3, Part G - 3.3.3.3 - Table 3.11 Client Characteristic Configuration bit field definition
+    '''
+    DEFAULT = 0x0000
+    NOTIFICATION = 0x0001
+    INDICATION = 0x0002
diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py
index 94799a3..1ba1e2a 100644
--- a/bumble/gatt_client.py
+++ b/bumble/gatt_client.py
@@ -26,19 +26,17 @@
 import asyncio
 import logging
 import struct
+
 from colors import color
 
-from .core import ProtocolError, TimeoutError
-from .hci import *
 from .att import *
-from .gatt import (
-    GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
-    GATT_REQUEST_TIMEOUT,
-    GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
-    GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
-    GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
-    Characteristic
-)
+from .core import InvalidStateError, ProtocolError, TimeoutError
+from .gatt import (GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
+                   GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
+                   GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, GATT_REQUEST_TIMEOUT,
+                   GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, Characteristic,
+                   ClientCharacteristicConfigurationBits)
+from .hci import *
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -58,10 +56,16 @@
         self.type             = attribute_type
 
     async def read_value(self, no_long_read=False):
-        return await self.client.read_value(self.handle, no_long_read)
+        return self.decode_value(await self.client.read_value(self.handle, no_long_read))
 
     async def write_value(self, value, with_response=False):
-        return await self.client.write_value(self.handle, value, with_response)
+        return await self.client.write_value(self.handle, self.encode_value(value), with_response)
+
+    def encode_value(self, value):
+        return value
+
+    def decode_value(self, value_bytes):
+        return value_bytes
 
     def __str__(self):
         return f'Attribute(handle=0x{self.handle:04X}, type={self.uuid})'
@@ -98,6 +102,7 @@
         self.properties             = properties
         self.descriptors            = []
         self.descriptors_discovered = False
+        self.subscribers            = {}  # Map from subscriber to proxy subscriber
 
     def get_descriptor(self, descriptor_type):
         for descriptor in self.descriptors:
@@ -107,10 +112,26 @@
     async def discover_descriptors(self):
         return await self.client.discover_descriptors(self)
 
-    async def subscribe(self, subscriber=None):
-        return await self.client.subscribe(self, subscriber)
+    async def subscribe(self, subscriber=None, prefer_notify=True):
+        if subscriber is not None:
+            if subscriber in self.subscribers:
+                # We already have a proxy subscriber
+                subscriber = self.subscribers[subscriber]
+            else:
+                # Create and register a proxy that will decode the value
+                original_subscriber = subscriber
+
+                def on_change(value):
+                    original_subscriber(self.decode_value(value))
+                self.subscribers[subscriber] = on_change
+                subscriber = on_change
+
+        return await self.client.subscribe(self, subscriber, prefer_notify)
 
     async def unsubscribe(self, subscriber=None):
+        if subscriber in self.subscribers:
+            subscriber = self.subscribers.pop(subscriber)
+
         return await self.client.unsubscribe(self, subscriber)
 
     def __str__(self):
@@ -140,7 +161,6 @@
 class Client:
     def __init__(self, connection):
         self.connection               = connection
-        self.mtu                      = ATT_DEFAULT_MTU
         self.mtu_exchange_done        = False
         self.request_semaphore        = asyncio.Semaphore(1)
         self.pending_request          = None
@@ -162,8 +182,8 @@
         # Wait until we can send (only one pending command at a time for the connection)
         response = None
         async with self.request_semaphore:
-            assert(self.pending_request is None)
-            assert(self.pending_response is None)
+            assert self.pending_request is None
+            assert self.pending_response is None
 
             # Create a future value to hold the eventual response
             self.pending_response = asyncio.get_running_loop().create_future()
@@ -194,7 +214,7 @@
 
         # We can only send one request per connection
         if self.mtu_exchange_done:
-            return
+            return self.connection.att_mtu
 
         # Send the request
         self.mtu_exchange_done = True
@@ -207,8 +227,10 @@
                 response
             )
 
-        self.mtu = max(ATT_DEFAULT_MTU, response.server_rx_mtu)
-        return self.mtu
+        # Compute the final MTU
+        self.connection.att_mtu = min(mtu, response.server_rx_mtu)
+
+        return self.connection.att_mtu
 
     def get_services_by_uuid(self, uuid):
         return [service for service in self.services if service.uuid == uuid]
@@ -249,7 +271,7 @@
             if response.op_code == ATT_ERROR_RESPONSE:
                 if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
                     # Unexpected end
-                    logger.waning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
+                    logger.warning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
                     # TODO raise appropriate exception
                     return
                 break
@@ -313,7 +335,7 @@
             if response.op_code == ATT_ERROR_RESPONSE:
                 if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
                     # Unexpected end
-                    logger.waning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
+                    logger.warning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
                     # TODO raise appropriate exception
                     return
                 break
@@ -522,7 +544,7 @@
 
         return attributes
 
-    async def subscribe(self, characteristic, subscriber=None):
+    async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
         # If we haven't already discovered the descriptors for this characteristic, do it now
         if not characteristic.descriptors_discovered:
             await self.discover_descriptors(characteristic)
@@ -533,23 +555,32 @@
             logger.warning('subscribing to characteristic with no CCCD descriptor')
             return
 
-        # Set the subscription bits and select the subscriber set
-        bits = 0
-        subscriber_sets = []
-        if characteristic.properties & Characteristic.NOTIFY:
-            bits |= 0x0001
-            subscriber_sets.append(self.notification_subscribers.setdefault(characteristic.handle, set()))
-        if characteristic.properties & Characteristic.INDICATE:
-            bits |= 0x0002
-            subscriber_sets.append(self.indication_subscribers.setdefault(characteristic.handle, set()))
+        if (
+            characteristic.properties & Characteristic.NOTIFY
+            and characteristic.properties & Characteristic.INDICATE
+        ):
+            if prefer_notify:
+                bits = ClientCharacteristicConfigurationBits.NOTIFICATION
+                subscribers = self.notification_subscribers
+            else:
+                bits = ClientCharacteristicConfigurationBits.INDICATION
+                subscribers = self.indication_subscribers
+        elif characteristic.properties & Characteristic.NOTIFY:
+            bits = ClientCharacteristicConfigurationBits.NOTIFICATION
+            subscribers = self.notification_subscribers
+        elif characteristic.properties & Characteristic.INDICATE:
+            bits = ClientCharacteristicConfigurationBits.INDICATION
+            subscribers = self.indication_subscribers
+        else:
+            raise InvalidStateError("characteristic is not notify or indicate")
 
         # Add subscribers to the sets
-        for subscriber_set in subscriber_sets:
-            if subscriber is not None:
-                subscriber_set.add(subscriber)
-            # Add the characteristic as a subscriber, which will result in the characteristic
-            # emitting an 'update' event when a notification or indication is received
-            subscriber_set.add(characteristic)
+        subscriber_set = subscribers.setdefault(characteristic.handle, set())
+        if subscriber is not None:
+            subscriber_set.add(subscriber)
+        # Add the characteristic as a subscriber, which will result in the characteristic
+        # emitting an 'update' event when a notification or indication is received
+        subscriber_set.add(characteristic)
 
         await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
 
@@ -570,12 +601,18 @@
                 subscribers = subscriber_set.get(characteristic.handle, [])
                 if subscriber in subscribers:
                     subscribers.remove(subscriber)
+
+                    # Cleanup if we removed the last one
+                    if not subscribers:
+                        subscriber_set.remove(characteristic.handle)
         else:
             # Remove all subscribers for this attribute from the sets!
             self.notification_subscribers.pop(characteristic.handle, None)
             self.indication_subscribers.pop(characteristic.handle, None)
 
-        await self.write_value(cccd, b'\x00\x00', with_response=True)
+        if not self.notification_subscribers and not self.indication_subscribers:
+            # No more subscribers left
+            await self.write_value(cccd, b'\x00\x00', with_response=True)
 
     async def read_value(self, attribute, no_long_read=False):
         '''
@@ -600,7 +637,7 @@
         # If the value is the max size for the MTU, try to read more unless the caller
         # specifically asked not to do that
         attribute_value = response.attribute_value
-        if not no_long_read and len(attribute_value) == self.mtu - 1:
+        if not no_long_read and len(attribute_value) == self.connection.att_mtu - 1:
             logger.debug('using READ BLOB to get the rest of the value')
             offset = len(attribute_value)
             while True:
@@ -622,7 +659,7 @@
                 part = response.part_attribute_value
                 attribute_value += part
 
-                if len(part) < self.mtu - 1:
+                if len(part) < self.connection.att_mtu - 1:
                     break
 
                 offset += len(part)
diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py
index 8297f11..5b6fea3 100644
--- a/bumble/gatt_server.py
+++ b/bumble/gatt_server.py
@@ -41,6 +41,12 @@
 
 
 # -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+GATT_SERVER_DEFAULT_MAX_MTU = 517
+
+
+# -----------------------------------------------------------------------------
 # GATT Server
 # -----------------------------------------------------------------------------
 class Server(EventEmitter):
@@ -49,9 +55,8 @@
         self.device                = device
         self.attributes            = []  # Attributes, ordered by increasing handle values
         self.attributes_by_handle  = {}  # Map for fast attribute access by handle
-        self.max_mtu               = 23  # FIXME: 517  # The max MTU we're willing to negotiate
+        self.max_mtu               = GATT_SERVER_DEFAULT_MAX_MTU  # The max MTU we're willing to negotiate
         self.subscribers           = {}  # Map of subscriber states by connection handle and attribute handle
-        self.mtus                  = {}  # Map of ATT MTU values by connection handle
         self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
         self.pending_confirmations = defaultdict(lambda: None)
 
@@ -150,7 +155,7 @@
         return cccd or bytes([0, 0])
 
     def write_cccd(self, connection, characteristic, value):
-        logger.debug(f'Subscription update for connection={connection.handle:04X}, handle={characteristic.handle:04X}: {value.hex()}')
+        logger.debug(f'Subscription update for connection=0x{connection.handle:04X}, handle=0x{characteristic.handle:04X}: {value.hex()}')
 
         # Sanity check
         if len(value) != 2:
@@ -169,7 +174,7 @@
         logger.debug(f'GATT Response from server: [0x{connection.handle:04X}] {response}')
         self.send_gatt_pdu(connection.handle, response.to_bytes())
 
-    async def notify_subscriber(self, connection, attribute, force=False):
+    async def notify_subscriber(self, connection, attribute, value=None, force=False):
         # Check if there's a subscriber
         if not force:
             subscribers = self.subscribers.get(connection.handle)
@@ -184,13 +189,12 @@
                 logger.debug(f'not notifying, cccd={cccd.hex()}')
                 return
 
-        # Get the value
-        value = attribute.read_value(connection)
+        # Get or encode the value
+        value = attribute.read_value(connection) if value is None else attribute.encode_value(value)
 
         # Truncate if needed
-        mtu = self.get_mtu(connection)
-        if len(value) > mtu - 3:
-            value = value[:mtu - 3]
+        if len(value) > connection.att_mtu - 3:
+            value = value[:connection.att_mtu - 3]
 
         # Notify
         notification = ATT_Handle_Value_Notification(
@@ -198,27 +202,9 @@
             attribute_value  = value
         )
         logger.debug(f'GATT Notify from server: [0x{connection.handle:04X}] {notification}')
-        self.send_gatt_pdu(connection.handle, notification.to_bytes())
+        self.send_gatt_pdu(connection.handle, bytes(notification))
 
-    async def notify_subscribers(self, attribute, force=False):
-        # Get all the connections for which there's at least one subscription
-        connections = [
-            connection for connection in [
-                self.device.lookup_connection(connection_handle)
-                for (connection_handle, subscribers) in self.subscribers.items()
-                if force or subscribers.get(attribute.handle)
-            ]
-            if connection is not None
-        ]
-
-        # Notify for each connection
-        if connections:
-            await asyncio.wait([
-                self.notify_subscriber(connection, attribute, force)
-                for connection in connections
-            ])
-
-    async def indicate_subscriber(self, connection, attribute, force=False):
+    async def indicate_subscriber(self, connection, attribute, value=None, force=False):
         # Check if there's a subscriber
         if not force:
             subscribers = self.subscribers.get(connection.handle)
@@ -233,13 +219,12 @@
                 logger.debug(f'not indicating, cccd={cccd.hex()}')
                 return
 
-        # Get the value
-        value = attribute.read_value(connection)
+        # Get or encode the value
+        value = attribute.read_value(connection) if value is None else attribute.encode_value(value)
 
         # Truncate if needed
-        mtu = self.get_mtu(connection)
-        if len(value) > mtu - 3:
-            value = value[:mtu - 3]
+        if len(value) > connection.att_mtu - 3:
+            value = value[:connection.att_mtu - 3]
 
         # Indicate
         indication = ATT_Handle_Value_Indication(
@@ -264,27 +249,32 @@
             finally:
                 self.pending_confirmations[connection.handle] = None
 
-    async def indicate_subscribers(self, attribute):
+    async def notify_or_indicate_subscribers(self, indicate, attribute, value=None, force=False):
         # Get all the connections for which there's at least one subscription
         connections = [
             connection for connection in [
                 self.device.lookup_connection(connection_handle)
                 for (connection_handle, subscribers) in self.subscribers.items()
-                if subscribers.get(attribute.handle)
+                if force or subscribers.get(attribute.handle)
             ]
             if connection is not None
         ]
 
-        # Indicate for each connection
+        # Indicate or notify for each connection
         if connections:
+            coroutine = self.indicate_subscriber if indicate else self.notify_subscriber
             await asyncio.wait([
-                self.indicate_subscriber(connection, attribute)
+                asyncio.create_task(coroutine(connection, attribute, value, force))
                 for connection in connections
             ])
 
+    async def notify_subscribers(self, attribute, value=None, force=False):
+        return await self.notify_or_indicate_subscribers(False, attribute, value, force)
+
+    async def indicate_subscribers(self, attribute, value=None, force=False):
+        return await self.notify_or_indicate_subscribers(True, attribute, value, force)
+
     def on_disconnection(self, connection):
-        if connection.handle in self.mtus:
-            del self.mtus[connection.handle]
         if connection.handle in self.subscribers:
             del self.subscribers[connection.handle]
         if connection.handle in self.indication_semaphores:
@@ -325,9 +315,6 @@
                 # Just ignore
                 logger.warning(f'{color("--- Ignoring GATT Request from [0x{connection.handle:04X}]:", "red")}  {att_pdu}')
 
-    def get_mtu(self, connection):
-        return self.mtus.get(connection.handle, ATT_DEFAULT_MTU)
-
     #######################################################
     # ATT handlers
     #######################################################
@@ -347,12 +334,16 @@
         '''
         See Bluetooth spec Vol 3, Part F - 3.4.2.1 Exchange MTU Request
         '''
-        mtu = max(ATT_DEFAULT_MTU, min(self.max_mtu, request.client_rx_mtu))
-        self.mtus[connection.handle] = mtu
-        self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = mtu))
+        self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = self.max_mtu))
 
-        # Notify the device
-        self.device.on_connection_att_mtu_update(connection.handle, mtu)
+        # Compute the final MTU
+        if request.client_rx_mtu >= ATT_DEFAULT_MTU:
+            mtu = min(self.max_mtu, request.client_rx_mtu)
+
+            # Notify the device
+            self.device.on_connection_att_mtu_update(connection.handle, mtu)
+        else:
+            logger.warning('invalid client_rx_mtu received, MTU not changed')
 
     def on_att_find_information_request(self, connection, request):
         '''
@@ -369,7 +360,7 @@
             return
 
         # Build list of returned attributes
-        pdu_space_available = self.get_mtu(connection) - 2
+        pdu_space_available = connection.att_mtu - 2
         attributes = []
         uuid_size = 0
         for attribute in (
@@ -420,7 +411,7 @@
         '''
 
         # Build list of returned attributes
-        pdu_space_available = self.get_mtu(connection) - 2
+        pdu_space_available = connection.att_mtu - 2
         attributes = []
         for attribute in (
             attribute for attribute in self.attributes if
@@ -468,8 +459,7 @@
         See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
         '''
 
-        mtu = self.get_mtu(connection)
-        pdu_space_available = mtu - 2
+        pdu_space_available = connection.att_mtu - 2
         attributes = []
         for attribute in (
             attribute for attribute in self.attributes if
@@ -482,7 +472,7 @@
 
             # Check the attribute value size
             attribute_value = attribute.read_value(connection)
-            max_attribute_size = min(mtu - 4, 253)
+            max_attribute_size = min(connection.att_mtu - 4, 253)
             if len(attribute_value) > max_attribute_size:
                 # We need to truncate
                 attribute_value = attribute_value[:max_attribute_size]
@@ -522,7 +512,7 @@
         if attribute := self.get_attribute(request.attribute_handle):
             # TODO: check permissions
             value = attribute.read_value(connection)
-            value_size = min(self.get_mtu(connection) - 1, len(value))
+            value_size = min(connection.att_mtu - 1, len(value))
             response = ATT_Read_Response(
                 attribute_value = value[:value_size]
             )
@@ -541,7 +531,6 @@
 
         if attribute := self.get_attribute(request.attribute_handle):
             # TODO: check permissions
-            mtu = self.get_mtu(connection)
             value = attribute.read_value(connection)
             if request.value_offset > len(value):
                 response = ATT_Error_Response(
@@ -549,14 +538,14 @@
                     attribute_handle_in_error = request.attribute_handle,
                     error_code                = ATT_INVALID_OFFSET_ERROR
                 )
-            elif len(value) <= mtu - 1:
+            elif len(value) <= connection.att_mtu - 1:
                 response = ATT_Error_Response(
                     request_opcode_in_error   = request.op_code,
                     attribute_handle_in_error = request.attribute_handle,
                     error_code                = ATT_ATTRIBUTE_NOT_LONG_ERROR
                 )
             else:
-                part_size = min(mtu - 1, len(value) - request.value_offset)
+                part_size = min(connection.att_mtu - 1, len(value) - request.value_offset)
                 response = ATT_Read_Blob_Response(
                     part_attribute_value = value[request.value_offset:request.value_offset + part_size]
                 )
@@ -585,8 +574,7 @@
             self.send_response(connection, response)
             return
 
-        mtu = self.get_mtu(connection)
-        pdu_space_available = mtu - 2
+        pdu_space_available = connection.att_mtu - 2
         attributes = []
         for attribute in (
             attribute for attribute in self.attributes if
@@ -597,7 +585,7 @@
         ):
             # Check the attribute value size
             attribute_value = attribute.read_value(connection)
-            max_attribute_size = min(mtu - 6, 251)
+            max_attribute_size = min(connection.att_mtu - 6, 251)
             if len(attribute_value) > max_attribute_size:
                 # We need to truncate
                 attribute_value = attribute_value[:max_attribute_size]
diff --git a/bumble/hci.py b/bumble/hci.py
index 9858c04..be5973b 100644
--- a/bumble/hci.py
+++ b/bumble/hci.py
@@ -62,6 +62,18 @@
     return f'[{class_of_device:06X}] Services({",".join(DeviceClass.service_class_labels(service_classes))}),Class({DeviceClass.major_device_class_name(major_device_class)}|{DeviceClass.minor_device_class_name(major_device_class, minor_device_class)})'
 
 
+def phy_list_to_bits(phys):
+    if phys is None:
+        return 0
+    else:
+        phy_bits = 0
+        for phy in phys:
+            if phy not in HCI_LE_PHY_TYPE_TO_BIT:
+                raise ValueError('invalid PHY')
+            phy_bits |= (1 << HCI_LE_PHY_TYPE_TO_BIT[phy])
+        return phy_bits
+
+
 # -----------------------------------------------------------------------------
 # Constants
 # -----------------------------------------------------------------------------
@@ -670,10 +682,22 @@
 
 HCI_LE_PHY_NAMES = {
     HCI_LE_1M_PHY:    'LE 1M',
-    HCI_LE_2M_PHY:    'L2 2M',
+    HCI_LE_2M_PHY:    'LE 2M',
     HCI_LE_CODED_PHY: 'LE Coded'
 }
 
+HCI_LE_1M_PHY_BIT    = 0
+HCI_LE_2M_PHY_BIT    = 1
+HCI_LE_CODED_PHY_BIT = 2
+
+HCI_LE_PHY_BIT_NAMES = ['LE_1M_PHY', 'LE_2M_PHY', 'LE_CODED_PHY']
+
+HCI_LE_PHY_TYPE_TO_BIT = {
+    HCI_LE_1M_PHY:    HCI_LE_1M_PHY_BIT,
+    HCI_LE_2M_PHY:    HCI_LE_2M_PHY_BIT,
+    HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_BIT
+}
+
 # Connection Parameters
 HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25
 HCI_CONNECTION_LATENCY_MS_PER_UNIT  = 1.25
@@ -1373,11 +1397,14 @@
         super().__init__(error_code, 'hci', HCI_Constant.error_name(error_code))
 
 
+# -----------------------------------------------------------------------------
 class HCI_StatusError(ProtocolError):
     def __init__(self, response):
-        super().__init__(response.status,
-                error_namespace=HCI_Command.command_name(response.command_opcode),
-                error_name=HCI_Constant.status_name(response.status))
+        super().__init__(
+            response.status,
+            error_namespace=HCI_Command.command_name(response.command_opcode),
+            error_name=HCI_Constant.status_name(response.status)
+        )
 
 
 # -----------------------------------------------------------------------------
@@ -1402,7 +1429,7 @@
     def dict_from_bytes(data, offset, fields):
         result = collections.OrderedDict()
         for (field_name, field_type) in fields:
-            # The field_type may be a dictionnary with a mapper, parser, and/or size
+            # The field_type may be a dictionary with a mapper, parser, and/or size
             if type(field_type) is dict:
                 if 'size' in field_type:
                     field_type = field_type['size']
@@ -1464,7 +1491,7 @@
     def dict_to_bytes(object, fields):
         result = bytearray()
         for (field_name, field_type) in fields:
-            # The field_type may be a dictionnary with a mapper, parser, serializer, and/or size
+            # The field_type may be a dictionary with a mapper, parser, serializer, and/or size
             serializer = None
             if type(field_type) is dict:
                 if 'serializer' in field_type:
@@ -1523,9 +1550,9 @@
 
         return bytes(result)
 
-    @staticmethod
-    def from_bytes(data, offset, fields):
-        return HCI_Object(fields, **HCI_Object.dict_from_bytes(data, offset, fields))
+    @classmethod
+    def from_bytes(cls, data, offset, fields):
+        return cls(fields, **cls.dict_from_bytes(data, offset, fields))
 
     def to_bytes(self):
         return HCI_Object.dict_to_bytes(self.__dict__, self.fields)
@@ -1625,11 +1652,29 @@
 
     ADDRESS_TYPE_SPEC = {'size': 1, 'mapper': lambda x: Address.address_type_name(x)}
 
+    @classmethod
+    @property
+    def ANY(cls):
+        return cls(b"\xff\xff\xff\xff\xff\xff", cls.PUBLIC_DEVICE_ADDRESS)
+
+    @classmethod
+    @property
+    def NIL(cls):
+        return cls(b"\x00\x00\x00\x00\x00\x00", cls.PUBLIC_DEVICE_ADDRESS)
+
     @staticmethod
     def address_type_name(address_type):
         return name_or_number(Address.ADDRESS_TYPE_NAMES, address_type)
 
     @staticmethod
+    def from_string_for_transport(string, transport):
+        if transport == BT_BR_EDR_TRANSPORT:
+            address_type = Address.PUBLIC_DEVICE_ADDRESS
+        else:
+            address_type = Address.RANDOM_DEVICE_ADDRESS
+        return Address(string, address_type)
+
+    @staticmethod
     def parse_address(data, offset):
         # Fix the type to a default value. This is used for parsing type-less Classic addresses
         return Address.parse_address_with_type(data, offset, Address.PUBLIC_DEVICE_ADDRESS)
@@ -1669,6 +1714,9 @@
 
         self.address_type = address_type
 
+    def clone(self):
+        return Address(self.address_bytes, self.address_type)
+
     @property
     def is_public(self):
         return self.address_type == self.PUBLIC_DEVICE_ADDRESS or self.address_type == self.PUBLIC_IDENTITY_ADDRESS
@@ -1705,10 +1753,33 @@
         '''
         String representation of the address, MSB first
         '''
-        return ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
+        str = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
+        if not self.is_public:
+            return str
+        return str + '/P'
 
 
 # -----------------------------------------------------------------------------
+class OwnAddressType:
+    PUBLIC = 0
+    RANDOM = 1
+    RESOLVABLE_OR_PUBLIC = 2
+    RESOLVABLE_OR_RANDOM = 3
+
+    TYPE_NAMES = {
+        PUBLIC:               'PUBLIC',
+        RANDOM:               'RANDOM',
+        RESOLVABLE_OR_PUBLIC: 'RESOLVABLE_OR_PUBLIC',
+        RESOLVABLE_OR_RANDOM: 'RESOLVABLE_OR_RANDOM'
+    }
+
+    @staticmethod
+    def type_name(type):
+        return name_or_number(OwnAddressType.TYPE_NAMES, type)
+
+    TYPE_SPEC = {'size': 1, 'mapper': lambda x: OwnAddressType.type_name(x)}
+
+# -----------------------------------------------------------------------------
 class HCI_Packet:
     '''
     Abstract Base class for HCI packets
@@ -1882,6 +1953,22 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('bd_addr', Address.parse_address)
+    ],
+    return_parameters_fields=[
+        ('status',  STATUS_SPEC),
+        ('bd_addr', Address.parse_address)
+    ]
+)
+class HCI_Create_Connection_Cancel_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.7 Create Connection Cancel Command
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Command.command([
     ('bd_addr', Address.parse_address),
     ('role',    1)
@@ -1894,6 +1981,17 @@
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command([
+    ('bd_addr', Address.parse_address),
+    ('reason',  {'size': 1, 'mapper': HCI_Constant.error_name})
+])
+class HCI_Reject_Connection_Request_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.9 Reject Connection Request Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
     ('bd_addr',  Address.parse_address),
     ('link_key', 16)
 ])
@@ -2297,7 +2395,7 @@
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command([
-    ('conn_accept_timeout', 2)
+    ('connection_accept_timeout', 2)
 ])
 class HCI_Write_Connection_Accept_Timeout_Command(HCI_Command):
     '''
@@ -2402,9 +2500,10 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Command.command()
 class HCI_Read_Synchronous_Flow_Control_Enable_Command(HCI_Command):
     '''
-    See Bluetooth spec @ 7.3.36 Write Synchronous Flow Control Enable Command
+    See Bluetooth spec @ 7.3.36 Read Synchronous Flow Control Enable Command
     '''
 
 
@@ -2693,6 +2792,23 @@
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
     fields=[
+        ('handle', 2)
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('handle', 2),
+        ('rssi',   -1)
+    ]
+)
+class HCI_Read_RSSI_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.5.4 Read RSSI Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
         ('connection_handle', 2)
     ],
     return_parameters_fields=[
@@ -2755,7 +2871,7 @@
     ('advertising_interval_min',  2),
     ('advertising_interval_max',  2),
     ('advertising_type',          {'size': 1, 'mapper': lambda x: HCI_LE_Set_Advertising_Parameters_Command.advertising_type_name(x)}),
-    ('own_address_type',          Address.ADDRESS_TYPE_SPEC),
+    ('own_address_type',          OwnAddressType.TYPE_SPEC),
     ('peer_address_type',         Address.ADDRESS_TYPE_SPEC),
     ('peer_address',              Address.parse_address_preceded_by_type),
     ('advertising_channel_map',   1),
@@ -2766,18 +2882,18 @@
     See Bluetooth spec @ 7.8.5 LE Set Advertising Parameters Command
     '''
 
-    ADV_IND         = 0x00
-    ADV_DIRECT_IND  = 0x01
-    ADV_SCAN_IND    = 0x02
-    ADV_NONCONN_IND = 0x03
-    ADV_DIRECT_IND  = 0x04
+    ADV_IND                 = 0x00
+    ADV_DIRECT_IND          = 0x01
+    ADV_SCAN_IND            = 0x02
+    ADV_NONCONN_IND         = 0x03
+    ADV_DIRECT_IND_LOW_DUTY = 0x04
 
     ADVERTISING_TYPE_NAMES = {
-        ADV_IND:         'ADV_IND',
-        ADV_DIRECT_IND:  'ADV_DIRECT_IND',
-        ADV_SCAN_IND:    'ADV_SCAN_IND',
-        ADV_NONCONN_IND: 'ADV_NONCONN_IND',
-        ADV_DIRECT_IND:  'ADV_DIRECT_IND'
+        ADV_IND:                 'ADV_IND',
+        ADV_DIRECT_IND:          'ADV_DIRECT_IND',
+        ADV_SCAN_IND:            'ADV_SCAN_IND',
+        ADV_NONCONN_IND:         'ADV_NONCONN_IND',
+        ADV_DIRECT_IND_LOW_DUTY: 'ADV_DIRECT_IND_LOW_DUTY'
     }
 
     @classmethod
@@ -2834,7 +2950,7 @@
     ('le_scan_type',           1),
     ('le_scan_interval',       2),
     ('le_scan_window',         2),
-    ('own_address_type',       Address.ADDRESS_TYPE_SPEC),
+    ('own_address_type',       OwnAddressType.TYPE_SPEC),
     ('scanning_filter_policy', 1)
 ])
 class HCI_LE_Set_Scan_Parameters_Command(HCI_Command):
@@ -2868,13 +2984,13 @@
     ('initiator_filter_policy', 1),
     ('peer_address_type',       Address.ADDRESS_TYPE_SPEC),
     ('peer_address',            Address.parse_address_preceded_by_type),
-    ('own_address_type',        Address.ADDRESS_TYPE_SPEC),
-    ('conn_interval_min',       2),
-    ('conn_interval_max',       2),
-    ('conn_latency',            2),
+    ('own_address_type',        OwnAddressType.TYPE_SPEC),
+    ('connection_interval_min', 2),
+    ('connection_interval_max', 2),
+    ('max_latency',             2),
     ('supervision_timeout',     2),
-    ('minimum_ce_length',       2),
-    ('maximum_ce_length',       2)
+    ('min_ce_length',           2),
+    ('max_ce_length',           2)
 ])
 class HCI_LE_Create_Connection_Command(HCI_Command):
     '''
@@ -2930,13 +3046,13 @@
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command([
-    ('connection_handle',   2),
-    ('conn_interval_min',   2),
-    ('conn_interval_max',   2),
-    ('conn_latency',        2),
-    ('supervision_timeout', 2),
-    ('minimum_ce_length',   2),
-    ('maximum_ce_length',   2)
+    ('connection_handle',       2),
+    ('connection_interval_min', 2),
+    ('connection_interval_max', 2),
+    ('max_latency',             2),
+    ('supervision_timeout',     2),
+    ('min_ce_length',           2),
+    ('max_ce_length',           2)
 ])
 class HCI_LE_Connection_Update_Command(HCI_Command):
     '''
@@ -3002,10 +3118,10 @@
     ('connection_handle', 2),
     ('interval_min',      2),
     ('interval_max',      2),
-    ('latency',           2),
+    ('max_latency',       2),
     ('timeout',           2),
-    ('minimum_ce_length', 2),
-    ('maximum_ce_length', 2)
+    ('min_ce_length',     2),
+    ('max_ce_length',     2)
 ])
 class HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(HCI_Command):
     '''
@@ -3025,6 +3141,36 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('connection_handle', 2),
+        ('tx_octets',         2),
+        ('tx_time',           2),
+    ],
+    return_parameters_fields=[
+        ('status',            STATUS_SPEC),
+        ('connection_handle', 2)
+    ]
+)
+class HCI_LE_Set_Data_Length_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.33 LE Set Data Length Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(return_parameters_fields=[
+    ('status',                  STATUS_SPEC),
+    ('suggested_max_tx_octets', 2),
+    ('suggested_max_tx_time',   2),
+])
+class HCI_LE_Read_Suggested_Default_Data_Length_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.34 LE Read Suggested Default Data Length Command
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Command.command([
     ('suggested_max_tx_octets', 2),
     ('suggested_max_tx_time',   2)
@@ -3058,18 +3204,6 @@
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command([
-    ('all_phys', 1),
-    ('tx_phys',  1),
-    ('rx_phys',  1)
-])
-class HCI_LE_Set_Default_PHY_Command(HCI_Command):
-    '''
-    See Bluetooth spec @ 7.8.48 LE Set Default PHY Command
-    '''
-
-
-# -----------------------------------------------------------------------------
-@HCI_Command.command([
     ('address_resolution_enable', 1)
 ])
 class HCI_LE_Set_Address_Resolution_Enable_Command(HCI_Command):
@@ -3089,6 +3223,313 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Command.command(return_parameters_fields=[
+    ('status',                  STATUS_SPEC),
+    ('supported_max_tx_octets', 2),
+    ('supported_max_tx_time',   2),
+    ('supported_max_rx_octets', 2),
+    ('supported_max_rx_time',   2)
+])
+class HCI_LE_Read_Maximum_Data_Length_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.46 LE Read Maximum Data Length Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('connection_handle', 2)
+    ],
+    return_parameters_fields=[
+        ('status',            STATUS_SPEC),
+        ('connection_handle', 2),
+        ('tx_phy',            {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+        ('rx_phy',            {'size': 1, 'mapper': HCI_Constant.le_phy_name})
+    ])
+class HCI_LE_Read_PHY_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.47 LE Read PHY Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('all_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_Set_Default_PHY_Command.ANY_PHY_BIT_NAMES)}),
+    ('tx_phys',  {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}),
+    ('rx_phys',  {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)})
+])
+class HCI_LE_Set_Default_PHY_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.48 LE Set Default PHY Command
+    '''
+    ANY_TX_PHY_BIT = 0
+    ANY_RX_PHY_BIT = 1
+
+    ANY_PHY_BIT_NAMES = ['Any TX', 'Any RX']
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('connection_handle', 2),
+    ('all_phys',          {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_Set_PHY_Command.ANY_PHY_BIT_NAMES)}),
+    ('tx_phys',           {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}),
+    ('rx_phys',           {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}),
+    ('phy_options',       2)
+])
+class HCI_LE_Set_PHY_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.49 LE Set PHY Command
+    '''
+    ANY_TX_PHY_BIT = 0
+    ANY_RX_PHY_BIT = 1
+
+    ANY_PHY_BIT_NAMES = ['Any TX', 'Any RX']
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('advertising_handle', 1),
+    ('random_address', lambda data, offset: Address.parse_address_with_type(data, offset, Address.RANDOM_DEVICE_ADDRESS))
+])
+class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.52 LE Set Advertising Set Random Address Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('advertising_handle',               1),
+        ('advertising_event_properties',     {'size': 2, 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.advertising_properties_string(x)}),
+        ('primary_advertising_interval_min', 3),
+        ('primary_advertising_interval_max', 3),
+        ('primary_advertising_channel_map',  {'size': 1, 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.channel_map_string(x)}),
+        ('own_address_type',                 OwnAddressType.TYPE_SPEC),
+        ('peer_address_type',                Address.ADDRESS_TYPE_SPEC),
+        ('peer_address',                     Address.parse_address_preceded_by_type),
+        ('advertising_filter_policy',        1),
+        ('advertising_tx_power',             1),
+        ('primary_advertising_phy',          {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+        ('secondary_advertising_max_skip',   1),
+        ('secondary_advertising_phy',        {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+        ('advertising_sid',                  1),
+        ('scan_request_notification_enable', 1)
+    ],
+    return_parameters_fields=[
+        ('status',                      STATUS_SPEC),
+        ('selected_tx__power', 1)
+    ]
+)
+class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.53 LE Set Extended Advertising Parameters Command
+    '''
+
+    CONNECTABLE_ADVERTISING                          = 0
+    SCANNABLE_ADVERTISING                            = 1
+    DIRECTED_ADVERTISING                             = 2
+    HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 3
+    USE_LEGACY_ADVERTISING_PDUS                      = 4
+    ANONYMOUS_ADVERTISING                            = 5
+    INCLUDE_TX_POWER                                 = 6
+
+    ADVERTISING_PROPERTIES_NAMES = (
+        'CONNECTABLE_ADVERTISING',
+        'SCANNABLE_ADVERTISING',
+        'DIRECTED_ADVERTISING',
+        'HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING',
+        'USE_LEGACY_ADVERTISING_PDUS',
+        'ANONYMOUS_ADVERTISING',
+        'INCLUDE_TX_POWER'
+    )
+
+    CHANNEL_37 = 0
+    CHANNEL_38 = 1
+    CHANNEL_39 = 2
+
+    CHANNEL_NAMES = ('37', '38', '39')
+
+    @classmethod
+    def advertising_properties_string(cls, properties):
+        return f'[{",".join(bit_flags_to_strings(properties, cls.ADVERTISING_PROPERTIES_NAMES))}]'
+
+    @classmethod
+    def channel_map_string(cls, channel_map):
+        return f'[{",".join(bit_flags_to_strings(channel_map, cls.CHANNEL_NAMES))}]'
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('advertising_handle',  1),
+    ('operation',           {'size': 1, 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(x)}),
+    ('fragment_preference', 1),
+    ('advertising_data', {
+        'parser':     HCI_Object.parse_length_prefixed_bytes,
+        'serializer': functools.partial(HCI_Object.serialize_length_prefixed_bytes)
+    })
+])
+class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.54 LE Set Extended Advertising Data Command
+    '''
+
+    INTERMEDIATE_FRAGMENT = 0x00
+    FIRST_FRAGMENT        = 0x01
+    LAST_FRAGMENT         = 0x02
+    COMPLETE_DATA         = 0x03
+    UNCHANGED_DATA        = 0x04
+
+    OPERATION_NAMES = {
+        INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
+        FIRST_FRAGMENT:        'FIRST_FRAGMENT',
+        LAST_FRAGMENT:         'LAST_FRAGMENT',
+        COMPLETE_DATA:         'COMPLETE_DATA',
+        UNCHANGED_DATA:        'UNCHANGED_DATA'
+    }
+
+    @classmethod
+    def operation_name(cls, operation):
+        return name_or_number(cls.OPERATION_NAMES, operation)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('advertising_handle',  1),
+    ('operation',           {'size': 1, 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(x)}),
+    ('fragment_preference', 1),
+    ('scan_response_data', {
+        'parser':     HCI_Object.parse_length_prefixed_bytes,
+        'serializer': functools.partial(HCI_Object.serialize_length_prefixed_bytes)
+    })
+])
+class HCI_LE_Set_Extended_Scan_Response_Data_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.55 LE Set Extended Scan Response Data Command
+    '''
+
+    INTERMEDIATE_FRAGMENT = 0x00
+    FIRST_FRAGMENT        = 0x01
+    LAST_FRAGMENT         = 0x02
+    COMPLETE_DATA         = 0x03
+
+    OPERATION_NAMES = {
+        INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
+        FIRST_FRAGMENT:        'FIRST_FRAGMENT',
+        LAST_FRAGMENT:         'LAST_FRAGMENT',
+        COMPLETE_DATA:         'COMPLETE_DATA'
+    }
+
+    @classmethod
+    def operation_name(cls, operation):
+        return name_or_number(cls.OPERATION_NAMES, operation)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(fields=None)
+class HCI_LE_Set_Extended_Advertising_Enable_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.56 LE Set Extended Advertising Enable Command
+    '''
+
+    @classmethod
+    def from_parameters(cls, parameters):
+        enable   = parameters[0]
+        num_sets = parameters[1]
+        advertising_handles             = []
+        durations                       = []
+        max_extended_advertising_events = []
+        offset = 2
+        for _ in range(num_sets):
+            advertising_handles.append(parameters[offset])
+            durations.append(struct.unpack_from('<H', parameters, offset + 1)[0])
+            max_extended_advertising_events.append(parameters[offset + 3])
+            offset += 4
+
+        return cls(enable, advertising_handles, durations, max_extended_advertising_events)
+
+    def __init__(self, enable, advertising_handles, durations, max_extended_advertising_events):
+        super().__init__(HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND)
+        self.enable                          = enable
+        self.advertising_handles             = advertising_handles
+        self.durations                       = durations
+        self.max_extended_advertising_events = max_extended_advertising_events
+
+        self.parameters = bytes([enable, len(advertising_handles)]) + b''.join([
+            struct.pack(
+                '<BHB',
+                advertising_handles[i],
+                durations[i],
+                max_extended_advertising_events[i]
+            )
+            for i in range(len(advertising_handles))
+        ])
+
+    def __str__(self):
+        fields = [('enable:', self.enable)]
+        for i in range(len(self.advertising_handles)):
+            fields.append((f'advertising_handle[{i}]:             ', self.advertising_handles[i]))
+            fields.append((f'duration[{i}]:                       ', self.durations[i]))
+            fields.append((f'max_extended_advertising_events[{i}]:', self.max_extended_advertising_events[i]))
+
+        return color(self.name, 'green') + ':\n' + '\n'.join(
+            [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
+        )
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(return_parameters_fields=[
+    ('status',                      STATUS_SPEC),
+    ('max_advertising_data_length', 2)
+])
+class HCI_LE_Read_Maximum_Advertising_Data_Length_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.57 LE Read Maximum Advertising Data Length Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(return_parameters_fields=[
+    ('status',                         STATUS_SPEC),
+    ('num_supported_advertising_sets', 1)
+])
+class HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.58 LE Read Number of Supported Advertising Sets Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('advertising_handle', 1)
+])
+class HCI_LE_Remove_Advertising_Set_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.59 LE Remove Advertising Set Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command()
+class HCI_LE_Clear_Advertising_Sets_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.60 LE Clear Advertising Sets Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('enable',             1),
+    ('advertising_handle', 1)
+])
+class HCI_LE_Set_Periodic_Advertising_Enable_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.63 LE Set Periodic Advertising Enable Command
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Command.command(fields=None)
 class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command):
     '''
@@ -3102,11 +3543,8 @@
     EXTENDED_UNFILTERED_POLICY = 0x02
     EXTENDED_FILTERED_POLICY   = 0x03
 
-    LE_1M_PHY    = 0x00
-    LE_CODED_PHY = 0x02
-
-    @staticmethod
-    def from_parameters(parameters):
+    @classmethod
+    def from_parameters(cls, parameters):
         own_address_type       = parameters[0]
         scanning_filter_policy = parameters[1]
         scanning_phys          = parameters[2]
@@ -3116,11 +3554,11 @@
         scan_intervals = []
         scan_windows   = []
         for i in range(phy_bits_set):
-            scan_types.append(parameters[3 + (3 * i)])
-            scan_intervals.append(parameters[3 + (3 * i) + 1])
-            scan_windows.append(parameters[3 + (3 * i) + 2])
+            scan_types.append(parameters[3 + (5 * i)])
+            scan_intervals.append(struct.unpack_from('<H', parameters, 3 + (5 * i) + 1)[0])
+            scan_windows.append(struct.unpack_from('<H', parameters, 3 + (5 * i) + 3)[0])
 
-        return HCI_LE_Set_Extended_Scan_Parameters_Command(
+        return cls(
             own_address_type       = own_address_type,
             scanning_filter_policy = scanning_filter_policy,
             scanning_phys          = scanning_phys,
@@ -3149,20 +3587,10 @@
         self.parameters = bytes([own_address_type, scanning_filter_policy, scanning_phys])
         phy_bits_set = bin(scanning_phys).count('1')
         for i in range(phy_bits_set):
-            self.parameters += bytes([scan_types[i], scan_intervals[i], scan_windows[i]])
+            self.parameters += struct.pack('<BHH', scan_types[i], scan_intervals[i], scan_windows[i])
 
     def __str__(self):
-        scanning_phys_strs = []
-
-        for bit in range(8):
-            if self.scanning_phys & (1 << bit) != 0:
-                if bit == 0:
-                    scanning_phys_strs.append('LE_1M_PHY')
-                elif bit == 2:
-                    scanning_phys_strs.append('LE_CODED_PHY')
-                else:
-                    scanning_phys_strs.append(f'0x{(1 << bit):02X}')
-
+        scanning_phys_strs = bit_flags_to_strings(self.scanning_phys, HCI_LE_PHY_BIT_NAMES)
         fields = [
             ('own_address_type:      ', Address.address_type_name(self.own_address_type)),
             ('scanning_filter_policy:', self.scanning_filter_policy),
@@ -3179,6 +3607,165 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('enable',            1),
+    ('filter_duplicates', 1),
+    ('duration',          2),
+    ('period',            2)
+])
+class HCI_LE_Set_Extended_Scan_Enable_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.65 LE Set Extended Scan Enable Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(fields=None)
+class HCI_LE_Extended_Create_Connection_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.66 LE Extended Create Connection Command
+    '''
+
+    @classmethod
+    def from_parameters(cls, parameters):
+        initiator_filter_policy = parameters[0]
+        own_address_type        = parameters[1]
+        peer_address_type       = parameters[2]
+        peer_address            = Address.parse_address_preceded_by_type(parameters, 3)[1]
+        initiating_phys         = parameters[9]
+
+        phy_bits_set = bin(initiating_phys).count('1')
+
+        def read_parameter_list(offset):
+            return [struct.unpack_from('<H', parameters, offset + 16 * i)[0] for i in range(phy_bits_set)]
+
+        return cls(
+            initiator_filter_policy  = initiator_filter_policy,
+            own_address_type         = own_address_type,
+            peer_address_type        = peer_address_type,
+            peer_address             = peer_address,
+            initiating_phys          = initiating_phys,
+            scan_intervals           = read_parameter_list(10),
+            scan_windows             = read_parameter_list(12),
+            connection_interval_mins = read_parameter_list(14),
+            connection_interval_maxs = read_parameter_list(16),
+            max_latencies            = read_parameter_list(18),
+            supervision_timeouts     = read_parameter_list(20),
+            min_ce_lengths           = read_parameter_list(22),
+            max_ce_lengths           = read_parameter_list(24)
+        )
+
+    def __init__(
+        self,
+        initiator_filter_policy,
+        own_address_type,
+        peer_address_type,
+        peer_address,
+        initiating_phys,
+        scan_intervals,
+        scan_windows,
+        connection_interval_mins,
+        connection_interval_maxs,
+        max_latencies,
+        supervision_timeouts,
+        min_ce_lengths,
+        max_ce_lengths
+    ):
+        super().__init__(HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND)
+        self.initiator_filter_policy  = initiator_filter_policy
+        self.own_address_type         = own_address_type
+        self.peer_address_type        = peer_address_type
+        self.peer_address             = peer_address
+        self.initiating_phys          = initiating_phys
+        self.scan_intervals           = scan_intervals
+        self.scan_windows             = scan_windows
+        self.connection_interval_mins = connection_interval_mins
+        self.connection_interval_maxs = connection_interval_maxs
+        self.max_latencies            = max_latencies
+        self.supervision_timeouts     = supervision_timeouts
+        self.min_ce_lengths           = min_ce_lengths
+        self.max_ce_lengths           = max_ce_lengths
+
+        self.parameters = bytes([
+            initiator_filter_policy,
+            own_address_type,
+            peer_address_type
+        ]) + bytes(peer_address) + bytes([initiating_phys])
+
+        phy_bits_set = bin(initiating_phys).count('1')
+        for i in range(phy_bits_set):
+            self.parameters += struct.pack(
+                '<HHHHHHHH',
+                scan_intervals[i],
+                scan_windows[i],
+                connection_interval_mins[i],
+                connection_interval_maxs[i],
+                max_latencies[i],
+                supervision_timeouts[i],
+                min_ce_lengths[i],
+                max_ce_lengths[i]
+            )
+
+    def __str__(self):
+        initiating_phys_strs = bit_flags_to_strings(self.initiating_phys, HCI_LE_PHY_BIT_NAMES)
+        fields = [
+            ('initiator_filter_policy:', self.initiator_filter_policy),
+            ('own_address_type:       ', OwnAddressType.type_name(self.own_address_type)),
+            ('peer_address_type:      ', Address.address_type_name(self.peer_address_type)),
+            ('peer_address:           ', str(self.peer_address)),
+            ('initiating_phys:        ', ','.join(initiating_phys_strs)),
+        ]
+        for (i, initiating_phys_str) in enumerate(initiating_phys_strs):
+            fields.append((f'{initiating_phys_str}.scan_interval:          ', self.scan_intervals[i])),
+            fields.append((f'{initiating_phys_str}.scan_window:            ', self.scan_windows[i])),
+            fields.append((f'{initiating_phys_str}.connection_interval_min:', self.connection_interval_mins[i])),
+            fields.append((f'{initiating_phys_str}.connection_interval_max:', self.connection_interval_maxs[i])),
+            fields.append((f'{initiating_phys_str}.max_latency:            ', self.max_latencies[i])),
+            fields.append((f'{initiating_phys_str}.supervision_timeout:    ', self.supervision_timeouts[i])),
+            fields.append((f'{initiating_phys_str}.min_ce_length:          ', self.min_ce_lengths[i])),
+            fields.append((f'{initiating_phys_str}.max_ce_length:          ', self.max_ce_lengths[i]))
+
+        return color(self.name, 'green') + ':\n' + '\n'.join(
+            [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
+        )
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+    ('peer_identity_address_type', Address.ADDRESS_TYPE_SPEC),
+    ('peer_identity_address',      Address.parse_address_preceded_by_type),
+    ('privacy_mode',               {'size': 1, 'mapper': lambda x: HCI_LE_Set_Privacy_Mode_Command.privacy_mode_name(x)})
+])
+class HCI_LE_Set_Privacy_Mode_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.8.77 LE Set Privacy Mode Command
+    '''
+
+    NETWORK_PRIVACY_MODE = 0x00
+    DEVICE_PRIVACY_MODE  = 0x01
+
+    PRIVACY_MODE_NAMES = {
+        NETWORK_PRIVACY_MODE: 'NETWORK_PRIVACY_MODE',
+        DEVICE_PRIVACY_MODE:  'DEVICE_PRIVACY_MODE'
+    }
+
+    @classmethod
+    def privacy_mode_name(cls, privacy_mode):
+        return name_or_number(cls.PRIVACY_MODE_NAMES, privacy_mode)
+
+
+# -----------------------------------------------------------------------------
+@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):
@@ -3353,15 +3940,15 @@
 
 # -----------------------------------------------------------------------------
 @HCI_LE_Meta_Event.event([
-    ('status',                STATUS_SPEC),
-    ('connection_handle',     2),
-    ('role',                  {'size': 1, 'mapper': lambda x: 'CENTRAL' if x == 0 else 'PERIPHERAL'}),
-    ('peer_address_type',     Address.ADDRESS_TYPE_SPEC),
-    ('peer_address',          Address.parse_address_preceded_by_type),
-    ('conn_interval',         2),
-    ('conn_latency',          2),
-    ('supervision_timeout',   2),
-    ('master_clock_accuracy', 1)
+    ('status',                 STATUS_SPEC),
+    ('connection_handle',      2),
+    ('role',                   {'size': 1, 'mapper': lambda x: 'CENTRAL' if x == 0 else 'PERIPHERAL'}),
+    ('peer_address_type',      Address.ADDRESS_TYPE_SPEC),
+    ('peer_address',           Address.parse_address_preceded_by_type),
+    ('connection_interval',    2),
+    ('peripheral_latency',     2),
+    ('supervision_timeout',    2),
+    ('central_clock_accuracy', 1)
 ])
 class HCI_LE_Connection_Complete_Event(HCI_LE_Meta_Event):
     '''
@@ -3391,29 +3978,44 @@
         SCAN_RSP:        'SCAN_RSP'          # Scan Response
     }
 
-    REPORT_FIELDS = [
-        ('event_type',   1),
-        ('address_type', Address.ADDRESS_TYPE_SPEC),
-        ('address',      Address.parse_address_preceded_by_type),
-        ('data',         {'parser': HCI_Object.parse_length_prefixed_bytes, 'serializer': HCI_Object.serialize_length_prefixed_bytes}),
-        ('rssi',         -1)
-    ]
+    class Report(HCI_Object):
+        FIELDS = [
+            ('event_type',   1),
+            ('address_type', Address.ADDRESS_TYPE_SPEC),
+            ('address',      Address.parse_address_preceded_by_type),
+            ('data',         {'parser': HCI_Object.parse_length_prefixed_bytes, 'serializer': HCI_Object.serialize_length_prefixed_bytes}),
+            ('rssi',         -1)
+        ]
+
+        @classmethod
+        def from_parameters(cls, parameters, offset):
+            return cls.from_bytes(parameters, offset, cls.FIELDS)
+
+        def event_type_string(self):
+            return HCI_LE_Advertising_Report_Event.event_type_name(self.event_type)
+
+        def to_string(self, prefix):
+            return super().to_string(prefix, {
+                'event_type': HCI_LE_Advertising_Report_Event.event_type_name,
+                'address_type': Address.address_type_name,
+                'data': lambda x: str(AdvertisingData.from_bytes(x))
+            })
 
     @classmethod
     def event_type_name(cls, event_type):
         return name_or_number(cls.EVENT_TYPE_NAMES, event_type)
 
-    @staticmethod
-    def from_parameters(parameters):
+    @classmethod
+    def from_parameters(cls, parameters):
         num_reports = parameters[1]
         reports = []
         offset = 2
         for _ in range(num_reports):
-            report = HCI_Object.from_bytes(parameters, offset, HCI_LE_Advertising_Report_Event.REPORT_FIELDS)
+            report = cls.Report.from_parameters(parameters, offset)
             offset += 10 + len(report.data)
             reports.append(report)
 
-        return HCI_LE_Advertising_Report_Event(reports)
+        return cls(reports)
 
     def __init__(self, reports):
         self.reports = reports[:]
@@ -3424,11 +4026,7 @@
         super().__init__(self.subevent_code, parameters)
 
     def __str__(self):
-        reports = '\n'.join([report.to_string('  ', {
-            'event_type':   self.event_type_name,
-            'address_type': Address.address_type_name,
-            'data': lambda x: str(AdvertisingData.from_bytes(x))
-        }) for report in self.reports])
+        reports = '\n'.join([f'{i}:\n{report.to_string("  ")}' for i, report in enumerate(self.reports)])
         return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
 
 
@@ -3439,8 +4037,8 @@
 @HCI_LE_Meta_Event.event([
     ('status',              STATUS_SPEC),
     ('connection_handle',   2),
-    ('conn_interval',       2),
-    ('conn_latency',        2),
+    ('connection_interval', 2),
+    ('peripheral_latency',  2),
     ('supervision_timeout', 2)
 ])
 class HCI_LE_Connection_Update_Complete_Event(HCI_LE_Meta_Event):
@@ -3478,7 +4076,7 @@
     ('connection_handle', 2),
     ('interval_min',      2),
     ('interval_max',      2),
-    ('latency',           2),
+    ('max_latency',       2),
     ('timeout',           2)
 ])
 class HCI_LE_Remote_Connection_Parameter_Request_Event(HCI_LE_Meta_Event):
@@ -3510,10 +4108,10 @@
     ('peer_address',                     Address.parse_address_preceded_by_type),
     ('local_resolvable_private_address', Address.parse_address),
     ('peer_resolvable_private_address',  Address.parse_address),
-    ('conn_interval',                    2),
-    ('conn_latency',                     2),
+    ('connection_interval',              2),
+    ('peripheral_latency',               2),
     ('supervision_timeout',              2),
-    ('master_clock_accuracy',            1)
+    ('central_clock_accuracy',           1)
 ])
 class HCI_LE_Enhanced_Connection_Complete_Event(HCI_LE_Meta_Event):
     '''
@@ -3535,6 +4133,124 @@
 
 
 # -----------------------------------------------------------------------------
+class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
+    '''
+    See Bluetooth spec @ 7.7.65.13 LE Extended Advertising Report Event
+    '''
+    subevent_code = HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT
+
+    # Event types flags
+    CONNECTABLE_ADVERTISING     = 0
+    SCANNABLE_ADVERTISING       = 1
+    DIRECTED_ADVERTISING        = 2
+    SCAN_RESPONSE               = 3
+    LEGACY_ADVERTISING_PDU_USED = 4
+
+    DATA_COMPLETE                             = 0x00
+    DATA_INCOMPLETE_MORE_TO_COME              = 0x01
+    DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME = 0x02
+
+    EVENT_TYPE_FLAG_NAMES = (
+        'CONNECTABLE_ADVERTISING',
+        'SCANNABLE_ADVERTISING',
+        'DIRECTED_ADVERTISING',
+        'SCAN_RESPONSE',
+        'LEGACY_ADVERTISING_PDU_USED'
+    )
+
+    LEGACY_PDU_TYPE_MAP = {
+        0b0011: HCI_LE_Advertising_Report_Event.ADV_IND,
+        0b0101: HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND,
+        0b0010: HCI_LE_Advertising_Report_Event.ADV_SCAN_IND,
+        0b0000: HCI_LE_Advertising_Report_Event.ADV_NONCONN_IND,
+        0b1011: HCI_LE_Advertising_Report_Event.SCAN_RSP,
+        0b1010: HCI_LE_Advertising_Report_Event.SCAN_RSP
+    }
+
+    NO_ADI_FIELD_PROVIDED              = 0xFF
+    TX_POWER_INFORMATION_NOT_AVAILABLE = 0x7F
+    RSSI_NOT_AVAILABLE                 = 0x7F
+    ANONYMOUS_ADDRESS_TYPE             = 0xFF
+    UNRESOLVED_RESOLVABLE_ADDRESS_TYPE = 0xFE
+
+    class Report(HCI_Object):
+        FIELDS = [
+            ('event_type',                    2),
+            ('address_type',                  Address.ADDRESS_TYPE_SPEC),
+            ('address',                       Address.parse_address_preceded_by_type),
+            ('primary_phy',                   {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+            ('secondary_phy',                 {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+            ('advertising_sid',               1),
+            ('tx_power',                      1),
+            ('rssi',                          -1),
+            ('periodic_advertising_interval', 2),
+            ('direct_address_type',           Address.ADDRESS_TYPE_SPEC),
+            ('direct_address',                Address.parse_address_preceded_by_type),
+            ('data',                          {'parser': HCI_Object.parse_length_prefixed_bytes, 'serializer': HCI_Object.serialize_length_prefixed_bytes}),
+        ]
+
+        @classmethod
+        def from_parameters(cls, parameters, offset):
+            return cls.from_bytes(parameters, offset, cls.FIELDS)
+
+        def event_type_string(self):
+            return HCI_LE_Extended_Advertising_Report_Event.event_type_string(self.event_type)
+
+        def to_string(self, prefix):
+            return super().to_string(prefix, {
+                'event_type': HCI_LE_Extended_Advertising_Report_Event.event_type_string,
+                'address_type': Address.address_type_name,
+                'data': lambda x: str(AdvertisingData.from_bytes(x))
+            })
+
+    @staticmethod
+    def event_type_string(event_type):
+        event_type_flags = bit_flags_to_strings(
+            event_type & 0x1F,
+            HCI_LE_Extended_Advertising_Report_Event.EVENT_TYPE_FLAG_NAMES,
+        )
+        event_type_flags.append(('COMPLETE', 'INCOMPLETE+', 'INCOMPLETE#', '?')[(event_type >> 5) & 3])
+
+        if event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.LEGACY_ADVERTISING_PDU_USED):
+            legacy_pdu_type = HCI_LE_Extended_Advertising_Report_Event.LEGACY_PDU_TYPE_MAP.get(event_type & 0x0F)
+            if legacy_pdu_type is not None:
+                legacy_info_string = f'({HCI_LE_Advertising_Report_Event.event_type_name(legacy_pdu_type)})'
+            else:
+                legacy_info_string = ''
+        else:
+            legacy_info_string = ''
+
+        return f'0x{event_type:04X} [{",".join(event_type_flags)}]{legacy_info_string}'
+
+    @classmethod
+    def from_parameters(cls, parameters):
+        num_reports = parameters[1]
+        reports = []
+        offset = 2
+        for _ in range(num_reports):
+            report = cls.Report.from_parameters(parameters, offset)
+            offset += 24 + len(report.data)
+            reports.append(report)
+
+        return cls(reports)
+
+    def __init__(self, reports):
+        self.reports = reports[:]
+
+        # Serialize the fields
+        parameters = bytes([HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT, len(reports)]) + b''.join([bytes(report) for report in reports])
+
+        super().__init__(self.subevent_code, parameters)
+
+    def __str__(self):
+        reports = '\n'.join([f'{i}:\n{report.to_string("  ")}' for i, report in enumerate(self.reports)])
+        return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
+
+
+HCI_Event.meta_event_classes[HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT] = HCI_LE_Extended_Advertising_Report_Event
+
+
+# -----------------------------------------------------------------------------
 @HCI_LE_Meta_Event.event([
     ('connection_handle',           2),
     ('channel_selection_algorithm', 1)
@@ -3686,7 +4402,7 @@
     E0_OR_AES_CCM = 0x01
     AES_CCM       = 0x02
 
-    ENCYRPTION_ENABLED_NAMES = {
+    ENCRYPTION_ENABLED_NAMES = {
         OFF:           'OFF',
         E0_OR_AES_CCM: 'E0_OR_AES_CCM',
         AES_CCM:       'AES_CCM'
@@ -3694,7 +4410,7 @@
 
     @staticmethod
     def encryption_enabled_name(encryption_enabled):
-        return name_or_number(HCI_Encryption_Change_Event.ENCYRPTION_ENABLED_NAMES, encryption_enabled)
+        return name_or_number(HCI_Encryption_Change_Event.ENCRYPTION_ENABLED_NAMES, encryption_enabled)
 
 
 # -----------------------------------------------------------------------------
@@ -3954,7 +4670,7 @@
 
 # -----------------------------------------------------------------------------
 @HCI_Event.registered
-class HCI_Inquiry_Result_With_Rssi_Event(HCI_Event):
+class HCI_Inquiry_Result_With_RSSI_Event(HCI_Event):
     '''
     See Bluetooth spec @ 7.7.33 Inquiry Result with RSSI Event
     '''
@@ -3974,11 +4690,11 @@
         responses = []
         offset = 1
         for _ in range(num_responses):
-            response = HCI_Object.from_bytes(parameters, offset, HCI_Inquiry_Result_With_Rssi_Event.RESPONSE_FIELDS)
+            response = HCI_Object.from_bytes(parameters, offset, HCI_Inquiry_Result_With_RSSI_Event.RESPONSE_FIELDS)
             offset += 14
             responses.append(response)
 
-        return HCI_Inquiry_Result_With_Rssi_Event(responses)
+        return HCI_Inquiry_Result_With_RSSI_Event(responses)
 
     def __init__(self, responses):
         self.responses = responses[:]
@@ -4041,7 +4757,7 @@
         U_LAW_LOG_AIR_MODE:        'u-law log',
         A_LAW_LOG_AIR_MORE:        'A-law log',
         CVSD_AIR_MODE:             'CVSD',
-        TRANSPARENT_DATA_AIR_MODE: 'Transparend Data'
+        TRANSPARENT_DATA_AIR_MODE: 'Transparent Data'
     }
 
     @staticmethod
@@ -4164,6 +4880,17 @@
 
 # -----------------------------------------------------------------------------
 @HCI_Event.event([
+    ('bd_addr', Address.parse_address),
+    ('passkey', 4)
+])
+class HCI_User_Passkey_Notification_Event(HCI_Event):
+    '''
+    See Bluetooth spec @ 7.7.48 User Passkey Notification Event
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Event.event([
     ('bd_addr',                 Address.parse_address),
     ('host_supported_features', 8)
 ])
diff --git a/bumble/host.py b/bumble/host.py
index cc692d0..8e43c50 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -76,9 +76,11 @@
         self.hc_total_num_acl_data_packets    = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
         self.acl_packet_queue                 = collections.deque()
         self.acl_packets_in_flight            = 0
-        self.local_version                    = HCI_VERSION_BLUETOOTH_CORE_4_0
+        self.local_version                    = None
         self.local_supported_commands         = bytes(64)
         self.local_le_features                = 0
+        self.suggested_max_tx_octets          = 251   # Max allowed
+        self.suggested_max_tx_time            = 2120  # Max allowed
         self.command_semaphore                = asyncio.Semaphore(1)
         self.long_term_key_provider           = None
         self.link_key_provider                = None
@@ -91,32 +93,23 @@
             self.set_packet_sink(controller_sink)
 
     async def reset(self):
-        await self.send_command(HCI_Reset_Command())
+        await self.send_command(HCI_Reset_Command(), check_result=True)
         self.ready = True
 
-        response = await self.send_command(HCI_Read_Local_Supported_Commands_Command())
-        if response.return_parameters.status == HCI_SUCCESS:
-            self.local_supported_commands = response.return_parameters.supported_commands
-        else:
-            logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}')
+        response = await self.send_command(HCI_Read_Local_Supported_Commands_Command(), check_result=True)
+        self.local_supported_commands = response.return_parameters.supported_commands
 
         if self.supports_command(HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
-            response = await self.send_command(HCI_LE_Read_Local_Supported_Features_Command())
-            if response.return_parameters.status == HCI_SUCCESS:
-                self.local_le_features = struct.unpack('<Q', response.return_parameters.le_features)[0]
-            else:
-                logger.warn(f'HCI_LE_Read_Supported_Features_Command failed: {response.return_parameters.status}')
+            response = await self.send_command(HCI_LE_Read_Local_Supported_Features_Command(), check_result=True)
+            self.local_le_features = struct.unpack('<Q', response.return_parameters.le_features)[0]
 
         if self.supports_command(HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
-            response = await self.send_command(HCI_Read_Local_Version_Information_Command())
-            if response.return_parameters.status == HCI_SUCCESS:
-                self.local_version = response.return_parameters
-            else:
-                logger.warn(f'HCI_Read_Local_Version_Information_Command failed: {response.return_parameters.status}')
+            response = await self.send_command(HCI_Read_Local_Version_Information_Command(), check_result=True)
+            self.local_version = response.return_parameters
 
         await self.send_command(HCI_Set_Event_Mask_Command(event_mask = bytes.fromhex('FFFFFFFFFFFFFF3F')))
 
-        if self.local_version.hci_version <= HCI_VERSION_BLUETOOTH_CORE_4_0:
+        if self.local_version is not None and self.local_version.hci_version <= HCI_VERSION_BLUETOOTH_CORE_4_0:
             # Some older controllers don't like event masks with bits they don't understand
             le_event_mask = bytes.fromhex('1F00000000000000')
         else:
@@ -124,34 +117,48 @@
         await self.send_command(HCI_LE_Set_Event_Mask_Command(le_event_mask = le_event_mask))
 
         if self.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
-            response = await self.send_command(HCI_Read_Buffer_Size_Command())
-            if response.return_parameters.status == HCI_SUCCESS:
-                self.hc_acl_data_packet_length     = response.return_parameters.hc_acl_data_packet_length
-                self.hc_total_num_acl_data_packets = response.return_parameters.hc_total_num_acl_data_packets
-            else:
-                logger.warn(f'HCI_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
+            response = await self.send_command(HCI_Read_Buffer_Size_Command(), check_result=True)
+            self.hc_acl_data_packet_length     = response.return_parameters.hc_acl_data_packet_length
+            self.hc_total_num_acl_data_packets = response.return_parameters.hc_total_num_acl_data_packets
+
+            logger.debug(
+                f'HCI ACL flow control: hc_acl_data_packet_length={self.hc_acl_data_packet_length},'
+                f'hc_total_num_acl_data_packets={self.hc_total_num_acl_data_packets}'
+            )
 
         if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
-            response = await self.send_command(HCI_LE_Read_Buffer_Size_Command())
-            if response.return_parameters.status == HCI_SUCCESS:
-                self.hc_le_acl_data_packet_length     = response.return_parameters.hc_le_acl_data_packet_length
-                self.hc_total_num_le_acl_data_packets = response.return_parameters.hc_total_num_le_acl_data_packets
-            else:
-                logger.warn(f'HCI_LE_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
+            response = await self.send_command(HCI_LE_Read_Buffer_Size_Command(), check_result=True)
+            self.hc_le_acl_data_packet_length     = response.return_parameters.hc_le_acl_data_packet_length
+            self.hc_total_num_le_acl_data_packets = response.return_parameters.hc_total_num_le_acl_data_packets
 
-        if response.return_parameters.hc_le_acl_data_packet_length == 0 or response.return_parameters.hc_total_num_le_acl_data_packets == 0:
-            # LE and Classic share the same values
-            self.hc_le_acl_data_packet_length     = self.hc_acl_data_packet_length
-            self.hc_total_num_le_acl_data_packets = self.hc_total_num_acl_data_packets
+            logger.debug(
+                f'HCI LE ACL flow control: hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length},'
+                f'hc_total_num_le_acl_data_packets={self.hc_total_num_le_acl_data_packets}'
+            )
 
-        logger.debug(
-            f'HCI ACL flow control: hc_acl_data_packet_length={self.hc_acl_data_packet_length},'
-            f'hc_total_num_acl_data_packets={self.hc_total_num_acl_data_packets}'
-        )
-        logger.debug(
-            f'HCI LE ACL flow control: hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length},'
-            f'hc_total_num_le_acl_data_packets={self.hc_total_num_le_acl_data_packets}'
-        )
+            if (
+                response.return_parameters.hc_le_acl_data_packet_length == 0 or
+                response.return_parameters.hc_total_num_le_acl_data_packets == 0
+            ):
+                # LE and Classic share the same values
+                self.hc_le_acl_data_packet_length     = self.hc_acl_data_packet_length
+                self.hc_total_num_le_acl_data_packets = self.hc_total_num_acl_data_packets
+
+        if (
+            self.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND) and
+            self.supports_command(HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND)
+        ):
+            response = await self.send_command(HCI_LE_Read_Suggested_Default_Data_Length_Command())
+            suggested_max_tx_octets = response.return_parameters.suggested_max_tx_octets
+            suggested_max_tx_time   = response.return_parameters.suggested_max_tx_time
+            if (
+                suggested_max_tx_octets != self.suggested_max_tx_octets or
+                suggested_max_tx_time != self.suggested_max_tx_time
+            ):
+                await self.send_command(HCI_LE_Write_Suggested_Default_Data_Length_Command(
+                    suggested_max_tx_octets = self.suggested_max_tx_octets,
+                    suggested_max_tx_time   = self.suggested_max_tx_time
+                ))
 
         self.reset_done = True
 
@@ -171,7 +178,7 @@
     def send_hci_packet(self, packet):
         self.hci_sink.on_packet(packet.to_bytes())
 
-    async def send_command(self, command):
+    async def send_command(self, command, check_result=False):
         logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
 
         # Wait until we can send (only one pending command at a time)
@@ -186,11 +193,25 @@
             try:
                 self.send_hci_packet(command)
                 response = await self.pending_response
-                # TODO: check error values
+
+                # Check the return parameters if required
+                if check_result:
+                    if type(response.return_parameters) is int:
+                        status = response.return_parameters
+                    elif type(response.return_parameters) is bytes:
+                        # return parameters first field is a one byte status code
+                        status = response.return_parameters[0]
+                    else:
+                        status = response.return_parameters.status
+
+                    if status != HCI_SUCCESS:
+                        logger.warning(f'{command.name} failed ({HCI_Constant.error_name(status)})')
+                        raise HCI_Error(status)
+
                 return response
             except Exception as error:
                 logger.warning(f'{color("!!! Exception while sending HCI packet:", "red")} {error}')
-                # raise error
+                raise error
             finally:
                 self.pending_command = None
                 self.pending_response = None
@@ -348,13 +369,12 @@
 
     # Classic only
     def on_hci_connection_request_event(self, event):
-        # For now, just accept everything
-        # TODO: delegate the decision
-        self.send_command_sync(
-            HCI_Accept_Connection_Request_Command(
-                bd_addr = event.bd_addr,
-                role    = 0x01  # Remain the peripheral
-            )
+        # Notify the listeners
+        self.emit(
+            'connection_request',
+            event.bd_addr,
+            event.class_of_device,
+            event.link_type,
         )
 
     def on_hci_le_connection_complete_event(self, event):
@@ -370,8 +390,8 @@
 
             # Notify the client
             connection_parameters = ConnectionParameters(
-                event.conn_interval,
-                event.conn_latency,
+                event.connection_interval,
+                event.peripheral_latency,
                 event.supervision_timeout
             )
             self.emit(
@@ -387,7 +407,7 @@
             logger.debug(f'### CONNECTION FAILED: {event.status}')
 
             # Notify the listeners
-            self.emit('connection_failure', event.status)
+            self.emit('connection_failure', BT_LE_TRANSPORT, event.peer_address, event.status)
 
     def on_hci_le_enhanced_connection_complete_event(self, event):
         # Just use the same implementation as for the non-enhanced event for now
@@ -417,7 +437,7 @@
             logger.debug(f'### BR/EDR CONNECTION FAILED: {event.status}')
 
             # Notify the client
-            self.emit('connection_failure', event.connection_handle, event.status)
+            self.emit('connection_failure', BT_BR_EDR_TRANSPORT, event.bd_addr, event.status)
 
     def on_hci_disconnection_complete_event(self, event):
         # Find the connection
@@ -435,7 +455,7 @@
             logger.debug(f'### DISCONNECTION FAILED: {event.status}')
 
             # Notify the listeners
-            self.emit('disconnection_failure', event.status)
+            self.emit('disconnection_failure', event.connection_handle, event.status)
 
     def on_hci_le_connection_update_complete_event(self, event):
         if (connection := self.connections.get(event.connection_handle)) is None:
@@ -445,8 +465,8 @@
         # Notify the client
         if event.status == HCI_SUCCESS:
             connection_parameters = ConnectionParameters(
-                event.conn_interval,
-                event.conn_latency,
+                event.connection_interval,
+                event.peripheral_latency,
                 event.supervision_timeout
             )
             self.emit('connection_parameters_update', connection.handle, connection_parameters)
@@ -467,13 +487,10 @@
 
     def on_hci_le_advertising_report_event(self, event):
         for report in event.reports:
-            self.emit(
-                'advertising_report',
-                report.address,
-                report.data,
-                report.rssi,
-                report.event_type
-            )
+            self.emit('advertising_report', report)
+
+    def on_hci_le_extended_advertising_report_event(self, event):
+        self.on_hci_le_advertising_report_event(event)
 
     def on_hci_le_remote_connection_parameter_request_event(self, event):
         if event.connection_handle not in self.connections:
@@ -489,8 +506,8 @@
                 interval_max      = event.interval_max,
                 latency           = event.latency,
                 timeout           = event.timeout,
-                minimum_ce_length = 0,
-                maximum_ce_length = 0
+                min_ce_length     = 0,
+                max_ce_length     = 0
             )
         )
 
@@ -625,6 +642,9 @@
     def on_hci_user_passkey_request_event(self, event):
         self.emit('authentication_user_passkey_request', event.bd_addr)
 
+    def on_hci_user_passkey_notification_event(self, event):
+        self.emit('authentication_user_passkey_notification', event.bd_addr, event.passkey)
+
     def on_hci_inquiry_complete_event(self, event):
         self.emit('inquiry_complete')
 
@@ -652,3 +672,6 @@
             self.emit('remote_name_failure', event.bd_addr, event.status)
         else:
             self.emit('remote_name', event.bd_addr, event.remote_name)
+
+    def on_hci_remote_host_supported_features_notification_event(self, event):
+        self.emit('remote_host_supported_features', event.bd_addr, event.host_supported_features)
diff --git a/bumble/l2cap.py b/bumble/l2cap.py
index e39dff1..c61a45f 100644
--- a/bumble/l2cap.py
+++ b/bumble/l2cap.py
@@ -19,6 +19,7 @@
 import logging
 import struct
 
+from collections import deque
 from colors import color
 from pyee import EventEmitter
 
@@ -43,13 +44,23 @@
 
 L2CAP_DEFAULT_MTU = 2048  # Default value for the MTU we are willing to accept
 
+L2CAP_DEFAULT_CONNECTIONLESS_MTU = 1024
+
 # See Bluetooth spec @ Vol 3, Part A - Table 2.1: CID name space on ACL-U, ASB-U, and AMP-U logical links
 L2CAP_ACL_U_DYNAMIC_CID_RANGE_START = 0x0040
 L2CAP_ACL_U_DYNAMIC_CID_RANGE_END   = 0xFFFF
 
 # See Bluetooth spec @ Vol 3, Part A - Table 2.2: CID name space on LE-U logical link
 L2CAP_LE_U_DYNAMIC_CID_RANGE_START = 0x0040
-L2CAP_LE_U_DYNAMIC_CID_RANGE_START = 0x007F
+L2CAP_LE_U_DYNAMIC_CID_RANGE_END   = 0x007F
+
+# PSM Range - See Bluetooth spec @ Vol 3, Part A / Table 4.5: PSM ranges and usage
+L2CAP_PSM_DYNAMIC_RANGE_START = 0x1001
+L2CAP_PSM_DYNAMIC_RANGE_END   = 0xFFFF
+
+# LE PSM Ranges - See Bluetooth spec @ Vol 3, Part A / Table 4.19: LE Credit Based Connection Request LE_PSM ranges
+L2CAP_LE_PSM_DYNAMIC_RANGE_START = 0x0080
+L2CAP_LE_PSM_DYNAMIC_RANGE_END   = 0x00FF
 
 # Frame types
 L2CAP_COMMAND_REJECT                       = 0x01
@@ -107,8 +118,13 @@
 L2CAP_SIGNALING_MTU_EXCEEDED_REASON = 0x0001
 L2CAP_INVALID_CID_IN_REQUEST_REASON = 0x0002
 
-L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU = 2048
-L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS = 2048
+L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS             = 65535
+L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU                 = 23
+L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS                 = 23
+L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS                 = 65533
+L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU             = 2046
+L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS             = 2048
+L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS = 256
 
 L2CAP_MAXIMUM_TRANSMISSION_UNIT_CONFIGURATION_OPTION_TYPE = 0x01
 
@@ -172,7 +188,7 @@
         self.identifier = pdu[1]
         length = struct.unpack_from('<H', pdu, 2)[0]
         if length + 4 != len(pdu):
-            logger.warn(color(f'!!! length mismatch: expected {len(pdu) - 4} but got {length}', 'red'))
+            logger.warning(color(f'!!! length mismatch: expected {len(pdu) - 4} but got {length}', 'red'))
         if hasattr(self, 'fields'):
             self.init_from_bytes(pdu, 4)
         return self
@@ -185,10 +201,10 @@
     def decode_configuration_options(data):
         options = []
         while len(data) >= 2:
-            type = data[0]
+            type   = data[0]
             length = data[1]
-            value = data[2:2 + length]
-            data = data[2 + length:]
+            value  = data[2:2 + length]
+            data   = data[2 + length:]
             options.append((type, value))
 
         return options
@@ -268,7 +284,10 @@
 
 # -----------------------------------------------------------------------------
 @L2CAP_Control_Frame.subclass([
-    ('psm',        2),
+    ('psm', {
+        'parser': lambda data, offset: L2CAP_Connection_Request.parse_psm(data, offset),
+        'serializer': lambda value: L2CAP_Connection_Request.serialize_psm(value)
+    }),
     ('source_cid', 2)
 ])
 class L2CAP_Connection_Request(L2CAP_Control_Frame):
@@ -276,6 +295,28 @@
     See Bluetooth spec @ Vol 3, Part A - 4.2 CONNECTION REQUEST
     '''
 
+    @staticmethod
+    def parse_psm(data, offset=0):
+        psm_length = 2
+        psm = data[offset] | data[offset + 1] << 8
+
+        # The PSM field extends until the first even octet (inclusive)
+        while data[offset + psm_length - 1] % 2 == 1:
+            psm |= data[offset + psm_length] << (8 * psm_length)
+            psm_length += 1
+
+        return offset + psm_length, psm
+
+    @staticmethod
+    def serialize_psm(psm):
+        serialized = struct.pack('<H', psm & 0xFFFF)
+        psm >>= 16
+        while psm:
+            serialized += bytes([psm & 0xFF])
+            psm >>= 8
+
+        return serialized
+
 
 # -----------------------------------------------------------------------------
 @L2CAP_Control_Frame.subclass([
@@ -289,16 +330,16 @@
     See Bluetooth spec @ Vol 3, Part A - 4.3 CONNECTION RESPONSE
     '''
 
-    CONNECTION_SUCCESSFUL                               = 0x0000
-    CONNECTION_PENDING                                  = 0x0001
-    CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED             = 0x0002
-    CONNECTION_REFUSED_SECURITY_BLOCK                   = 0x0003
-    CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE           = 0x0004
-    CONNECTION_REFUSED_INVALID_SOURCE_CID               = 0x0006
-    CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED     = 0x0007
-    CONNECTION_REFUSED_UNACCEPTABLE_PARAMETERS          = 0x000B
+    CONNECTION_SUCCESSFUL                           = 0x0000
+    CONNECTION_PENDING                              = 0x0001
+    CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED         = 0x0002
+    CONNECTION_REFUSED_SECURITY_BLOCK               = 0x0003
+    CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE       = 0x0004
+    CONNECTION_REFUSED_INVALID_SOURCE_CID           = 0x0006
+    CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED = 0x0007
+    CONNECTION_REFUSED_UNACCEPTABLE_PARAMETERS      = 0x000B
 
-    CONNECTION_RESULT_NAMES = {
+    RESULT_NAMES = {
         CONNECTION_SUCCESSFUL:                           'CONNECTION_SUCCESSFUL',
         CONNECTION_PENDING:                              'CONNECTION_PENDING',
         CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED:         'CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED',
@@ -311,7 +352,7 @@
 
     @staticmethod
     def result_name(result):
-        return name_or_number(L2CAP_Connection_Response.CONNECTION_RESULT_NAMES, result)
+        return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
 
 
 # -----------------------------------------------------------------------------
@@ -462,10 +503,10 @@
 
 # -----------------------------------------------------------------------------
 @L2CAP_Control_Frame.subclass([
-    ('interval_min',       2),
-    ('interval_max',       2),
-    ('slave_latency',      2),
-    ('timeout_multiplier', 2)
+    ('interval_min', 2),
+    ('interval_max', 2),
+    ('latency',      2),
+    ('timeout',      2)
 ])
 class L2CAP_Connection_Parameter_Update_Request(L2CAP_Control_Frame):
     '''
@@ -485,10 +526,10 @@
 
 # -----------------------------------------------------------------------------
 @L2CAP_Control_Frame.subclass([
-    ('le_psm', 2),
-    ('source_cid', 2),
-    ('mtu', 2),
-    ('mps', 2),
+    ('le_psm',          2),
+    ('source_cid',      2),
+    ('mtu',             2),
+    ('mps',             2),
     ('initial_credits', 2)
 ])
 class L2CAP_LE_Credit_Based_Connection_Request(L2CAP_Control_Frame):
@@ -521,7 +562,7 @@
     CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED     = 0x000A
     CONNECTION_REFUSED_UNACCEPTABLE_PARAMETERS          = 0x000B
 
-    CONNECTION_RESULT_NAMES = {
+    RESULT_NAMES = {
         CONNECTION_SUCCESSFUL:                               'CONNECTION_SUCCESSFUL',
         CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED:             'CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED',
         CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE:           'CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE',
@@ -536,12 +577,12 @@
 
     @staticmethod
     def result_name(result):
-        return name_or_number(L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_RESULT_NAMES, result)
+        return name_or_number(L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result)
 
 
 # -----------------------------------------------------------------------------
 @L2CAP_Control_Frame.subclass([
-    ('cid', 2),
+    ('cid',     2),
     ('credits', 2)
 ])
 class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
@@ -619,6 +660,9 @@
     def send_pdu(self, pdu):
         self.manager.send_pdu(self.connection, self.destination_cid, pdu)
 
+    def send_control_frame(self, frame):
+        self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
+
     async def send_request(self, request):
         # Check that there isn't already a request pending
         if self.response:
@@ -637,15 +681,16 @@
         elif self.sink:
             self.sink(pdu)
         else:
-            logger.warn(color('received pdu without a pending request or sink', 'red'))
-
-    def send_control_frame(self, frame):
-        self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
+            logger.warning(color('received pdu without a pending request or sink', 'red'))
 
     async def connect(self):
         if self.state != Channel.CLOSED:
             raise InvalidStateError('invalid state')
 
+        # Check that we can start a new connection
+        if self.connection_result:
+            raise RuntimeError('connection already pending')
+
         self.change_state(Channel.WAIT_CONNECT_RSP)
         self.send_control_frame(
             L2CAP_Connection_Request(
@@ -657,7 +702,12 @@
 
         # Create a future to wait for the state machine to get to a success or error state
         self.connection_result = asyncio.get_running_loop().create_future()
-        return await self.connection_result
+
+        # Wait for the connection to succeed or fail
+        try:
+            return await self.connection_result
+        finally:
+            self.connection_result = None
 
     async def disconnect(self):
         if self.state != Channel.OPEN:
@@ -708,7 +758,7 @@
 
     def on_connection_response(self, response):
         if self.state != Channel.WAIT_CONNECT_RSP:
-            logger.warn(color('invalid state', 'red'))
+            logger.warning(color('invalid state', 'red'))
             return
 
         if response.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL:
@@ -734,7 +784,7 @@
             self.state != Channel.WAIT_CONFIG_REQ and
             self.state != Channel.WAIT_CONFIG_REQ_RSP
         ):
-            logger.warn(color('invalid state', 'red'))
+            logger.warning(color('invalid state', 'red'))
             return
 
         # Decode the options
@@ -750,7 +800,7 @@
                 source_cid = self.destination_cid,
                 flags      = 0x0000,
                 result     = L2CAP_Configure_Response.SUCCESS,
-                options    = request.options  # TODO: don't accept everthing blindly
+                options    = request.options  # TODO: don't accept everything blindly
             )
         )
         if self.state == Channel.WAIT_CONFIG:
@@ -777,7 +827,7 @@
                     self.connection_result = None
                 self.emit('open')
             else:
-                logger.warn(color('invalid state', 'red'))
+                logger.warning(color('invalid state', 'red'))
         elif response.result == L2CAP_Configure_Response.FAILURE_UNACCEPTABLE_PARAMETERS:
             # Re-configure with what's suggested in the response
             self.send_control_frame(
@@ -789,7 +839,7 @@
                 )
             )
         else:
-            logger.warn(color(f'!!! configuration rejected: {L2CAP_Configure_Response.result_name(response.result)}', 'red'))
+            logger.warning(color(f'!!! configuration rejected: {L2CAP_Configure_Response.result_name(response.result)}', 'red'))
             # TODO: decide how to fail gracefully
 
     def on_disconnection_request(self, request):
@@ -805,15 +855,15 @@
             self.emit('close')
             self.manager.on_channel_closed(self)
         else:
-            logger.warn(color('invalid state', 'red'))
+            logger.warning(color('invalid state', 'red'))
 
     def on_disconnection_response(self, response):
         if self.state != Channel.WAIT_DISCONNECT:
-            logger.warn(color('invalid state', 'red'))
+            logger.warning(color('invalid state', 'red'))
             return
 
         if response.destination_cid != self.destination_cid or response.source_cid != self.source_cid:
-            logger.warn('unexpected source or destination CID')
+            logger.warning('unexpected source or destination CID')
             return
 
         self.change_state(Channel.CLOSED)
@@ -828,22 +878,362 @@
 
 
 # -----------------------------------------------------------------------------
+class LeConnectionOrientedChannel(EventEmitter):
+    """
+    LE Credit-based Connection Oriented Channel
+    """
+
+    INIT             = 0
+    CONNECTED        = 1
+    CONNECTING       = 2
+    DISCONNECTING    = 3
+    DISCONNECTED     = 4
+    CONNECTION_ERROR = 5
+
+    STATE_NAMES = {
+        INIT:             'INIT',
+        CONNECTED:        'CONNECTED',
+        CONNECTING:       'CONNECTING',
+        DISCONNECTING:    'DISCONNECTING',
+        DISCONNECTED:     'DISCONNECTED',
+        CONNECTION_ERROR: 'CONNECTION_ERROR'
+    }
+
+    @staticmethod
+    def state_name(state):
+        return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
+
+    def __init__(
+        self,
+        manager,
+        connection,
+        le_psm,
+        source_cid,
+        destination_cid,
+        mtu,
+        mps,
+        credits,
+        peer_mtu,
+        peer_mps,
+        peer_credits,
+        connected
+    ):
+        super().__init__()
+        self.manager                = manager
+        self.connection             = connection
+        self.le_psm                 = le_psm
+        self.source_cid             = source_cid
+        self.destination_cid        = destination_cid
+        self.mtu                    = mtu
+        self.mps                    = mps
+        self.credits                = credits
+        self.peer_mtu               = peer_mtu
+        self.peer_mps               = peer_mps
+        self.peer_credits           = peer_credits
+        self.peer_max_credits       = self.peer_credits
+        self.peer_credits_threshold = self.peer_max_credits // 2
+        self.in_sdu                 = None
+        self.in_sdu_length          = 0
+        self.out_queue              = deque()
+        self.out_sdu                = None
+        self.sink                   = None
+        self.connection_result      = None
+        self.disconnection_result   = None
+        self.drained                = asyncio.Event()
+
+        self.drained.set()
+
+        if connected:
+            self.state = LeConnectionOrientedChannel.CONNECTED
+        else:
+            self.state = LeConnectionOrientedChannel.INIT
+
+    def change_state(self, new_state):
+        logger.debug(f'{self} state change -> {color(self.state_name(new_state), "cyan")}')
+        self.state = new_state
+
+        if new_state == self.CONNECTED:
+            self.emit('open')
+        elif new_state == self.DISCONNECTED:
+            self.emit('close')
+
+    def send_pdu(self, pdu):
+        self.manager.send_pdu(self.connection, self.destination_cid, pdu)
+
+    def send_control_frame(self, frame):
+        self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
+
+    async def connect(self):
+        # Check that we're in the right state
+        if self.state != self.INIT:
+            raise InvalidStateError('not in a connectable state')
+
+        # 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')
+
+        self.change_state(self.CONNECTING)
+        request = L2CAP_LE_Credit_Based_Connection_Request(
+            identifier      = identifier,
+            le_psm          = self.le_psm,
+            source_cid      = self.source_cid,
+            mtu             = self.mtu,
+            mps             = self.mps,
+            initial_credits = self.peer_credits
+        )
+        self.manager.le_coc_requests[identifier] = request
+        self.send_control_frame(request)
+
+        # Create a future to wait for the response
+        self.connection_result = asyncio.get_running_loop().create_future()
+
+        # Wait for the connection to succeed or fail
+        return await self.connection_result
+
+    async def disconnect(self):
+        # Check that we're connected
+        if self.state != self.CONNECTED:
+            raise InvalidStateError('not connected')
+
+        self.change_state(self.DISCONNECTING)
+        self.flush_output()
+        self.send_control_frame(
+            L2CAP_Disconnection_Request(
+                identifier      = self.manager.next_identifier(self.connection),
+                destination_cid = self.destination_cid,
+                source_cid      = self.source_cid
+            )
+        )
+
+        # Create a future to wait for the state machine to get to a success or error state
+        self.disconnection_result = asyncio.get_running_loop().create_future()
+        return await self.disconnection_result
+
+    def on_pdu(self, pdu):
+        if self.sink is None:
+            logger.warning('received pdu without a sink')
+            return
+
+        if self.state != self.CONNECTED:
+            logger.warning('received PDU while not connected, dropping')
+
+        # Manage the peer credits
+        if self.peer_credits == 0:
+            logger.warning('received LE frame when peer out of credits')
+        else:
+            self.peer_credits -= 1
+            if self.peer_credits <= self.peer_credits_threshold:
+                # The credits fell below the threshold, replenish them to the max
+                self.send_control_frame(
+                    L2CAP_LE_Flow_Control_Credit(
+                        identifier = self.manager.next_identifier(self.connection),
+                        cid        = self.source_cid,
+                        credits    = self.peer_max_credits - self.peer_credits
+                    )
+                )
+                self.peer_credits = self.peer_max_credits
+
+        # Check if this starts a new SDU
+        if self.in_sdu is None:
+            # Start a new SDU
+            self.in_sdu = pdu
+        else:
+            # Continue an SDU
+            self.in_sdu += pdu
+
+        # Check if the SDU is complete
+        if self.in_sdu_length == 0:
+            # We don't know the size yet, check if we have received the header to compute it
+            if len(self.in_sdu) >= 2:
+                self.in_sdu_length = struct.unpack_from('<H', self.in_sdu, 0)[0]
+        if self.in_sdu_length == 0:
+            # We'll compute it later
+            return
+        if len(self.in_sdu) < 2 + self.in_sdu_length:
+            # Not complete yet
+            logger.debug(f'SDU: {len(self.in_sdu) - 2} of {self.in_sdu_length} bytes received')
+            return
+        if len(self.in_sdu) != 2 + self.in_sdu_length:
+            # Overflow
+            logger.warning(f'SDU overflow: sdu_length={self.in_sdu_length}, received {len(self.in_sdu) - 2}')
+            # TODO: we should disconnect
+            self.in_sdu = None
+            self.in_sdu_length = 0
+            return
+
+        # Send the SDU to the sink
+        logger.debug(f'SDU complete: 2+{len(self.in_sdu) - 2} bytes')
+        self.sink(self.in_sdu[2:])
+
+        # Prepare for a new SDU
+        self.in_sdu = None
+        self.in_sdu_length = 0
+
+    def on_connection_response(self, response):
+        # Look for a matching pending response result
+        if self.connection_result is None:
+            logger.warning(f'received unexpected connection response (id={response.identifier})')
+            return
+
+        if response.result == L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL:
+            self.destination_cid = response.destination_cid
+            self.peer_mtu        = response.mtu
+            self.peer_mps        = response.mps
+            self.credits         = response.initial_credits
+            self.connected       = True
+            self.connection_result.set_result(self)
+            self.change_state(self.CONNECTED)
+        else:
+            self.connection_result.set_exception(
+                ProtocolError(
+                    response.result,
+                    'l2cap',
+                    L2CAP_LE_Credit_Based_Connection_Response.result_name(response.result))
+            )
+            self.change_state(self.CONNECTION_ERROR)
+
+        # Cleanup
+        self.connection_result = None
+
+    def on_credits(self, credits):
+        self.credits += credits
+        logger.debug(f'received {credits} credits, total = {self.credits}')
+
+        # Try to send more data if we have any queued up
+        self.process_output()
+
+    def on_disconnection_request(self, request):
+        self.send_control_frame(
+            L2CAP_Disconnection_Response(
+                identifier      = request.identifier,
+                destination_cid = request.destination_cid,
+                source_cid      = request.source_cid
+            )
+        )
+        self.change_state(self.DISCONNECTED)
+        self.flush_output()
+
+    def on_disconnection_response(self, response):
+        if self.state != self.DISCONNECTING:
+            logger.warning(color('invalid state', 'red'))
+            return
+
+        if response.destination_cid != self.destination_cid or response.source_cid != self.source_cid:
+            logger.warning('unexpected source or destination CID')
+            return
+
+        self.change_state(self.DISCONNECTED)
+        if self.disconnection_result:
+            self.disconnection_result.set_result(None)
+            self.disconnection_result = None
+
+    def flush_output(self):
+        self.out_queue.clear()
+        self.out_sdu = None
+
+    def process_output(self):
+        while self.credits > 0:
+            if self.out_sdu is not None:
+                # Finish the current SDU
+                packet = self.out_sdu[:self.peer_mps]
+                self.send_pdu(packet)
+                self.credits -= 1
+                logger.debug(f'sent {len(packet)} bytes, {self.credits} credits left')
+                if len(packet) == len(self.out_sdu):
+                    # We sent everything
+                    self.out_sdu = None
+                else:
+                    # Keep what's still left to send
+                    self.out_sdu = self.out_sdu[len(packet):]
+                continue
+            elif self.out_queue:
+                # Create the next SDU (2 bytes header plus up to MTU bytes payload)
+                logger.debug(f'assembling SDU from {len(self.out_queue)} packets in output queue')
+                payload = b''
+                while self.out_queue and len(payload) < self.peer_mtu:
+                    # We can add more data to the payload
+                    chunk = self.out_queue[0][:self.peer_mtu - len(payload)]
+                    payload += chunk
+                    self.out_queue[0] = self.out_queue[0][len(chunk):]
+                    if len(self.out_queue[0]) == 0:
+                        # We consumed the entire buffer, remove it
+                        self.out_queue.popleft()
+                        logger.debug(f'packet completed, {len(self.out_queue)} left in queue')
+
+                # Construct the SDU with its header
+                assert len(payload) != 0
+                logger.debug(f'SDU complete: {len(payload)} payload bytes')
+                self.out_sdu = struct.pack('<H', len(payload)) + payload
+            else:
+                # Nothing left to send for now
+                self.drained.set()
+                return
+
+    def write(self, data):
+        if self.state != self.CONNECTED:
+            logger.warning('not connected, dropping data')
+            return
+
+        # Queue the data
+        self.out_queue.append(data)
+        self.drained.clear()
+        logger.debug(f'{len(data)} bytes packet queued, {len(self.out_queue)} packets in queue')
+
+        # Send what we can
+        self.process_output()
+
+    async def drain(self):
+        await self.drained.wait()
+
+    def pause_reading(self):
+        # TODO: not implemented yet
+        pass
+
+    def resume_reading(self):
+        # TODO: not implemented yet
+        pass
+
+    def __str__(self):
+        return f'CoC({self.source_cid}->{self.destination_cid}, State={self.state_name(self.state)}, PSM={self.le_psm}, MTU={self.mtu}/{self.peer_mtu}, MPS={self.mps}/{self.peer_mps}, credits={self.credits}/{self.peer_credits})'
+
+
+# -----------------------------------------------------------------------------
 class ChannelManager:
-    def __init__(self, extended_features=None, connectionless_mtu=1024):
-        self.host               = None
-        self.channels           = {}  # Channels, mapped by connection and cid
-        # Fixed channel handlers, mapped by cid
-        self.fixed_channels     = {
-            L2CAP_SIGNALING_CID: None, L2CAP_LE_SIGNALING_CID: None}
+    def __init__(self, extended_features=[], connectionless_mtu=L2CAP_DEFAULT_CONNECTIONLESS_MTU):
+        self._host              = None
         self.identifiers        = {}  # Incrementing identifier values by connection
+        self.channels           = {}  # All channels, mapped by connection and source cid
+        self.fixed_channels     = {   # Fixed channel handlers, mapped by cid
+            L2CAP_SIGNALING_CID: None, L2CAP_LE_SIGNALING_CID: None
+        }
         self.servers            = {}  # Servers accepting connections, by PSM
-        self.extended_features  = [] if extended_features is None else extended_features
+        self.le_coc_channels    = {}  # LE CoC channels, mapped by connection and destination cid
+        self.le_coc_servers     = {}  # LE CoC - Servers accepting connections, by PSM
+        self.le_coc_requests    = {}  # LE CoC connection requests, by identifier
+        self.extended_features  = extended_features
         self.connectionless_mtu = connectionless_mtu
 
+    @property
+    def host(self):
+        return self._host
+
+    @host.setter
+    def host(self, host):
+        if self._host is not None:
+            self._host.remove_listener('disconnection', self.on_disconnection)
+        self._host = host
+        if host is not None:
+            host.on('disconnection', self.on_disconnection)
+
     def find_channel(self, connection_handle, cid):
         if connection_channels := self.channels.get(connection_handle):
             return connection_channels.get(cid)
 
+    def find_le_coc_channel(self, connection_handle, cid):
+        if connection_channels := self.le_coc_channels.get(connection_handle):
+            return connection_channels.get(cid)
+
     @staticmethod
     def find_free_br_edr_cid(channels):
         # Pick the smallest valid CID that's not already in the list
@@ -853,21 +1243,108 @@
             if cid not in channels:
                 return cid
 
+    @staticmethod
+    def find_free_le_cid(channels):
+        # Pick the smallest valid CID that's not already in the list
+        # (not necessarily the most efficient algorithm, but the list of CID is
+        # very small in practice)
+        for cid in range(L2CAP_LE_U_DYNAMIC_CID_RANGE_START, L2CAP_LE_U_DYNAMIC_CID_RANGE_END + 1):
+            if cid not in channels:
+                return cid
+
+    @staticmethod
+    def check_le_coc_parameters(max_credits, mtu, mps):
+        if max_credits < 1 or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS:
+            raise ValueError('max credits out of range')
+        if mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
+            raise ValueError('MTU too small')
+        if mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS or mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS:
+            raise ValueError('MPS out of range')
+
     def next_identifier(self, connection):
         identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
         self.identifiers[connection.handle] = identifier
         return identifier
-    
+
     def register_fixed_channel(self, cid, handler):
         self.fixed_channels[cid] = handler
-    
+
     def deregister_fixed_channel(self, cid):
         if cid in self.fixed_channels:
             del self.fixed_channels[cid]
 
     def register_server(self, psm, server):
+        if psm == 0:
+            # Find a free PSM
+            for candidate in range(L2CAP_PSM_DYNAMIC_RANGE_START, L2CAP_PSM_DYNAMIC_RANGE_END + 1, 2):
+                if (candidate >> 8) % 2 == 1:
+                    continue
+                if candidate in self.servers:
+                    continue
+                psm = candidate
+                break
+            else:
+                raise InvalidStateError('no free PSM')
+        else:
+            # Check that the PSM isn't already in use
+            if psm in self.servers:
+                raise ValueError('PSM already in use')
+
+            # Check that the PSM is valid
+            if psm % 2 == 0:
+                raise ValueError('invalid PSM (not odd)')
+            check = psm >> 8
+            while check:
+                if check % 2 != 0:
+                    raise ValueError('invalid PSM')
+                check >>= 8
+
         self.servers[psm] = server
 
+        return psm
+
+    def register_le_coc_server(
+        self,
+        psm,
+        server,
+        max_credits=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
+        mtu=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
+        mps=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
+    ):
+        self.check_le_coc_parameters(max_credits, mtu, mps)
+
+        if psm == 0:
+            # Find a free PSM
+            for candidate in range(L2CAP_LE_PSM_DYNAMIC_RANGE_START, L2CAP_LE_PSM_DYNAMIC_RANGE_END + 1):
+                if candidate in self.le_coc_servers:
+                    continue
+                psm = candidate
+                break
+            else:
+                raise InvalidStateError('no free PSM')
+        else:
+            # Check that the PSM isn't already in use
+            if psm in self.le_coc_servers:
+                raise ValueError('PSM already in use')
+
+        self.le_coc_servers[psm] = (
+            server,
+            max_credits or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
+            mtu or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
+            mps or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
+        )
+
+        return psm
+
+    def on_disconnection(self, connection_handle, reason):
+        logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
+        if connection_handle in self.channels:
+            del self.channels[connection_handle]
+        if connection_handle in self.le_coc_channels:
+            del self.le_coc_channels[connection_handle]
+        if connection_handle in self.identifiers:
+            del self.identifiers[connection_handle]
+
     def send_pdu(self, connection, cid, pdu):
         pdu_str = pdu.hex() if type(pdu) is bytes else str(pdu)
         logger.debug(f'{color(">>> Sending L2CAP PDU", "blue")} on connection [0x{connection.handle:04X}] (CID={cid}) {connection.peer_address}: {pdu_str}')
@@ -883,7 +1360,7 @@
             self.fixed_channels[cid](connection.handle, pdu)
         else:
             if (channel := self.find_channel(connection.handle, cid)) is None:
-                logger.warn(color(f'channel not found for 0x{connection.handle:04X}:{cid}', 'red'))
+                logger.warning(color(f'channel not found for 0x{connection.handle:04X}:{cid}', 'red'))
                 return
 
             channel.on_pdu(pdu)
@@ -927,7 +1404,6 @@
 
     def on_l2cap_command_reject(self, connection, cid, packet):
         logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
-        pass
 
     def on_l2cap_connection_request(self, connection, cid, request):
         # Check if there's a server for this PSM
@@ -959,7 +1435,7 @@
             server(channel)
             channel.on_connection_request(request)
         else:
-            logger.warn(f'No server for connection 0x{connection.handle:04X} on PSM {request.psm}')
+            logger.warning(f'No server for connection 0x{connection.handle:04X} on PSM {request.psm}')
             self.send_control_frame(
                 connection,
                 cid,
@@ -974,35 +1450,35 @@
 
     def on_l2cap_connection_response(self, connection, cid, response):
         if (channel := self.find_channel(connection.handle, response.source_cid)) is None:
-            logger.warn(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+            logger.warning(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
             return
 
         channel.on_connection_response(response)
 
     def on_l2cap_configure_request(self, connection, cid, request):
         if (channel := self.find_channel(connection.handle, request.destination_cid)) is None:
-            logger.warn(color(f'channel {request.destination_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+            logger.warning(color(f'channel {request.destination_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
             return
 
         channel.on_configure_request(request)
 
     def on_l2cap_configure_response(self, connection, cid, response):
         if (channel := self.find_channel(connection.handle, response.source_cid)) is None:
-            logger.warn(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+            logger.warning(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
             return
 
         channel.on_configure_response(response)
 
     def on_l2cap_disconnection_request(self, connection, cid, request):
         if (channel := self.find_channel(connection.handle, request.destination_cid)) is None:
-            logger.warn(color(f'channel {request.destination_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+            logger.warning(color(f'channel {request.destination_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
             return
 
         channel.on_disconnection_request(request)
 
     def on_l2cap_disconnection_response(self, connection, cid, response):
         if (channel := self.find_channel(connection.handle, response.source_cid)) is None:
-            logger.warn(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+            logger.warning(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
             return
 
         channel.on_disconnection_response(response)
@@ -1057,13 +1533,13 @@
                 )
             )
             self.host.send_command_sync(HCI_LE_Connection_Update_Command(
-                connection_handle   = connection.handle,
-                conn_interval_min   = request.interval_min,
-                conn_interval_max   = request.interval_max,
-                conn_latency        = request.slave_latency,
-                supervision_timeout = request.timeout_multiplier,
-                minimum_ce_length   = 0,
-                maximum_ce_length   = 0
+                connection_handle       = connection.handle,
+                connection_interval_min = request.interval_min,
+                connection_interval_max = request.interval_max,
+                max_latency             = request.latency,
+                supervision_timeout     = request.timeout,
+                min_ce_length           = 0,
+                max_ce_length           = 0
             ))
         else:
             self.send_control_frame(
@@ -1076,25 +1552,123 @@
             )
 
     def on_l2cap_connection_parameter_update_response(self, connection, cid, response):
+        # TODO: check response
         pass
 
     def on_l2cap_le_credit_based_connection_request(self, connection, cid, request):
-        # FIXME: temp fixed values
-        self.send_control_frame(
-            connection,
-            cid,
-            L2CAP_LE_Credit_Based_Connection_Response(
-                identifier      = request.identifier,
-                destination_cid = 194,  # FIXME: for testing only
-                mtu             = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
-                mps             = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
-                initial_credits = 3,  # FIXME: for testing only
-                result          = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL
-            )
-        )
+        if request.le_psm in self.le_coc_servers:
+            (server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
 
-    def on_l2cap_le_flow_control_credit(self, connection, cid, packet):
-        pass
+            # Check that the CID isn't already used
+            le_connection_channels = self.le_coc_channels.setdefault(connection.handle, {})
+            if request.source_cid in le_connection_channels:
+                logger.warning(f'source CID {request.source_cid} already in use')
+                self.send_control_frame(
+                    connection,
+                    cid,
+                    L2CAP_LE_Credit_Based_Connection_Response(
+                        identifier      = request.identifier,
+                        destination_cid = 0,
+                        mtu             = mtu,
+                        mps             = mps,
+                        initial_credits = 0,
+                        result          = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED
+                    )
+                )
+                return
+
+            # Find a free CID for this new channel
+            connection_channels = self.channels.setdefault(connection.handle, {})
+            source_cid = self.find_free_le_cid(connection_channels)
+            if source_cid is None:  # Should never happen!
+                self.send_control_frame(
+                    connection,
+                    cid,
+                    L2CAP_LE_Credit_Based_Connection_Response(
+                        identifier      = request.identifier,
+                        destination_cid = 0,
+                        mtu             = mtu,
+                        mps             = mps,
+                        initial_credits = 0,
+                        result          = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
+                    )
+                )
+                return
+
+            # Create a new channel
+            logger.debug(f'creating LE CoC server channel with cid={source_cid} for psm {request.le_psm}')
+            channel = LeConnectionOrientedChannel(
+                self,
+                connection,
+                request.le_psm,
+                source_cid,
+                request.source_cid,
+                mtu,
+                mps,
+                request.initial_credits,
+                request.mtu,
+                request.mps,
+                max_credits,
+                True
+            )
+            connection_channels[source_cid] = channel
+            le_connection_channels[request.source_cid] = channel
+
+            # Respond
+            self.send_control_frame(
+                connection,
+                cid,
+                L2CAP_LE_Credit_Based_Connection_Response(
+                    identifier      = request.identifier,
+                    destination_cid = source_cid,
+                    mtu             = mtu,
+                    mps             = mps,
+                    initial_credits = max_credits,
+                    result          = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL
+                )
+            )
+
+            # Notify
+            server(channel)
+        else:
+            logger.info(f'No LE server for connection 0x{connection.handle:04X} on PSM {request.le_psm}')
+            self.send_control_frame(
+                connection,
+                cid,
+                L2CAP_LE_Credit_Based_Connection_Response(
+                    identifier      = request.identifier,
+                    destination_cid = 0,
+                    mtu             = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
+                    mps             = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
+                    initial_credits = 0,
+                    result          = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED,
+                )
+            )
+
+    def on_l2cap_le_credit_based_connection_response(self, connection, cid, response):
+        # Find the pending request by identifier
+        request = self.le_coc_requests.get(response.identifier)
+        if request is None:
+            logger.warning(color('!!! received response for unknown request', 'red'))
+            return
+        del self.le_coc_requests[response.identifier]
+
+        # Find the channel for this request
+        channel = self.find_channel(connection.handle, request.source_cid)
+        if channel is None:
+            logger.warning(color(f'received connection response for an unknown channel (cid={request.source_cid})', 'red'))
+            return
+
+        # Process the response
+        channel.on_connection_response(response)
+
+    def on_l2cap_le_flow_control_credit(self, connection, cid, credit):
+        channel = self.find_le_coc_channel(connection.handle, credit.cid)
+        if channel is None:
+            logger.warning(f'received credits for an unknown channel (cid={credit.cid}')
+            return
+
+        channel.on_credits(credit.credits)
 
     def on_channel_closed(self, channel):
         connection_channels = self.channels.get(channel.connection.handle)
@@ -1102,22 +1676,65 @@
             if channel.source_cid in connection_channels:
                 del connection_channels[channel.source_cid]
 
-    async def connect(self, connection, psm):
-        # NOTE: this implementation hard-codes BR/EDR more
-        # TODO: LE mode (maybe?)
+    async def open_le_coc(self, connection, psm, max_credits, mtu, mps):
+        self.check_le_coc_parameters(max_credits, mtu, mps)
 
-        # Find a free CID for a new channel
+        # Find a free CID for the new channel
         connection_channels = self.channels.setdefault(connection.handle, {})
-        cid = self.find_free_br_edr_cid(connection_channels)
-        if cid is None:  # Should never happen!
+        source_cid = self.find_free_le_cid(connection_channels)
+        if source_cid is None:  # Should never happen!
             raise RuntimeError('all CIDs already in use')
 
         # Create the channel
-        logger.debug(f'creating client channel with cid={cid} for psm {psm}')
-        channel = Channel(self, connection, L2CAP_SIGNALING_CID, psm, cid, L2CAP_MIN_BR_EDR_MTU)
-        connection_channels[cid] = channel
+        logger.debug(f'creating coc channel with cid={source_cid} for psm {psm}')
+        channel = LeConnectionOrientedChannel(
+            manager         = self,
+            connection      = connection,
+            le_psm          = psm,
+            source_cid      = source_cid,
+            destination_cid = 0,
+            mtu             = mtu,
+            mps             = mps,
+            credits         = 0,
+            peer_mtu        = 0,
+            peer_mps        = 0,
+            peer_credits    = max_credits,
+            connected       = False
+        )
+        connection_channels[source_cid] = channel
 
         # Connect
-        await channel.connect()
+        try:
+            await channel.connect()
+        except Exception as error:
+            logger.warning(f'connection failed: {error}')
+            del connection_channels[source_cid]
+            raise
+
+        # Remember the channel by source CID and destination CID
+        le_connection_channels = self.le_coc_channels.setdefault(connection.handle, {})
+        le_connection_channels[channel.destination_cid] = channel
+
+        return channel
+
+    async def connect(self, connection, psm):
+        # NOTE: this implementation hard-codes BR/EDR
+
+        # Find a free CID for a new channel
+        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')
+
+        # Create the channel
+        logger.debug(f'creating client channel with cid={source_cid} for psm {psm}')
+        channel = Channel(self, connection, L2CAP_SIGNALING_CID, psm, source_cid, L2CAP_MIN_BR_EDR_MTU)
+        connection_channels[source_cid] = channel
+
+        # Connect
+        try:
+            await channel.connect()
+        except Exception:
+            del connection_channels[source_cid]
 
         return channel
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index be4d406..a87b8d8 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -21,7 +21,7 @@
 from colors import color
 from pyee import EventEmitter
 
-from .core import InvalidStateError, ProtocolError, ConnectionError
+from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError, ConnectionError
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -634,7 +634,12 @@
         if self.state == Multiplexer.OPENING:
             self.change_state(Multiplexer.CONNECTED)
             if self.open_result:
-                self.open_result.set_exception(ConnectionError(ConnectionError.CONNECTION_REFUSED))
+                self.open_result.set_exception(ConnectionError(
+                    ConnectionError.CONNECTION_REFUSED,
+                    BT_BR_EDR_TRANSPORT,
+                    self.l2cap_channel.connection.peer_address,
+                    'rfcomm'
+                ))
         else:
             logger.warn(f'unexpected state for DM: {self}')
 
diff --git a/bumble/smp.py b/bumble/smp.py
index c544a82..8f0ea0b 100644
--- a/bumble/smp.py
+++ b/bumble/smp.py
@@ -477,6 +477,9 @@
     async def accept(self):
         return True
 
+    async def confirm(self):
+        return True
+
     async def compare_numbers(self, number, digits=6):
         return True
 
@@ -637,15 +640,16 @@
         self.oob = False
 
         # Set up addresses
+        self_address = connection.self_address
         peer_address = connection.peer_resolvable_address or connection.peer_address
         if self.is_initiator:
-            self.ia  = bytes(manager.address)
-            self.iat = 1 if manager.address.is_random else 0
+            self.ia  = bytes(self_address)
+            self.iat = 1 if self_address.is_random else 0
             self.ra  = bytes(peer_address)
             self.rat = 1 if peer_address.is_random else 0
         else:
-            self.ra  = bytes(manager.address)
-            self.rat = 1 if manager.address.is_random else 0
+            self.ra  = bytes(self_address)
+            self.rat = 1 if self_address.is_random else 0
             self.ia  = bytes(peer_address)
             self.iat = 1 if peer_address.is_random else 0
 
@@ -715,6 +719,21 @@
             return False
         return True
 
+    def prompt_user_for_confirmation(self, next_steps):
+        async def prompt():
+            logger.debug('ask for confirmation')
+            try:
+                response = await self.pairing_config.delegate.confirm()
+                if response:
+                    next_steps()
+                    return
+            except Exception as error:
+                logger.warn(f'exception while confirm: {error}')
+
+            self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR)
+
+        asyncio.create_task(prompt())
+
     def prompt_user_for_numeric_comparison(self, code, next_steps):
         async def prompt():
             logger.debug(f'verification code: {code}')
@@ -907,8 +926,8 @@
                     SMP_Identity_Information_Command(identity_resolving_key=self.manager.device.irk)
                 )
                 self.send_command(SMP_Identity_Address_Information_Command(
-                    addr_type = self.manager.address.address_type,
-                    bd_addr   = self.manager.address
+                    addr_type = self.connection.self_address.address_type,
+                    bd_addr   = self.connection.self_address
                 ))
 
             # Distribute CSRK
@@ -939,8 +958,8 @@
                     SMP_Identity_Information_Command(identity_resolving_key=self.manager.device.irk)
                 )
                 self.send_command(SMP_Identity_Address_Information_Command(
-                    addr_type = self.manager.address.address_type,
-                    bd_addr   = self.manager.address
+                    addr_type = self.connection.self_address.address_type,
+                    bd_addr   = self.connection.self_address
                 ))
 
             # Distribute CSRK
@@ -1091,7 +1110,7 @@
         self.manager.on_pairing(self, peer_address, keys)
 
     def on_pairing_failure(self, reason):
-        logger.warn(f'pairing failure ({error_name(reason)})')
+        logger.warning(f'pairing failure ({error_name(reason)})')
 
         if self.completed:
             return
@@ -1387,12 +1406,12 @@
             # Compute the 6-digit code
             code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
 
-            if self.pairing_method == self.NUMERIC_COMPARISON:
-                # Ask for user confirmation
-                self.wait_before_continuing = asyncio.get_running_loop().create_future()
-                self.prompt_user_for_numeric_comparison(code, next_steps)
+            # Ask for user confirmation
+            self.wait_before_continuing = asyncio.get_running_loop().create_future()
+            if self.pairing_method == self.JUST_WORKS:
+                self.prompt_user_for_confirmation(next_steps)
             else:
-                next_steps()
+                self.prompt_user_for_numeric_comparison(code, next_steps)
         else:
             next_steps()
 
@@ -1486,10 +1505,9 @@
     Implements the Initiator and Responder roles of the Security Manager Protocol
     '''
 
-    def __init__(self, device, address):
+    def __init__(self, device):
         super().__init__()
         self.device                 = device
-        self.address                = address
         self.sessions               = {}
         self._ecc_key               = None
         self.pairing_config_factory = lambda connection: PairingConfig()
diff --git a/bumble/transport/common.py b/bumble/transport/common.py
index d5c1ae9..0f5d27f 100644
--- a/bumble/transport/common.py
+++ b/bumble/transport/common.py
@@ -274,7 +274,7 @@
                     self.terminated.set_result(error)
                     break
 
-        self.pump_task = asyncio.get_running_loop().create_task(pump_packets())
+        self.pump_task = asyncio.create_task(pump_packets())
 
     def close(self):
         if self.pump_task:
@@ -304,7 +304,7 @@
                     logger.warn(f'exception while sending packet: {error}')
                     break
 
-        self.pump_task = asyncio.get_running_loop().create_task(pump_packets())
+        self.pump_task = asyncio.create_task(pump_packets())
 
     def close(self):
         if self.pump_task:
diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py
index 80a8871..1133a5e 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -36,7 +36,7 @@
 async def open_usb_transport(spec):
     '''
     Open a USB transport.
-    The parameter string has this syntax:
+    The moniker string has this syntax:
     either <index> or
     <vendor>:<product> or
     <vendor>:<product>/<serial-number>] or
@@ -47,15 +47,21 @@
     /<serial-number> suffix or #<index> suffix max be specified when more than one device with
     the same vendor and product identifiers are present.
 
+    In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
+    the first USB interface of the device will be used, regardless of the interface class/subclass.
+    This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
+
     Examples:
     0 --> the first BT USB dongle
     04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901
     04b4:f901#2 --> the third USB device with vendor=04b4 and product=f901
     04b4:f901/00E04C239987 --> the BT USB dongle with vendor=04b4 and product=f901 and serial number 00E04C239987
+    usb:0B05:17CB! --> the BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
     '''
 
     USB_RECIPIENT_DEVICE                             = 0x00
     USB_REQUEST_TYPE_CLASS                           = 0x01 << 5
+    USB_DEVICE_CLASS_DEVICE                          = 0x00
     USB_DEVICE_CLASS_WIRELESS_CONTROLLER             = 0xE0
     USB_DEVICE_SUBCLASS_RF_CONTROLLER                = 0x01
     USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
@@ -63,6 +69,12 @@
     USB_ENDPOINT_TRANSFER_TYPE_INTERRUPT             = 0x03
     USB_ENDPOINT_IN                                  = 0x80
 
+    USB_BT_HCI_CLASS_TUPLE = (
+        USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
+        USB_DEVICE_SUBCLASS_RF_CONTROLLER,
+        USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+    )
+
     READ_SIZE = 1024
 
     class UsbPacketSink:
@@ -280,6 +292,13 @@
     context.open()
     try:
         found = None
+
+        if spec.endswith('!'):
+            spec = spec[:-1]
+            forced_mode = True
+        else:
+            forced_mode = False
+
         if ':' in spec:
             vendor_id, product_id = spec.split(':')
             serial_number = None
@@ -291,10 +310,14 @@
                 device_index = int(device_index_str)
 
             for device in context.getDeviceIterator(skip_on_error=True):
+                try:
+                    device_serial_number = device.getSerialNumber()
+                except usb1.USBError:
+                    device_serial_number = None
                 if (
                     device.getVendorID() == int(vendor_id, 16) and
                     device.getProductID() == int(product_id, 16) and
-                    (serial_number is None or device.getSerialNumber() == serial_number)
+                    (serial_number is None or serial_number == device_serial_number)
                 ):
                     if device_index == 0:
                         found = device
@@ -302,13 +325,27 @@
                     device_index -= 1
                 device.close()
         else:
+            # Look for a compatible device by index
+            def device_is_bluetooth_hci(device):
+                # Check if the device class indicates a match
+                if (device.getDeviceClass(), device.getDeviceSubClass(), device.getDeviceProtocol()) == \
+                        USB_BT_HCI_CLASS_TUPLE:
+                    return True
+
+                # If the device class is 'Device', look for a matching interface
+                if device.getDeviceClass() == USB_DEVICE_CLASS_DEVICE:
+                    for configuration in device:
+                        for interface in configuration:
+                            for setting in interface:
+                                if (setting.getClass(), setting.getSubClass(), setting.getProtocol()) == \
+                                        USB_BT_HCI_CLASS_TUPLE:
+                                    return True
+
+                return False
+
             device_index = int(spec)
             for device in context.getDeviceIterator(skip_on_error=True):
-                if (
-                    device.getDeviceClass()    == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and
-                    device.getDeviceSubClass() == USB_DEVICE_SUBCLASS_RF_CONTROLLER and
-                    device.getDeviceProtocol() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
-                ):
+                if device_is_bluetooth_hci(device):
                     if device_index == 0:
                         found = device
                         break
@@ -329,9 +366,8 @@
                     setting = None
                     for setting in interface:
                         if (
-                            setting.getClass() != USB_DEVICE_CLASS_WIRELESS_CONTROLLER or
-                            setting.getSubClass() != USB_DEVICE_SUBCLASS_RF_CONTROLLER or
-                            setting.getProtocol() != USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+                            not forced_mode and
+                            (setting.getClass(), setting.getSubClass(), setting.getProtocol()) != USB_BT_HCI_CLASS_TUPLE
                         ):
                             continue
 
@@ -378,14 +414,13 @@
 
         device = found.open()
 
-        # Detach the kernel driver if supported and needed
+        # Auto-detach the kernel driver if supported
         if usb1.hasCapability(usb1.CAP_SUPPORTS_DETACH_KERNEL_DRIVER):
             try:
-                if device.kernelDriverActive(interface):
-                    logger.debug("detaching kernel driver")
-                    device.detachKernelDriver(interface)
-            except usb1.USBError:
-                pass
+                logger.debug('auto-detaching kernel driver')
+                device.setAutoDetachKernelDriver(True)
+            except usb1.USBError as error:
+                logger.warning(f'unable to auto-detach kernel driver: {error}')
 
         # Set the configuration if needed
         try:
diff --git a/bumble/utils.py b/bumble/utils.py
index 1ab3fd7..5d8ab95 100644
--- a/bumble/utils.py
+++ b/bumble/utils.py
@@ -18,6 +18,7 @@
 import asyncio
 import logging
 import traceback
+import collections
 from functools import wraps
 from colors import color
 from pyee import EventEmitter
@@ -140,3 +141,95 @@
             return wrapper
 
         return decorator
+
+
+# -----------------------------------------------------------------------------
+class FlowControlAsyncPipe:
+    """
+    Asyncio pipe with flow control. When writing to the pipe, the source is
+    paused (by calling a function passed in when the pipe is created) if the
+    amount of queued data exceeds a specified threshold.
+    """
+    def __init__(self, pause_source, resume_source, write_to_sink=None, drain_sink=None, threshold=0):
+        self.pause_source  = pause_source
+        self.resume_source = resume_source
+        self.write_to_sink = write_to_sink
+        self.drain_sink    = drain_sink
+        self.threshold     = threshold
+        self.queue         = collections.deque()  # Queue of packets
+        self.queued_bytes  = 0                    # Number of bytes in the queue
+        self.ready_to_pump = asyncio.Event()
+        self.paused        = False
+        self.source_paused = False
+        self.pump_task     = None
+
+    def start(self):
+        if self.pump_task is None:
+            self.pump_task = asyncio.create_task(self.pump())
+
+        self.check_pump()
+
+    def stop(self):
+        if self.pump_task is not None:
+            self.pump_task.cancel()
+            self.pump_task = None
+
+    def write(self, packet):
+        self.queued_bytes += len(packet)
+        self.queue.append(packet)
+
+        # Pause the source if we're over the threshold
+        if self.queued_bytes > self.threshold and not self.source_paused:
+            logger.debug(f'pausing source (queued={self.queued_bytes})')
+            self.pause_source()
+            self.source_paused = True
+
+        self.check_pump()
+
+    def pause(self):
+        if not self.paused:
+            self.paused = True
+            if not self.source_paused:
+                self.pause_source()
+                self.source_paused = True
+            self.check_pump()
+
+    def resume(self):
+        if self.paused:
+            self.paused = False
+            if self.source_paused:
+                self.resume_source()
+                self.source_paused = False
+            self.check_pump()
+
+    def can_pump(self):
+        return self.queue and not self.paused and self.write_to_sink is not None
+
+    def check_pump(self):
+        if self.can_pump():
+            self.ready_to_pump.set()
+        else:
+            self.ready_to_pump.clear()
+
+    async def pump(self):
+        while True:
+            # Wait until we can try to pump packets
+            await self.ready_to_pump.wait()
+
+            # Try to pump a packet
+            if self.can_pump():
+                packet = self.queue.pop()
+                self.write_to_sink(packet)
+                self.queued_bytes -= len(packet)
+
+                # Drain the sink if we can
+                if self.drain_sink:
+                    await self.drain_sink()
+
+                # Check if we can accept more
+                if self.queued_bytes <= self.threshold and self.source_paused:
+                    logger.debug(f'resuming source (queued={self.queued_bytes})')
+                    self.source_paused = False
+                    self.resume_source()
+
+            self.check_pump()
diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml
index 3bade7b..87d791c 100644
--- a/docs/mkdocs/mkdocs.yml
+++ b/docs/mkdocs/mkdocs.yml
@@ -45,6 +45,10 @@
     - HCI Bridge: apps_and_tools/hci_bridge.md
     - Golden Gate Bridge: apps_and_tools/gg_bridge.md
     - Show: apps_and_tools/show.md
+    - GATT Dump: apps_and_tools/gatt_dump.md
+    - Pair: apps_and_tools/pair.md
+    - Unbond: apps_and_tools/unbond.md
+    - USB Probe: apps_and_tools/usb_probe.md
   - Hardware:
     - Overview: hardware/index.md
   - Platforms:
diff --git a/docs/mkdocs/requirements.txt b/docs/mkdocs/requirements.txt
index a9b8452..a12d40d 100644
--- a/docs/mkdocs/requirements.txt
+++ b/docs/mkdocs/requirements.txt
@@ -1,6 +1,6 @@
 # This requirements file is for python3
-mkdocs == 1.2.3
-mkdocs-material == 7.1.7
-mkdocs-material-extensions == 1.0.1
-pymdown-extensions == 8.2
-mkdocstrings == 0.15.1
\ No newline at end of file
+mkdocs == 1.4.0
+mkdocs-material == 8.5.6
+mkdocs-material-extensions == 1.0.3
+pymdown-extensions == 9.6
+mkdocstrings-python == 0.7.1
\ No newline at end of file
diff --git a/docs/mkdocs/src/apps_and_tools/usb_probe.md b/docs/mkdocs/src/apps_and_tools/usb_probe.md
index 80e2f41..95751a3 100644
--- a/docs/mkdocs/src/apps_and_tools/usb_probe.md
+++ b/docs/mkdocs/src/apps_and_tools/usb_probe.md
@@ -13,17 +13,29 @@
 
 ## Usage
 
-This command line tool takes no arguments.
+This command line tool may be invoked with no arguments, or with `--verbose`
+for extra details.
 When installed from PyPI, run as
 ```
 $ bumble-usb-probe
 ```
 
+or, for extra details, with the `--verbose` argument
+```
+$ bumble-usb-probe --v
+```
+
 When running from the source distribution:
 ```
 $ python3 apps/usb-probe.py
 ```
 
+or 
+
+```
+$ python3 apps/usb-probe.py --verbose
+```
+
 !!! example
     ```
     $ python3 apps/usb_probe.py 
diff --git a/docs/mkdocs/src/platforms/linux.md b/docs/mkdocs/src/platforms/linux.md
index 6468cd9..338908c 100644
--- a/docs/mkdocs/src/platforms/linux.md
+++ b/docs/mkdocs/src/platforms/linux.md
@@ -1,48 +1,86 @@
 :material-linux: LINUX PLATFORM
 ===============================
 
-In addition to all the standard functionality available from the project by running the python tools and/or writing your own apps by leveraging the API, it is also possible on Linux hosts to interface the Bumble stack with the native BlueZ stack, and with Bluetooth controllers.
+Using Bumble With Physical Bluetooth Controllers
+------------------------------------------------
 
-Using Bumble With BlueZ
------------------------
+A Bumble application can interface with a local Bluetooth controller on a Linux host.
+The 3 main types of physical Bluetooth controllers are:
 
-A Bumble virtual controller can be attached to the BlueZ stack.
-Attaching a controller to BlueZ can be done by either simulating a UART HCI interface, or by using the VHCI driver interface if available.
-In both cases, the controller can run locally on the Linux host, or remotely on a different host, with a bridge between the remote controller and the local BlueZ host, which may be useful when the BlueZ stack is running on an embedded system, or a host on which running the Bumble controller is not convenient.
+  * Bluetooth USB Dongle
+  * HCI over  UART (via a serial port)
+  * Kernel-managed Bluetooth HCI (HCI Sockets)
 
-### Using VHCI
+!!! tip "Conflicts with the kernel and BlueZ"
+    If your use a USB dongle that is recognized by your kernel as a supported Bluetooth device, it is
+    likely that the kernel driver will claim that USB device and attach it to the BlueZ stack. 
+    If you want to claim ownership of it to use with Bumble, you will need to set the state of the corresponding HCI interface as `DOWN`. 
+    HCI interfaces are numbered, starting from 0 (i.e `hci0`, `hci1`, ...).
 
-With the [VHCI transport](../transports/vhci.md) you can attach a Bumble virtual controller to the BlueZ stack. Once attached, the controller will appear just like any other controller, and thus can be used with the standard BlueZ tools.
-
-!!! example "Attaching a virtual controller"
-    With the example app `run_controller.py`:
+    For example, to bring `hci0` down:
     ```
-    PYTHONPATH=. python3 examples/run_controller.py F6:F7:F8:F9:FA:FB examples/device1.json vhci
-    ```
-    
-    You should see a 'Virtual Bus' controller. For example:
-    ```
-    $ hciconfig
-    hci0:	Type: Primary  Bus: Virtual
-        BD Address: F6:F7:F8:F9:FA:FB  ACL MTU: 27:64  SCO MTU: 0:0
-        UP RUNNING 
-        RX bytes:0 acl:0 sco:0 events:43 errors:0
-        TX bytes:274 acl:0 sco:0 commands:43 errors:0
+    $ sudo hciconfig hci0 down
     ```
 
-    And scanning for devices should show the virtual 'Bumble' device that's running as part of the `run_controller.py` example app:
+    You can use the `hciconfig` command with no arguments to get a list of HCI interfaces seen by
+    the kernel.
+
+    Also, if `bluetoothd` is running on your system, it will likely re-claim the interface after you
+    close it, so you may need to bring the interface back `UP` before using it again, or to disable
+    `bluetoothd` altogether (see the section further below about BlueZ and `bluetoothd`).
+
+### Using a USB Dongle
+
+See the [USB Transport page](../transports/usb.md) for general information on how to use HCI USB controllers.
+
+!!! tip "USB Permissions"
+    By default, when running as a regular user, you won't have the permission to use
+    arbitrary USB devices.
+    You can change the permissions for a specific USB device based on its bus number and 
+    device number (you can use `lsusb` to find the Bus and Device numbers for your Bluetooth
+    dongle).
+
+    Example:
     ```
-    pi@raspberrypi:~ $ sudo hcitool -i hci2 lescan
-    LE Scan ...
-    F0:F1:F2:F3:F4:F5 Bumble
+    $ sudo chmod o+w /dev/bus/usb/001/004
     ```
+    This will change the permissions for Device 4 on Bus 1.
+
+    Note that the USB Bus number and Device number may change depending on where you plug the USB
+    dongle and what other USB devices and hubs are also plugged in.
+
+    If you need to make the permission changes permanent across reboots, you can create a `udev`
+    rule for your specific Bluetooth dongle. Visit [this Arch Linux Wiki page](https://wiki.archlinux.org/title/udev) for a
+    good overview of how you may do that.
+
+### Using HCI over UART
+
+See the [Serial Transport page](../transports/serial.md) for general information on how to use HCI over a UART (serial port).
 
 ### Using HCI Sockets
 
 HCI sockets provide a way to send/receive HCI packets to/from a Bluetooth controller managed by the kernel.
-The HCI device referenced by an `hci-socket` transport (`hciX`, where `X` is an integer, with `hci0` being the first controller device, and so on) must be in the `DOWN` state before it can be opened as a transport.
-You can bring a HCI controller `UP` or `DOWN` with `hciconfig`.
+See the [HCI Socket Transport page](../transports/hci_socket.md) for details on the `hci-socket` tansport syntax.
 
+The HCI device referenced by an `hci-socket` transport (`hci<X>`, where `<X>` is an integer, with `hci0` being the first controller device, and so on) must be in the `DOWN` state before it can be opened as a transport.
+You can bring a HCI controller `UP` or `DOWN` with `hciconfig hci<X> up` and `hciconfig hci<X> up`.
+
+!!! tip "HCI Socket Permissions"
+    By default, when running as a regular user, you won't have the permission to use
+    an HCI socket to a Bluetooth controller (you may see an exception like `PermissionError: [Errno 1] Operation not permitted`).
+
+    If you want to run without using `sudo`, you need to manage the capabilities by adding the appropriate entries in `/etc/security/capability.conf` to grant a user or group the `cap_net_admin` capability.  
+    See [this manpage](https://manpages.ubuntu.com/manpages/bionic/man5/capability.conf.5.html) for details.
+    
+    Alternatively, if you are just experimenting temporarily, the `capsh` command may be useful in order
+    to execute a single command with enhanced permissions, as in this example:
+
+
+    ```
+    $ sudo capsh --caps="cap_net_admin+eip cap_setpcap,cap_setuid,cap_setgid+ep" --keep=1 --user=$USER --addamb=cap_net_admin  -- -c "<path/to/executable> <executable-args>"
+    ```
+    Where `<path/to/executable>` is the path to your `python3` executable or to one of the Bumble bundled command-line applications.
+    
 !!! tip "List all available controllers"
     The command
     ```
@@ -72,29 +110,16 @@
     ```
     $ hciconfig hci0 down
     ``` 
-    (or `hciX` with `X` being the index of the controller device you want to use), but a simpler solution is to just stop the `bluetoothd` daemon, with a command like:
+    (or `hci<X>` with `<X>` being the index of the controller device you want to use), but a simpler solution is to just stop the `bluetoothd` daemon, with a command like:
     ```
     $ sudo systemctl stop bluetooth.service
     ```
     You can always re-start the daemon with
     ```
     $ sudo systemctl start bluetooth.service
-    ```
 
-### Using a Simulated UART HCI
-
-### Bridge to a Remote Controller
-
-
-Using Bumble With Bluetooth Controllers
----------------------------------------
-
-A Bumble application can interface with a local Bluetooth controller.
-If your Bluetooth controller is a standard HCI USB controller, see the [USB Transport page](../transports/usb.md) for details on how to use HCI USB controllers.
-If your Bluetooth controller is a standard HCI UART controller, see the [Serial Transport page](../transports/serial.md).
-Alternatively, a Bumble Host object can communicate with one of the platform's controllers via an HCI Socket.
-
-`<details to be filled in>`
+Bumble on the Raspberry Pi
+--------------------------
 
 ### Raspberry Pi 4 :fontawesome-brands-raspberry-pi:
 
@@ -102,9 +127,10 @@
 
 #### Via The Kernel
 
-Use an HCI Socket transport
+Use an HCI Socket transport (see section above)
 
 #### Directly
+
 In order to use the Bluetooth controller directly on a Raspberry Pi 4 board, you need to ensure that it isn't being used by the BlueZ stack (which it probably is by default).
 
 ```
@@ -136,3 +162,47 @@
     python3 run_scanner.py serial:/dev/serial1,3000000
     ```
 
+
+Using Bumble With BlueZ
+-----------------------
+
+In addition to all the standard functionality available from the project by running the python tools and/or writing your own apps by leveraging the API, it is also possible on Linux hosts to interface the Bumble stack with the native BlueZ stack, and with Bluetooth controllers.
+
+A Bumble virtual controller can be attached to the BlueZ stack.
+Attaching a controller to BlueZ can be done by either simulating a UART HCI interface, or by using the VHCI driver interface if available.
+In both cases, the controller can run locally on the Linux host, or remotely on a different host, with a bridge between the remote controller and the local BlueZ host, which may be useful when the BlueZ stack is running on an embedded system, or a host on which running the Bumble controller is not convenient.
+
+### Using VHCI
+
+With the [VHCI transport](../transports/vhci.md) you can attach a Bumble virtual controller to the BlueZ stack. Once attached, the controller will appear just like any other controller, and thus can be used with the standard BlueZ tools.
+
+!!! example "Attaching a virtual controller"
+    With the example app `run_controller.py`:
+    ```
+    python3 examples/run_controller.py F6:F7:F8:F9:FA:FB examples/device1.json vhci
+    ```
+    
+    You should see a 'Virtual Bus' controller. For example:
+    ```
+    $ hciconfig
+    hci0:	Type: Primary  Bus: Virtual
+        BD Address: F6:F7:F8:F9:FA:FB  ACL MTU: 27:64  SCO MTU: 0:0
+        UP RUNNING 
+        RX bytes:0 acl:0 sco:0 events:43 errors:0
+        TX bytes:274 acl:0 sco:0 commands:43 errors:0
+    ```
+
+    And scanning for devices should show the virtual 'Bumble' device that's running as part of the `run_controller.py` example app:
+    ```
+    pi@raspberrypi:~ $ sudo hcitool -i hci2 lescan
+    LE Scan ...
+    F0:F1:F2:F3:F4:F5 Bumble
+    ```
+
+    ```
+
+### Using a Simulated UART HCI
+
+### Bridge to a Remote Controller
+
+
diff --git a/docs/mkdocs/src/transports/android_emulator.md b/docs/mkdocs/src/transports/android_emulator.md
index d903394..e43c82c 100644
--- a/docs/mkdocs/src/transports/android_emulator.md
+++ b/docs/mkdocs/src/transports/android_emulator.md
@@ -5,8 +5,9 @@
 ("host" mode), or attaches a virtual controller to the Android Bluetooth host stack ("controller" mode).
 
 ## Moniker
-The moniker syntax for an Android Emulator transport is: `android-emulator:[mode=<host|controller>][mode=<host|controller>]`.
-Both the `mode=<host|controller>` and `mode=<host|controller>` parameters are optional (so the moniker `android-emulator` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the emulator)
+The moniker syntax for an Android Emulator transport is: `android-emulator:[mode=<host|controller>][<hostname>:<port>]`, where
+the `mode` parameter can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator.  
+Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-emulator` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the emulator).
 
 !!! example Example
     `android-emulator`  
diff --git a/docs/mkdocs/src/transports/usb.md b/docs/mkdocs/src/transports/usb.md
index 6fe5e2a..d794e33 100644
--- a/docs/mkdocs/src/transports/usb.md
+++ b/docs/mkdocs/src/transports/usb.md
@@ -5,6 +5,7 @@
 
 ## Moniker
 The moniker for a USB transport is either:
+
   * `usb:<index>`
   * `usb:<vendor>:<product>`
   * `usb:<vendor>:<product>/<serial-number>`
@@ -16,6 +17,10 @@
 
 `<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
 
+In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
+the first USB interface of the device will be used, regardless of the interface class/subclass.
+This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
+
 !!! examples
     `usb:04b4:f901`  
     The USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
@@ -29,6 +34,10 @@
     `usb:04b4:f901/#1`
     The second USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
 
+    `usb:0B05:17CB!`
+    The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
+
+
 ## Alternative
 The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
 Using the transport prefix `pyusb:` instead of `usb:` selects the implementation based on  [PyUSB](https://pypi.org/project/pyusb/), using the synchronous API of `libusb`, whereas the default implementation is based on [libusb1](https://pypi.org/project/libusb1/), using the asynchronous API of `libusb`. In order to use the alternative PyUSB-based implementation, you need to ensure that you have installed that python module, as it isn't installed by default as a dependency of Bumble.
diff --git a/examples/asha_sink1.json b/examples/asha_sink1.json
new file mode 100644
index 0000000..badef8b
--- /dev/null
+++ b/examples/asha_sink1.json
@@ -0,0 +1,5 @@
+{
+    "name": "Bumble Aid Left",
+    "address": "F1:F2:F3:F4:F5:F6",
+    "keystore": "JsonKeyStore"
+}
diff --git a/examples/asha_sink2.json b/examples/asha_sink2.json
new file mode 100644
index 0000000..785d406
--- /dev/null
+++ b/examples/asha_sink2.json
@@ -0,0 +1,5 @@
+{
+    "name": "Bumble Aid Right",
+    "address": "F7:F8:F9:FA:FB:FC",
+    "keystore": "JsonKeyStore"
+}
diff --git a/examples/keyboard.py b/examples/keyboard.py
index ddb9f0f..068cbc0 100644
--- a/examples/keyboard.py
+++ b/examples/keyboard.py
@@ -34,8 +34,8 @@
     Characteristic,
     CharacteristicValue,
     GATT_DEVICE_INFORMATION_SERVICE,
-    GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE,
-    GATT_DEVICE_BATTERY_SERVICE,
+    GATT_HUMAN_INTERFACE_DEVICE_SERVICE,
+    GATT_BATTERY_SERVICE,
     GATT_BATTERY_LEVEL_CHARACTERISTIC,
     GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
     GATT_REPORT_CHARACTERISTIC,
@@ -126,8 +126,8 @@
     connection = await device.connect(peer_address)
     await connection.pair()
     peer = Peer(connection)
-    await peer.discover_service(GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE)
-    hid_services = peer.get_services_by_uuid(GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE)
+    await peer.discover_service(GATT_HUMAN_INTERFACE_DEVICE_SERVICE)
+    hid_services = peer.get_services_by_uuid(GATT_HUMAN_INTERFACE_DEVICE_SERVICE)
     if not hid_services:
         print(color('!!! No HID service', 'red'))
         return
@@ -221,7 +221,7 @@
             ]
         ),
         Service(
-            GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE,
+            GATT_HUMAN_INTERFACE_DEVICE_SERVICE,
             [
                 Characteristic(
                     GATT_PROTOCOL_MODE_CHARACTERISTIC,
@@ -252,7 +252,7 @@
             ]
         ),
         Service(
-            GATT_DEVICE_BATTERY_SERVICE,
+            GATT_BATTERY_SERVICE,
             [
                 Characteristic(
                     GATT_BATTERY_LEVEL_CHARACTERISTIC,
@@ -273,7 +273,7 @@
         AdvertisingData([
             (AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble Keyboard', 'utf-8')),
             (AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
-                bytes(GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE)),
+                bytes(GATT_HUMAN_INTERFACE_DEVICE_SERVICE)),
             (AdvertisingData.APPEARANCE, struct.pack('<H', 0x03C1)),
             (AdvertisingData.FLAGS, bytes([0x05]))
         ])
diff --git a/examples/run_advertiser.py b/examples/run_advertiser.py
index 5201356..e54bc37 100644
--- a/examples/run_advertiser.py
+++ b/examples/run_advertiser.py
@@ -29,18 +29,31 @@
 
 # -----------------------------------------------------------------------------
 async def main():
-    if len(sys.argv) != 3:
-        print('Usage: run_advertiser.py <config-file> <transport-spec>')
-        print('example: run_advertiser.py device1.json link-relay:ws://localhost:8888/test')
+    if len(sys.argv) < 3:
+        print('Usage: run_advertiser.py <config-file> <transport-spec> [type] [address]')
+        print('example: run_advertiser.py device1.json usb:0')
         return
 
+    if len(sys.argv) >= 4:
+        advertising_type = AdvertisingType(int(sys.argv[3]))
+    else:
+        advertising_type = AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE
+
+    if advertising_type.is_directed:
+        if len(sys.argv) < 5:
+            print('<address> required for directed advertising')
+            return
+        target = Address(sys.argv[4])
+    else:
+        target = None
+
     print('<<< connecting to HCI...')
     async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
         print('<<< connected')
 
         device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
         await device.power_on()
-        await device.start_advertising()
+        await device.start_advertising(advertising_type=advertising_type, target=target)
         await hci_source.wait_for_termination()
 
 # -----------------------------------------------------------------------------
diff --git a/examples/run_asha_sink.py b/examples/run_asha_sink.py
new file mode 100644
index 0000000..bebb5de
--- /dev/null
+++ b/examples/run_asha_sink.py
@@ -0,0 +1,161 @@
+# 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 struct
+import sys
+import os
+import logging
+
+from bumble.core import AdvertisingData
+from bumble.device import Device
+from bumble.transport import open_transport_or_link
+from bumble.hci import UUID
+from bumble.gatt import (
+    Service,
+    Characteristic,
+    CharacteristicValue
+)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+ASHA_SERVICE                             = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
+ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
+ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC  = UUID('f0d4de7e-4a88-476c-9d9f-1937b0996cc0', 'AudioControlPoint')
+ASHA_AUDIO_STATUS_CHARACTERISTIC         = UUID('38663f1a-e711-4cac-b641-326b56404837', 'AudioStatus')
+ASHA_VOLUME_CHARACTERISTIC               = UUID('00e4ca9e-ab14-41e4-8823-f9e70c7e91df', 'Volume')
+ASHA_LE_PSM_OUT_CHARACTERISTIC           = UUID('2d410339-82b6-42aa-b34e-e2e01df8cc1a', 'LE_PSM_OUT')
+
+
+# -----------------------------------------------------------------------------
+async def main():
+    if len(sys.argv) != 4:
+        print('Usage: python run_asha_sink.py <device-config> <transport-spec> <audio-file>')
+        print('example: python run_asha_sink.py device1.json usb:0 audio_out.g722')
+        return
+
+    audio_out = open(sys.argv[3], 'wb')
+
+    async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
+        device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
+
+        # Handler for audio control commands
+        def on_audio_control_point_write(connection, value):
+            print('--- AUDIO CONTROL POINT Write:', value.hex())
+            opcode = value[0]
+            if opcode == 1:
+                # Start
+                audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]]
+                print(f'### START: codec={value[1]}, audio_type={audio_type}, volume={value[3]}, otherstate={value[4]}')
+            elif opcode == 2:
+                print('### STOP')
+            elif opcode == 3:
+                print(f'### STATUS: connected={value[1]}')
+
+            # Respond with a status
+            asyncio.create_task(device.notify_subscribers(audio_status_characteristic, force=True))
+
+        # Handler for volume control
+        def on_volume_write(connection, value):
+            print('--- VOLUME Write:', value[0])
+
+        # Register an L2CAP CoC server
+        def on_coc(channel):
+            def on_data(data):
+                print('<<< Voice data received:', data.hex())
+                audio_out.write(data)
+
+            channel.sink = on_data
+
+        psm = device.register_l2cap_channel_server(0, on_coc, 8)
+        print(f'### LE_PSM_OUT = {psm}')
+
+        # Add the ASHA service to the GATT server
+        read_only_properties_characteristic = Characteristic(
+            ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
+            Characteristic.READ,
+            Characteristic.READABLE,
+            bytes([
+                0x01,        # Version
+                0x00,        # Device Capabilities [Left, Monaural]
+                0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,  # HiSyncId
+                0x01,        # Feature Map [LE CoC audio output streaming supported]
+                0x00, 0x00,  # Render Delay
+                0x00, 0x00,  # RFU
+                0x02, 0x00   # Codec IDs [G.722 at 16 kHz]
+            ])
+        )
+        audio_control_point_characteristic = Characteristic(
+            ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
+            Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
+            Characteristic.WRITEABLE,
+            CharacteristicValue(write=on_audio_control_point_write)
+        )
+        audio_status_characteristic = Characteristic(
+            ASHA_AUDIO_STATUS_CHARACTERISTIC,
+            Characteristic.READ | Characteristic.NOTIFY,
+            Characteristic.READABLE,
+            bytes([0])
+        )
+        volume_characteristic = Characteristic(
+            ASHA_VOLUME_CHARACTERISTIC,
+            Characteristic.WRITE_WITHOUT_RESPONSE,
+            Characteristic.WRITEABLE,
+            CharacteristicValue(write=on_volume_write)
+        )
+        le_psm_out_characteristic = Characteristic(
+            ASHA_LE_PSM_OUT_CHARACTERISTIC,
+            Characteristic.READ,
+            Characteristic.READABLE,
+            struct.pack('<H', psm)
+        )
+        device.add_service(Service(
+            ASHA_SERVICE,
+            [
+                read_only_properties_characteristic,
+                audio_control_point_characteristic,
+                audio_status_characteristic,
+                volume_characteristic,
+                le_psm_out_characteristic
+            ]
+        ))
+
+        # Set the advertising data
+        device.advertising_data = bytes(
+            AdvertisingData([
+                (AdvertisingData.COMPLETE_LOCAL_NAME, bytes(device.name, 'utf-8')),
+                (AdvertisingData.FLAGS, bytes([0x06])),
+                (AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, bytes(ASHA_SERVICE)),
+                (AdvertisingData.SERVICE_DATA_16_BIT_UUID, bytes(ASHA_SERVICE) + bytes([
+                    0x01,  # Protocol Version
+                    0x00,  # Capability
+                    0x01, 0x02, 0x03, 0x04  # Truncated HiSyncID
+                ]))
+            ])
+        )
+
+        # Go!
+        await device.power_on()
+        await device.start_advertising(auto_restart=True)
+
+        await hci_source.wait_for_termination()
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_classic_connect.py b/examples/run_classic_connect.py
index d6842fe..8395a23 100644
--- a/examples/run_classic_connect.py
+++ b/examples/run_classic_connect.py
@@ -30,7 +30,7 @@
 # -----------------------------------------------------------------------------
 async def main():
     if len(sys.argv) < 3:
-        print('Usage: run_classic_connect.py <device-config> <transport-spec> <bluetooth-address>')
+        print('Usage: run_classic_connect.py <device-config> <transport-spec> <bluetooth-addresses..>')
         print('example: run_classic_connect.py classic1.json usb:04b4:f901 E1:CA:72:48:C4:E8')
         return
 
@@ -43,8 +43,7 @@
         device.classic_enabled = True
         await device.power_on()
 
-        # Connect to a peer
-        target_address = sys.argv[3]
+    async def connect(target_address):
         print(f'=== Connecting to {target_address}...')
         connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
         print(f'=== Connected to {connection.peer_address}!')
@@ -76,6 +75,10 @@
         await sdp_client.disconnect()
         await hci_source.wait_for_termination()
 
+    # Connect to a peer
+    target_addresses = sys.argv[3:]
+    await asyncio.wait([asyncio.create_task(connect(target_address)) for target_address in target_addresses])
+
 # -----------------------------------------------------------------------------
 logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
 asyncio.run(main())
diff --git a/examples/run_controller_with_scanner.py b/examples/run_controller_with_scanner.py
index 88bc1f8..18ba274 100644
--- a/examples/run_controller_with_scanner.py
+++ b/examples/run_controller_with_scanner.py
@@ -29,15 +29,15 @@
 
 # -----------------------------------------------------------------------------
 class ScannerListener(Device.Listener):
-    def on_advertisement(self, address, ad_data, rssi, connectable):
-        address_type_string = ('P', 'R', 'PI', 'RI')[address.address_type]
-        address_color = 'yellow' if connectable else 'red'
+    def on_advertisement(self, advertisement):
+        address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type]
+        address_color = 'yellow' if advertisement.is_connectable else 'red'
         if address_type_string.startswith('P'):
             type_color = 'green'
         else:
             type_color = 'cyan'
 
-        print(f'>>> {color(address, address_color)} [{color(address_type_string, type_color)}]: RSSI={rssi}, {ad_data}')
+        print(f'>>> {color(advertisement.address, address_color)} [{color(address_type_string, type_color)}]: RSSI={advertisement.rssi}, {advertisement.data}')
 
 
 # -----------------------------------------------------------------------------
diff --git a/examples/run_notifier.py b/examples/run_notifier.py
index f52b56c..15173cb 100644
--- a/examples/run_notifier.py
+++ b/examples/run_notifier.py
@@ -50,10 +50,16 @@
 
 
 # -----------------------------------------------------------------------------
+# Alternative way to listen for subscriptions
+# -----------------------------------------------------------------------------
+def on_my_characteristic_subscription(peer, enabled):
+    print(f'### My characteristic from {peer}: {"enabled" if enabled else "disabled"}')
+
+# -----------------------------------------------------------------------------
 async def main():
     if len(sys.argv) < 3:
-        print('Usage: run_gatt_server.py <device-config> <transport-spec>')
-        print('example: run_gatt_server.py device1.json usb:0')
+        print('Usage: run_notifier.py <device-config> <transport-spec>')
+        print('example: run_notifier.py device1.json usb:0')
         return
 
     print('<<< connecting to HCI...')
@@ -83,6 +89,7 @@
             Characteristic.READABLE,
             bytes([0x42])
         )
+        characteristic3.on('subscription', on_my_characteristic_subscription)
         custom_service = Service(
             '50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
             [characteristic1, characteristic2, characteristic3]
diff --git a/examples/run_rfcomm_client.py b/examples/run_rfcomm_client.py
index 83ef848..76586c3 100644
--- a/examples/run_rfcomm_client.py
+++ b/examples/run_rfcomm_client.py
@@ -98,6 +98,7 @@
 
     await sdp_client.disconnect()
 
+
 # -----------------------------------------------------------------------------
 class TcpServerProtocol(asyncio.Protocol):
     def __init__(self, rfcomm_session):
@@ -173,7 +174,7 @@
         print('*** Encryption on')
 
         # Create a client and start it
-        print('@@@ Starting to RFCOMM client...')
+        print('@@@ Starting RFCOMM client...')
         rfcomm_client = Client(device, connection)
         rfcomm_mux = await rfcomm_client.start()
         print('@@@ Started')
@@ -192,7 +193,7 @@
         if len(sys.argv) == 6:
             # A TCP port was specified, start listening
             tcp_port = int(sys.argv[5])
-            asyncio.get_running_loop().create_task(tcp_server(tcp_port, session))
+            asyncio.create_task(tcp_server(tcp_port, session))
 
         await hci_source.wait_for_termination()
 
diff --git a/examples/run_scanner.py b/examples/run_scanner.py
index feed88f..719e58e 100644
--- a/examples/run_scanner.py
+++ b/examples/run_scanner.py
@@ -40,24 +40,24 @@
         device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
 
         @device.on('advertisement')
-        def _(address, ad_data, rssi, connectable):
-            address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[address.address_type]
-            address_color = 'yellow' if connectable else 'red'
+        def _(advertisement):
+            address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[advertisement.address.address_type]
+            address_color = 'yellow' if advertisement.is_connectable else 'red'
             address_qualifier = ''
             if address_type_string.startswith('P'):
                 type_color = 'cyan'
             else:
-                if address.is_static:
+                if advertisement.address.is_static:
                     type_color = 'green'
                     address_qualifier = '(static)'
-                elif address.is_resolvable:
+                elif advertisement.address.is_resolvable:
                     type_color = 'magenta'
                     address_qualifier = '(resolvable)'
                 else:
                     type_color = 'white'
 
             separator = '\n  '
-            print(f'>>> {color(address, address_color)} [{color(address_type_string, type_color)}]{address_qualifier}:{separator}RSSI:{rssi}{separator}{ad_data.to_string(separator)}')
+            print(f'>>> {color(advertisement.address, address_color)} [{color(address_type_string, type_color)}]{address_qualifier}:{separator}RSSI:{advertisement.rssi}{separator}{advertisement.data.to_string(separator)}')
 
         await device.power_on()
         await device.start_scanning(filter_duplicates=filter_duplicates)
diff --git a/setup.cfg b/setup.cfg
index 05d07c9..441fbaa 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -48,8 +48,10 @@
 [options.entry_points]
 console_scripts =
     bumble-console = bumble.apps.console:main
+    bumble-controller-info = bumble.apps.controller_info:main
     bumble-gatt-dump = bumble.apps.gatt_dump:main
     bumble-hci-bridge = bumble.apps.hci_bridge:main
+    bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
     bumble-pair = bumble.apps.pair:main
     bumble-scan = bumble.apps.scan:main
     bumble-show = bumble.apps.show:main
@@ -63,10 +65,12 @@
 test =
     pytest >= 6.2
     pytest-asyncio >= 0.17
+    pytest-html >= 3.2.0
+    coverage >= 6.4
 development =
     invoke >= 1.4
     nox >= 2022
 documentation =
-    mkdocs >= 1.2.3
-    mkdocs-material >= 8.1.9
+    mkdocs >= 1.4.0
+    mkdocs-material >= 8.5.6
     mkdocstrings[python] >= 0.19.0
diff --git a/tasks.py b/tasks.py
index 61f3dd6..ddba8cd 100644
--- a/tasks.py
+++ b/tasks.py
@@ -53,7 +53,7 @@
 ns.add_collection(test_tasks, name="test")
 
 @task
-def test(ctx, filter=None, junit=False, install=False):
+def test(ctx, filter=None, junit=False, install=False, html=False):
     # Install the package before running the tests
     if install:
         ctx.run("python -m pip install .[test]")
@@ -63,6 +63,8 @@
         args += "--junit-xml test-results.xml"
     if filter is not None:
         args += " -k '{}'".format(filter)
+    if html:
+        args += "--html results.html"
     ctx.run("python -m pytest {} {}".format(os.path.join(ROOT_DIR, "tests"), args))
 
 test_tasks.add_task(test, default=True)
diff --git a/tests/core_test.py b/tests/core_test.py
index f4bdd83..fa397db 100644
--- a/tests/core_test.py
+++ b/tests/core_test.py
@@ -24,19 +24,19 @@
     ad = AdvertisingData.from_bytes(data)
     ad_bytes = bytes(ad)
     assert(data == ad_bytes)
-    assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME) is None)
-    assert(ad.get(AdvertisingData.TX_POWER_LEVEL) == bytes([123]))
-    assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True) == [])
-    assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True) == [bytes([123])])
+    assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None)
+    assert(ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123]))
+    assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == [])
+    assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [bytes([123])])
 
     data2 = bytes([2, AdvertisingData.TX_POWER_LEVEL, 234])
     ad.append(data2)
     ad_bytes = bytes(ad)
     assert(ad_bytes == data + data2)
-    assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME) is None)
-    assert(ad.get(AdvertisingData.TX_POWER_LEVEL) == bytes([123]))
-    assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True) == [])
-    assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True) == [bytes([123]), bytes([234])])
+    assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None)
+    assert(ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123]))
+    assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == [])
+    assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [bytes([123]), bytes([234])])
 
 
 # -----------------------------------------------------------------------------
diff --git a/tests/device_test.py b/tests/device_test.py
new file mode 100644
index 0000000..acf4446
--- /dev/null
+++ b/tests/device_test.py
@@ -0,0 +1,188 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import os
+from types import LambdaType
+import pytest
+
+from bumble.core import BT_BR_EDR_TRANSPORT
+from bumble.device import Connection, Device
+from bumble.host import Host
+from bumble.hci import (
+    HCI_ACCEPT_CONNECTION_REQUEST_COMMAND, HCI_COMMAND_STATUS_PENDING, HCI_CREATE_CONNECTION_COMMAND, HCI_SUCCESS,
+    Address, HCI_Command_Complete_Event, HCI_Command_Status_Event, HCI_Connection_Complete_Event, HCI_Connection_Request_Event, HCI_Packet
+)
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+class Sink:
+    def __init__(self, flow):
+        self.flow = flow
+        next(self.flow)
+
+    def on_packet(self, packet):
+        self.flow.send(packet)
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_device_connect_parallel():
+    d0 = Device(host=Host(None, None))
+    d1 = Device(host=Host(None, None))
+    d2 = Device(host=Host(None, None))
+
+    # enable classic
+    d0.classic_enabled = True
+    d1.classic_enabled = True
+    d2.classic_enabled = True
+
+    # set public addresses
+    d0.public_address = Address('F0:F1:F2:F3:F4:F5', address_type=Address.PUBLIC_DEVICE_ADDRESS)
+    d1.public_address = Address('F5:F4:F3:F2:F1:F0', address_type=Address.PUBLIC_DEVICE_ADDRESS)
+    d2.public_address = Address('F5:F4:F3:F3:F4:F5', address_type=Address.PUBLIC_DEVICE_ADDRESS)
+
+    def d0_flow():
+        packet = HCI_Packet.from_bytes((yield))
+        assert packet.name == 'HCI_CREATE_CONNECTION_COMMAND'
+        assert packet.bd_addr == d1.public_address
+
+        d0.host.on_hci_packet(HCI_Command_Status_Event(
+            status                  = HCI_COMMAND_STATUS_PENDING,
+            num_hci_command_packets = 1,
+            command_opcode          = HCI_CREATE_CONNECTION_COMMAND
+        ))
+
+        d1.host.on_hci_packet(HCI_Connection_Request_Event(
+           bd_addr         = d0.public_address,
+           class_of_device = 0,
+           link_type       = HCI_Connection_Complete_Event.ACL_LINK_TYPE
+        ))
+
+        packet = HCI_Packet.from_bytes((yield))
+        assert packet.name == 'HCI_CREATE_CONNECTION_COMMAND'
+        assert packet.bd_addr == d2.public_address
+
+        d0.host.on_hci_packet(HCI_Command_Status_Event(
+            status                  = HCI_COMMAND_STATUS_PENDING,
+            num_hci_command_packets = 1,
+            command_opcode          = HCI_CREATE_CONNECTION_COMMAND
+        ))
+
+        d2.host.on_hci_packet(HCI_Connection_Request_Event(
+           bd_addr         = d0.public_address,
+           class_of_device = 0,
+           link_type       = HCI_Connection_Complete_Event.ACL_LINK_TYPE
+        ))
+
+        assert (yield) == None
+        
+    def d1_flow():
+        packet = HCI_Packet.from_bytes((yield))
+        assert packet.name == 'HCI_ACCEPT_CONNECTION_REQUEST_COMMAND'
+
+        d1.host.on_hci_packet(HCI_Command_Complete_Event(
+            num_hci_command_packets = 1,
+            command_opcode          = HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
+            return_parameters       = b"\x00"
+        ))
+
+        d1.host.on_hci_packet(HCI_Connection_Complete_Event(
+            status             = HCI_SUCCESS,
+            connection_handle  = 0x100,
+            bd_addr            = d0.public_address,
+            link_type          = HCI_Connection_Complete_Event.ACL_LINK_TYPE,
+            encryption_enabled = True,
+        ))
+
+        d0.host.on_hci_packet(HCI_Connection_Complete_Event(
+            status             = HCI_SUCCESS,
+            connection_handle  = 0x100,
+            bd_addr            = d1.public_address,
+            link_type          = HCI_Connection_Complete_Event.ACL_LINK_TYPE,
+            encryption_enabled = True,
+        ))
+
+        assert (yield) == None
+
+    def d2_flow():
+        packet = HCI_Packet.from_bytes((yield))
+        assert packet.name == 'HCI_ACCEPT_CONNECTION_REQUEST_COMMAND'
+
+        d2.host.on_hci_packet(HCI_Command_Complete_Event(
+            num_hci_command_packets = 1,
+            command_opcode          = HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
+            return_parameters       = b"\x00"
+        ))
+
+        d2.host.on_hci_packet(HCI_Connection_Complete_Event(
+            status             = HCI_SUCCESS,
+            connection_handle  = 0x101,
+            bd_addr            = d0.public_address,
+            link_type          = HCI_Connection_Complete_Event.ACL_LINK_TYPE,
+            encryption_enabled = True,
+        ))
+
+        d0.host.on_hci_packet(HCI_Connection_Complete_Event(
+            status             = HCI_SUCCESS,
+            connection_handle  = 0x101,
+            bd_addr            = d2.public_address,
+            link_type          = HCI_Connection_Complete_Event.ACL_LINK_TYPE,
+            encryption_enabled = True,
+        ))
+
+        assert (yield) == None
+
+    d0.host.set_packet_sink(Sink(d0_flow()))
+    d1.host.set_packet_sink(Sink(d1_flow()))
+    d2.host.set_packet_sink(Sink(d2_flow()))
+
+    [c01, c02, a10, a20, a01] = await asyncio.gather(*[
+        asyncio.create_task(d0.connect(d1.public_address, transport=BT_BR_EDR_TRANSPORT)),
+        asyncio.create_task(d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)),
+        asyncio.create_task(d1.accept(peer_address=d0.public_address)),
+        asyncio.create_task(d2.accept()),
+        asyncio.create_task(d0.accept(peer_address=d1.public_address)),
+    ])
+
+    assert type(c01) == Connection
+    assert type(c02) == Connection
+    assert type(a10) == Connection
+    assert type(a20) == Connection
+    assert type(a01) == Connection
+
+    assert c01.handle == a10.handle and c01.handle == 0x100
+    assert c02.handle == a20.handle and c02.handle == 0x101
+    assert a01 == c01
+
+
+# -----------------------------------------------------------------------------
+async def run_test_device():
+    await test_device_connect_parallel()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run_test_device())
diff --git a/tests/gatt_test.py b/tests/gatt_test.py
index 4bfcce8..927aeee 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -22,6 +22,7 @@
 import pytest
 
 from bumble.controller import Controller
+from bumble.gatt_client import CharacteristicProxy
 from bumble.link import LocalLink
 from bumble.device import Device, Peer
 from bumble.host import Host
@@ -53,29 +54,29 @@
     parsed = ATT_PDU.from_bytes(pdu)
     x_str = str(x)
     parsed_str = str(parsed)
-    assert(x_str == parsed_str)
+    assert x_str == parsed_str
 
 
 # -----------------------------------------------------------------------------
 def test_UUID():
     u = UUID.from_16_bits(0x7788)
-    assert(str(u) == 'UUID-16:7788')
+    assert str(u) == 'UUID-16:7788'
     u = UUID.from_32_bits(0x11223344)
-    assert(str(u) == 'UUID-32:11223344')
+    assert str(u) == 'UUID-32:11223344'
     u = UUID('61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
-    assert(str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
+    assert str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
     v = UUID(str(u))
-    assert(str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
+    assert str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
     w = UUID.from_bytes(v.to_bytes())
-    assert(str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
+    assert str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
 
     u1 = UUID.from_16_bits(0x1234)
     b1 = u1.to_bytes(force_128 = True)
     u2 = UUID.from_bytes(b1)
-    assert(u1 == u2)
+    assert u1 == u2
 
     u3 = UUID.from_16_bits(0x180a)
-    assert(str(u3) == 'UUID-16:180A (Device Information)')
+    assert str(u3) == 'UUID-16:180A (Device Information)'
 
 
 # -----------------------------------------------------------------------------
@@ -99,6 +100,133 @@
 
 
 # -----------------------------------------------------------------------------
[email protected]
+async def test_characteristic_encoding():
+    class Foo(Characteristic):
+        def encode_value(self, value):
+            return bytes([value])
+
+        def decode_value(self, value_bytes):
+            return value_bytes[0]
+
+    c = Foo(GATT_BATTERY_LEVEL_CHARACTERISTIC, Characteristic.READ, Characteristic.READABLE, 123)
+    x = c.read_value(None)
+    assert x == bytes([123])
+    c.write_value(None, bytes([122]))
+    assert c.value == 122
+
+    class FooProxy(CharacteristicProxy):
+        def __init__(self, characteristic):
+            super().__init__(
+                characteristic.client,
+                characteristic.handle,
+                characteristic.end_group_handle,
+                characteristic.uuid,
+                characteristic.properties
+            )
+
+        def encode_value(self, value):
+            return bytes([value])
+
+        def decode_value(self, value_bytes):
+            return value_bytes[0]
+
+    [client, server] = LinkedDevices().devices[:2]
+
+    characteristic = Characteristic(
+        'FDB159DB-036C-49E3-B3DB-6325AC750806',
+        Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+        Characteristic.READABLE | Characteristic.WRITEABLE,
+        bytes([123])
+    )
+
+    service = Service(
+        '3A657F47-D34F-46B3-B1EC-698E29B6B829',
+        [characteristic]
+    )
+    server.add_service(service)
+
+    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(characteristic.uuid)
+    assert len(c) == 1
+    c = c[0]
+    cp = FooProxy(c)
+
+    v = await cp.read_value()
+    assert v == 123
+    await cp.write_value(124)
+    await async_barrier()
+    assert characteristic.value == bytes([124])
+
+    v = await cp.read_value()
+    assert v == 124
+    await cp.write_value(125, with_response=True)
+    await async_barrier()
+    assert characteristic.value == bytes([125])
+
+    cd = DelegatedCharacteristicAdapter(c, encode=lambda x: bytes([x // 2]))
+    await cd.write_value(100, with_response=True)
+    await async_barrier()
+    assert characteristic.value == bytes([50])
+
+    last_change = None
+
+    def on_change(value):
+        nonlocal last_change
+        last_change = value
+
+    await c.subscribe(on_change)
+    await server.notify_subscribers(characteristic)
+    await async_barrier()
+    assert last_change == characteristic.value
+    last_change = None
+
+    await server.notify_subscribers(characteristic, value=bytes([125]))
+    await async_barrier()
+    assert last_change == bytes([125])
+    last_change = None
+
+    await c.unsubscribe(on_change)
+    await server.notify_subscribers(characteristic)
+    await async_barrier()
+    assert last_change is None
+
+    await cp.subscribe(on_change)
+    await server.notify_subscribers(characteristic)
+    await async_barrier()
+    assert last_change == characteristic.value[0]
+    last_change = None
+
+    await server.notify_subscribers(characteristic, value=bytes([126]))
+    await async_barrier()
+    assert last_change == 126
+    last_change = None
+
+    await cp.unsubscribe(on_change)
+    await server.notify_subscribers(characteristic)
+    await async_barrier()
+    assert last_change is None
+
+    cd = DelegatedCharacteristicAdapter(c, decode=lambda x: x[0])
+    await cd.subscribe(on_change)
+    await server.notify_subscribers(characteristic)
+    await async_barrier()
+    assert last_change == characteristic.value[0]
+    last_change = None
+
+    await cd.unsubscribe(on_change)
+    await server.notify_subscribers(characteristic)
+    await async_barrier()
+    assert last_change is None
+
+
+# -----------------------------------------------------------------------------
 def test_CharacteristicAdapter():
     # Check that the CharacteristicAdapter base class is transparent
     v = bytes([1, 2, 3])
@@ -106,21 +234,21 @@
     a = CharacteristicAdapter(c)
 
     value = a.read_value(None)
-    assert(value == v)
+    assert value == v
 
     v = bytes([3, 4, 5])
     a.write_value(None, v)
-    assert(c.value == v)
+    assert c.value == v
 
     # Simple delegated adapter
     a = DelegatedCharacteristicAdapter(c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x)))
 
     value = a.read_value(None)
-    assert(value == bytes(reversed(v)))
+    assert value == bytes(reversed(v))
 
     v = bytes([3, 4, 5])
     a.write_value(None, v)
-    assert(a.value == bytes(reversed(v)))
+    assert a.value == bytes(reversed(v))
 
     # Packed adapter with single element format
     v = 1234
@@ -129,10 +257,10 @@
     a = PackedCharacteristicAdapter(c, '>H')
 
     value = a.read_value(None)
-    assert(value == pv)
+    assert value == pv
     c.value = None
     a.write_value(None, pv)
-    assert(a.value == v)
+    assert a.value == v
 
     # Packed adapter with multi-element format
     v1 = 1234
@@ -142,10 +270,10 @@
     a = PackedCharacteristicAdapter(c, '>HH')
 
     value = a.read_value(None)
-    assert(value == pv)
+    assert value == pv
     c.value = None
     a.write_value(None, pv)
-    assert(a.value == (v1, v2))
+    assert a.value == (v1, v2)
 
     # Mapped adapter
     v1 = 1234
@@ -156,10 +284,10 @@
     a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
 
     value = a.read_value(None)
-    assert(value == pv)
+    assert value == pv
     c.value = None
     a.write_value(None, pv)
-    assert(a.value == mapped)
+    assert a.value == mapped
 
     # UTF-8 adapter
     v = 'Hello π'
@@ -168,10 +296,10 @@
     a = UTF8CharacteristicAdapter(c)
 
     value = a.read_value(None)
-    assert(value == ev)
+    assert value == ev
     c.value = None
     a.write_value(None, ev)
-    assert(a.value == v)
+    assert a.value == v
 
 
 # -----------------------------------------------------------------------------
@@ -179,24 +307,25 @@
     b = bytes([1, 2, 3])
     c = CharacteristicValue(read=lambda _: b)
     x = c.read(None)
-    assert(x == b)
+    assert x == b
 
     result = []
     c = CharacteristicValue(write=lambda connection, value: result.append((connection, value)))
     z = object()
     c.write(z, b)
-    assert(result == [(z, b)])
+    assert result == [(z, b)]
 
 
 # -----------------------------------------------------------------------------
-class TwoDevices:
+class LinkedDevices:
     def __init__(self):
-        self.connections = [None, None]
+        self.connections = [None, None, None]
 
         self.link = LocalLink()
         self.controllers = [
             Controller('C1', link = self.link),
-            Controller('C2', link = self.link)
+            Controller('C2', link = self.link),
+            Controller('C3', link = self.link)
         ]
         self.devices = [
             Device(
@@ -204,12 +333,16 @@
                 host    = Host(self.controllers[0], AsyncPipeSink(self.controllers[0]))
             ),
             Device(
-                address = 'F5:F4:F3:F2:F1:F0',
+                address = 'F1:F2:F3:F4:F5:F6',
                 host    = Host(self.controllers[1], AsyncPipeSink(self.controllers[1]))
+            ),
+            Device(
+                address = 'F2:F3:F4:F5:F6:F7',
+                host    = Host(self.controllers[2], AsyncPipeSink(self.controllers[2]))
             )
         ]
 
-        self.paired = [None, None]
+        self.paired = [None, None, None]
 
 
 # -----------------------------------------------------------------------------
@@ -222,7 +355,7 @@
 # -----------------------------------------------------------------------------
 @pytest.mark.asyncio
 async def test_read_write():
-    [client, server] = TwoDevices().devices
+    [client, server] = LinkedDevices().devices[:2]
 
     characteristic1 = Characteristic(
         'FDB159DB-036C-49E3-B3DB-6325AC750806',
@@ -265,41 +398,41 @@
     await peer.discover_services()
     await peer.discover_characteristics()
     c = peer.get_characteristics_by_uuid(characteristic1.uuid)
-    assert(len(c) == 1)
+    assert len(c) == 1
     c1 = c[0]
     c = peer.get_characteristics_by_uuid(characteristic2.uuid)
-    assert(len(c) == 1)
+    assert len(c) == 1
     c2 = c[0]
 
     v1 = await peer.read_value(c1)
-    assert(v1 == b'')
+    assert v1 == b''
     b = bytes([1, 2, 3])
     await peer.write_value(c1, b)
     await async_barrier()
-    assert(characteristic1.value == b)
+    assert characteristic1.value == b
     v1 = await peer.read_value(c1)
-    assert(v1 == b)
-    assert(type(characteristic1._last_value) is tuple)
-    assert(len(characteristic1._last_value) == 2)
-    assert(str(characteristic1._last_value[0].peer_address) == str(client.random_address))
-    assert(characteristic1._last_value[1] == b)
+    assert v1 == b
+    assert type(characteristic1._last_value is tuple)
+    assert len(characteristic1._last_value) == 2
+    assert str(characteristic1._last_value[0].peer_address) == str(client.random_address)
+    assert characteristic1._last_value[1] == b
     bb = bytes([3, 4, 5, 6])
     characteristic1.value = bb
     v1 = await peer.read_value(c1)
-    assert(v1 == bb)
+    assert v1 == bb
 
     await peer.write_value(c2, b)
     await async_barrier()
-    assert(type(characteristic2._last_value) is tuple)
-    assert(len(characteristic2._last_value) == 2)
-    assert(str(characteristic2._last_value[0].peer_address) == str(client.random_address))
-    assert(characteristic2._last_value[1] == b)
+    assert type(characteristic2._last_value is tuple)
+    assert len(characteristic2._last_value) == 2
+    assert str(characteristic2._last_value[0].peer_address) == str(client.random_address)
+    assert characteristic2._last_value[1] == b
 
 
 # -----------------------------------------------------------------------------
 @pytest.mark.asyncio
 async def test_read_write2():
-    [client, server] = TwoDevices().devices
+    [client, server] = LinkedDevices().devices[:2]
 
     v = bytes([0x11, 0x22, 0x33, 0x44])
     characteristic1 = Characteristic(
@@ -324,32 +457,32 @@
 
     await peer.discover_services()
     c = peer.get_services_by_uuid(service1.uuid)
-    assert(len(c) == 1)
+    assert len(c) == 1
     s = c[0]
     await s.discover_characteristics()
     c = s.get_characteristics_by_uuid(characteristic1.uuid)
-    assert(len(c) == 1)
+    assert len(c) == 1
     c1 = c[0]
 
     v1 = await c1.read_value()
-    assert(v1 == v)
+    assert v1 == v
 
     a1 = PackedCharacteristicAdapter(c1, '>I')
     v1 = await a1.read_value()
-    assert(v1 == struct.unpack('>I', v)[0])
+    assert v1 == struct.unpack('>I', v)[0]
 
     b = bytes([0x55, 0x66, 0x77, 0x88])
     await a1.write_value(struct.unpack('>I', b)[0])
     await async_barrier()
-    assert(characteristic1.value == b)
+    assert characteristic1.value == b
     v1 = await a1.read_value()
-    assert(v1 == struct.unpack('>I', b)[0])
+    assert v1 == struct.unpack('>I', b)[0]
 
 
 # -----------------------------------------------------------------------------
 @pytest.mark.asyncio
 async def test_subscribe_notify():
-    [client, server] = TwoDevices().devices
+    [client, server] = LinkedDevices().devices[:2]
 
     characteristic1 = Characteristic(
         'FDB159DB-036C-49E3-B3DB-6325AC750806',
@@ -410,13 +543,13 @@
     await peer.discover_services()
     await peer.discover_characteristics()
     c = peer.get_characteristics_by_uuid(characteristic1.uuid)
-    assert(len(c) == 1)
+    assert len(c) == 1
     c1 = c[0]
     c = peer.get_characteristics_by_uuid(characteristic2.uuid)
-    assert(len(c) == 1)
+    assert len(c) == 1
     c2 = c[0]
     c = peer.get_characteristics_by_uuid(characteristic3.uuid)
-    assert(len(c) == 1)
+    assert len(c) == 1
     c3 = c[0]
 
     c1._called = False
@@ -429,23 +562,32 @@
     c1.on('update', on_c1_update)
     await peer.subscribe(c1)
     await async_barrier()
-    assert(server._last_subscription[1] == characteristic1)
-    assert(server._last_subscription[2])
-    assert(not server._last_subscription[3])
-    assert(characteristic1._last_subscription[1])
-    assert(not characteristic1._last_subscription[2])
+    assert server._last_subscription[1] == characteristic1
+    assert server._last_subscription[2]
+    assert not server._last_subscription[3]
+    assert characteristic1._last_subscription[1]
+    assert not characteristic1._last_subscription[2]
     await server.indicate_subscribers(characteristic1)
     await async_barrier()
-    assert(not c1._called)
+    assert not c1._called
     await server.notify_subscribers(characteristic1)
     await async_barrier()
-    assert(c1._called)
-    assert(c1._last_update == characteristic1.value)
+    assert c1._called
+    assert c1._last_update == characteristic1.value
+
+    c1._called = False
+    c1._last_update = None
+    c1_value = characteristic1.value
+    await server.notify_subscribers(characteristic1, bytes([0, 1, 2]))
+    await async_barrier()
+    assert c1._called
+    assert c1._last_update == bytes([0, 1, 2])
+    assert characteristic1.value == c1_value
 
     c1._called = False
     await peer.unsubscribe(c1)
     await server.notify_subscribers(characteristic1)
-    assert(not c1._called)
+    assert not c1._called
 
     c2._called = False
     c2._last_update = None
@@ -458,51 +600,109 @@
     await async_barrier()
     await server.notify_subscriber(characteristic2._last_subscription[0], characteristic2)
     await async_barrier()
-    assert(not c2._called)
+    assert not c2._called
     await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
     await async_barrier()
-    assert(c2._called)
-    assert(c2._last_update == characteristic2.value)
+    assert c2._called
+    assert c2._last_update == characteristic2.value
 
     c2._called = False
     await peer.unsubscribe(c2, on_c2_update)
     await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
     await async_barrier()
-    assert(not c2._called)
+    assert not c2._called
+
+    c3._called = False
+    c3._called_2 = False
+    c3._called_3 = False
+    c3._last_update = None
+    c3._last_update_2 = None
+    c3._last_update_3 = None
 
     def on_c3_update(value):
         c3._called = True
         c3._last_update = value
 
-    def on_c3_update_2(value):
+    def on_c3_update_2(value):  # for notify
         c3._called_2 = True
         c3._last_update_2 = value
 
+    def on_c3_update_3(value):  # for indicate
+        c3._called_3 = True
+        c3._last_update_3 = value
+
     c3.on('update', on_c3_update)
     await peer.subscribe(c3, on_c3_update_2)
     await async_barrier()
     await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
     await async_barrier()
-    assert(c3._called)
-    assert(c3._last_update == characteristic3.value)
-    assert(c3._called_2)
-    assert(c3._last_update_2 == characteristic3.value)
-    characteristic3.value = bytes([1, 2, 3])
-    await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
-    await async_barrier()
-    assert(c3._called)
-    assert(c3._last_update == characteristic3.value)
-    assert(c3._called_2)
-    assert(c3._last_update_2 == characteristic3.value)
+    assert c3._called
+    assert c3._last_update == characteristic3.value
+    assert c3._called_2
+    assert c3._last_update_2 == characteristic3.value
+    assert not c3._called_3
 
     c3._called = False
     c3._called_2 = False
+    c3._called_3 = False
+    await peer.unsubscribe(c3)
+    await peer.subscribe(c3, on_c3_update_3, prefer_notify=False)
+    await async_barrier()
+    characteristic3.value = bytes([1, 2, 3])
+    await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
+    await async_barrier()
+    assert c3._called
+    assert c3._last_update == characteristic3.value
+    assert not c3._called_2
+    assert c3._called_3
+    assert c3._last_update_3 == characteristic3.value
+
+    c3._called = False
+    c3._called_2 = False
+    c3._called_3 = False
     await peer.unsubscribe(c3)
     await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
     await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
     await async_barrier()
-    assert(not c3._called)
-    assert(not c3._called_2)
+    assert not c3._called
+    assert not c3._called_2
+    assert not c3._called_3
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_mtu_exchange():
+    [d1, d2, d3] = LinkedDevices().devices[:3]
+
+    d3.gatt_server.max_mtu = 100
+
+    d3_connections = []
+    @d3.on('connection')
+    def on_d3_connection(connection):
+        d3_connections.append(connection)
+
+    await d1.power_on()
+    await d2.power_on()
+    await d3.power_on()
+
+    d1_connection = await d1.connect(d3.random_address)
+    assert len(d3_connections) == 1
+    assert d3_connections[0] is not None
+
+    d2_connection = await d2.connect(d3.random_address)
+    assert len(d3_connections) == 2
+    assert d3_connections[1] is not None
+
+    d1_peer = Peer(d1_connection)
+    d2_peer = Peer(d2_connection)
+
+    d1_client_mtu = await d1_peer.request_mtu(220)
+    assert d1_client_mtu == 100
+    assert d1_connection.att_mtu == 100
+
+    d2_client_mtu = await d2_peer.request_mtu(50)
+    assert d2_client_mtu == 50
+    assert d2_connection.att_mtu == 50
 
 
 # -----------------------------------------------------------------------------
@@ -510,6 +710,9 @@
     await test_read_write()
     await test_read_write2()
     await test_subscribe_notify()
+    await test_characteristic_encoding()
+    await test_mtu_exchange()
+
 
 # -----------------------------------------------------------------------------
 if __name__ == '__main__':
diff --git a/tests/hci_test.py b/tests/hci_test.py
index 6331847..a5e3a82 100644
--- a/tests/hci_test.py
+++ b/tests/hci_test.py
@@ -27,8 +27,8 @@
     parsed_str = str(parsed)
     print(x_str)
     parsed_bytes = parsed.to_bytes()
-    assert(x_str == parsed_str)
-    assert(packet == parsed_bytes)
+    assert x_str == parsed_str
+    assert packet == parsed_bytes
 
 
 # -----------------------------------------------------------------------------
@@ -49,19 +49,19 @@
         role = 1,
         peer_address_type = 1,
         peer_address = address,
-        conn_interval = 3,
-        conn_latency = 4,
+        connection_interval = 3,
+        peripheral_latency = 4,
         supervision_timeout = 5,
-        master_clock_accuracy = 6
+        central_clock_accuracy = 6
     )
     basic_check(event)
 
 
 # -----------------------------------------------------------------------------
 def test_HCI_LE_Advertising_Report_Event():
-    address = Address('00:11:22:33:44:55')
-    report = HCI_Object(
-        HCI_LE_Advertising_Report_Event.REPORT_FIELDS,
+    address = Address('00:11:22:33:44:55/P')
+    report = HCI_LE_Advertising_Report_Event.Report(
+        HCI_LE_Advertising_Report_Event.Report.FIELDS,
         event_type   = HCI_LE_Advertising_Report_Event.ADV_IND,
         address_type = Address.PUBLIC_DEVICE_ADDRESS,
         address      = address,
@@ -87,8 +87,8 @@
     event = HCI_LE_Connection_Update_Complete_Event(
         status              = HCI_SUCCESS,
         connection_handle   = 0x007,
-        conn_interval       = 10,
-        conn_latency        = 3,
+        connection_interval = 10,
+        peripheral_latency  = 3,
         supervision_timeout = 5
     )
     basic_check(event)
@@ -133,7 +133,7 @@
     )
     basic_check(event)
     event = HCI_Packet.from_bytes(event.to_bytes())
-    assert(event.return_parameters == 7)
+    assert event.return_parameters == 7
 
     # With a simple status as an integer status
     event = HCI_Command_Complete_Event(
@@ -142,7 +142,7 @@
         return_parameters = 9
     )
     basic_check(event)
-    assert(event.return_parameters == 9)
+    assert event.return_parameters == 9
 
 
 # -----------------------------------------------------------------------------
@@ -283,12 +283,32 @@
         peer_address_type       = 1,
         peer_address            = Address('00:11:22:33:44:55'),
         own_address_type        = 2,
-        conn_interval_min       = 7,
-        conn_interval_max       = 8,
-        conn_latency            = 9,
+        connection_interval_min = 7,
+        connection_interval_max = 8,
+        max_latency             = 9,
         supervision_timeout     = 10,
-        minimum_ce_length       = 11,
-        maximum_ce_length       = 12
+        min_ce_length           = 11,
+        max_ce_length           = 12
+    )
+    basic_check(command)
+
+
+# -----------------------------------------------------------------------------
+def test_HCI_LE_Extended_Create_Connection_Command():
+    command = HCI_LE_Extended_Create_Connection_Command(
+        initiator_filter_policy  = 0,
+        own_address_type         = 0,
+        peer_address_type        = 1,
+        peer_address             = Address('00:11:22:33:44:55'),
+        initiating_phys          = 3,
+        scan_intervals           = (10, 11),
+        scan_windows             = (12, 13),
+        connection_interval_mins = (14, 15),
+        connection_interval_maxs = (16, 17),
+        max_latencies            = (18, 19),
+        supervision_timeouts     = (20, 21),
+        min_ce_lengths           = (100, 101),
+        max_ce_lengths           = (102, 103)
     )
     basic_check(command)
 
@@ -314,13 +334,13 @@
 # -----------------------------------------------------------------------------
 def test_HCI_LE_Connection_Update_Command():
     command = HCI_LE_Connection_Update_Command(
-        connection_handle   = 0x0002,
-        conn_interval_min   = 10,
-        conn_interval_max   = 20,
-        conn_latency        = 7,
-        supervision_timeout = 3,
-        minimum_ce_length   = 100,
-        maximum_ce_length   = 200
+        connection_handle       = 0x0002,
+        connection_interval_min = 10,
+        connection_interval_max = 20,
+        max_latency             = 7,
+        supervision_timeout     = 3,
+        min_ce_length           = 100,
+        max_ce_length           = 200
     )
     basic_check(command)
 
@@ -348,7 +368,7 @@
     command = HCI_LE_Set_Extended_Scan_Parameters_Command(
         own_address_type=Address.RANDOM_DEVICE_ADDRESS,
         scanning_filter_policy=HCI_LE_Set_Extended_Scan_Parameters_Command.BASIC_FILTERED_POLICY,
-        scanning_phys=(1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_1M_PHY | 1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_CODED_PHY | 1 << 4),
+        scanning_phys=(1 << HCI_LE_1M_PHY_BIT | 1 << HCI_LE_CODED_PHY_BIT | 1 << 4),
         scan_types=[
             HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING,
             HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING,
@@ -363,20 +383,20 @@
 # -----------------------------------------------------------------------------
 def test_address():
     a = Address('C4:F2:17:1A:1D:BB')
-    assert(not a.is_public)
-    assert(a.is_random)
-    assert(a.address_type == Address.RANDOM_DEVICE_ADDRESS)
-    assert(not a.is_resolvable)
-    assert(not a.is_resolved)
-    assert(a.is_static)
+    assert not a.is_public
+    assert a.is_random
+    assert a.address_type == Address.RANDOM_DEVICE_ADDRESS
+    assert not a.is_resolvable
+    assert not a.is_resolved
+    assert a.is_static
 
 
 # -----------------------------------------------------------------------------
 def test_custom():
     data = bytes([0x77, 0x02, 0x01, 0x03])
     packet = HCI_CustomPacket(data)
-    assert(packet.hci_packet_type == 0x77)
-    assert(packet.payload == data)
+    assert packet.hci_packet_type == 0x77
+    assert packet.payload == data
 
 
 # -----------------------------------------------------------------------------
@@ -408,6 +428,7 @@
     test_HCI_LE_Set_Scan_Parameters_Command()
     test_HCI_LE_Set_Scan_Enable_Command()
     test_HCI_LE_Create_Connection_Command()
+    test_HCI_LE_Extended_Create_Connection_Command()
     test_HCI_LE_Add_Device_To_Filter_Accept_List_Command()
     test_HCI_LE_Remove_Device_From_Filter_Accept_List_Command()
     test_HCI_LE_Connection_Update_Command()
diff --git a/tests/import_test.py b/tests/import_test.py
index d6eafbd..4a83cea 100644
--- a/tests/import_test.py
+++ b/tests/import_test.py
@@ -63,5 +63,56 @@
 
 
 # -----------------------------------------------------------------------------
+def test_app_imports():
+    from apps.console import main
+    assert main
+
+    from apps.controller_info import main
+    assert main
+
+    from apps.controllers import main
+    assert main
+
+    from apps.gatt_dump import main
+    assert main
+
+    from apps.gg_bridge import main
+    assert main
+
+    from apps.hci_bridge import main
+    assert main
+
+    from apps.pair import main
+    assert main
+
+    from apps.scan import main
+    assert main
+
+    from apps.show import main
+    assert main
+
+    from apps.unbond import main
+    assert main
+
+    from apps.usb_probe import main
+    assert main
+
+
+# -----------------------------------------------------------------------------
+def test_profiles_imports():
+    from bumble.profiles import (
+        battery_service,
+        device_information_service,
+        heart_rate_service
+    )
+
+    assert battery_service
+    assert device_information_service
+    assert heart_rate_service
+
+
+# -----------------------------------------------------------------------------
 if __name__ == '__main__':
     test_import()
+    test_app_imports()
+    test_profiles_imports()
diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py
new file mode 100644
index 0000000..82d5386
--- /dev/null
+++ b/tests/l2cap_test.py
@@ -0,0 +1,284 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import os
+import random
+import pytest
+
+from bumble.controller import Controller
+from bumble.link import LocalLink
+from bumble.device import Device
+from bumble.host import Host
+from bumble.transport import AsyncPipeSink
+from bumble.core import ProtocolError
+from bumble.l2cap import (
+    L2CAP_Connection_Request
+)
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+class TwoDevices:
+    def __init__(self):
+        self.connections = [None, None]
+
+        self.link = LocalLink()
+        self.controllers = [
+            Controller('C1', link = self.link),
+            Controller('C2', link = self.link)
+        ]
+        self.devices = [
+            Device(
+                address = 'F0:F1:F2:F3:F4:F5',
+                host    = Host(self.controllers[0], AsyncPipeSink(self.controllers[0]))
+            ),
+            Device(
+                address = 'F5:F4:F3:F2:F1:F0',
+                host    = Host(self.controllers[1], AsyncPipeSink(self.controllers[1]))
+            )
+        ]
+
+        self.paired = [None, None]
+
+    def on_connection(self, which, connection):
+        self.connections[which] = connection
+
+    def on_paired(self, which, keys):
+        self.paired[which] = keys
+
+
+# -----------------------------------------------------------------------------
+async def setup_connection():
+    # Create two devices, each with a controller, attached to the same link
+    two_devices = TwoDevices()
+
+    # Attach listeners
+    two_devices.devices[0].on('connection', lambda connection: two_devices.on_connection(0, connection))
+    two_devices.devices[1].on('connection', lambda connection: two_devices.on_connection(1, connection))
+
+    # Start
+    await two_devices.devices[0].power_on()
+    await two_devices.devices[1].power_on()
+
+    # Connect the two devices
+    await two_devices.devices[0].connect(two_devices.devices[1].random_address)
+
+    # Check the post conditions
+    assert two_devices.connections[0] is not None
+    assert two_devices.connections[1] is not None
+
+    return two_devices
+
+
+# -----------------------------------------------------------------------------
+def test_helpers():
+    psm = L2CAP_Connection_Request.serialize_psm(0x01)
+    assert psm == bytes([0x01, 0x00])
+
+    psm = L2CAP_Connection_Request.serialize_psm(0x1023)
+    assert psm == bytes([0x23, 0x10])
+
+    psm = L2CAP_Connection_Request.serialize_psm(0x242311)
+    assert psm == bytes([0x11, 0x23, 0x24])
+
+    (offset, psm) = L2CAP_Connection_Request.parse_psm(bytes([0x00, 0x01, 0x00, 0x44]), 1)
+    assert offset == 3
+    assert psm == 0x01
+
+    (offset, psm) = L2CAP_Connection_Request.parse_psm(bytes([0x00, 0x23, 0x10, 0x44]), 1)
+    assert offset == 3
+    assert psm == 0x1023
+
+    (offset, psm) = L2CAP_Connection_Request.parse_psm(bytes([0x00, 0x11, 0x23, 0x24, 0x44]), 1)
+    assert offset == 4
+    assert psm == 0x242311
+
+    rq = L2CAP_Connection_Request(psm = 0x01, source_cid = 0x44)
+    brq = bytes(rq)
+    srq = L2CAP_Connection_Request.from_bytes(brq)
+    assert srq.psm == rq.psm
+    assert srq.source_cid == rq.source_cid
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_basic_connection():
+    devices = await setup_connection()
+    psm = 1234
+
+    # Check that if there's no one listening, we can't connect
+    with pytest.raises(ProtocolError):
+        l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
+
+    # Now add a listener
+    incoming_channel = None
+    received = []
+
+    def on_coc(channel):
+        nonlocal incoming_channel
+        incoming_channel = channel
+
+        def on_data(data):
+            received.append(data)
+
+        channel.sink = on_data
+
+    devices.devices[1].register_l2cap_channel_server(psm, on_coc)
+    l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
+
+    messages = (
+        bytes([1, 2, 3]),
+        bytes([4, 5, 6]),
+        bytes(10000)
+    )
+    for message in messages:
+        l2cap_channel.write(message)
+        await asyncio.sleep(0)
+
+    await l2cap_channel.drain()
+
+    # Test closing
+    closed = [False, False]
+    closed_event = asyncio.Event()
+
+    def on_close(which, event):
+        closed[which] = True
+        if event:
+            event.set()
+
+    l2cap_channel.on('close', lambda: on_close(0, None))
+    incoming_channel.on('close', lambda: on_close(1, closed_event))
+    await l2cap_channel.disconnect()
+    assert closed == [True, True]
+    await closed_event.wait()
+
+    sent_bytes = b''.join(messages)
+    received_bytes = b''.join(received)
+    assert sent_bytes == received_bytes
+
+
+# -----------------------------------------------------------------------------
+async def transfer_payload(max_credits, mtu, mps):
+    devices = await setup_connection()
+
+    received = []
+
+    def on_coc(channel):
+        def on_data(data):
+            received.append(data)
+
+        channel.sink = on_data
+
+    psm = devices.devices[1].register_l2cap_channel_server(
+        psm         = 0,
+        server      = on_coc,
+        max_credits = max_credits,
+        mtu         = mtu,
+        mps         = mps
+    )
+    l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
+
+    messages = [
+        bytes([1, 2, 3, 4, 5, 6, 7]) * x
+        for x in (3, 10, 100, 789)
+    ]
+    for message in messages:
+        l2cap_channel.write(message)
+        await asyncio.sleep(0)
+        if random.randint(0, 5) == 1:
+            await l2cap_channel.drain()
+
+    await l2cap_channel.drain()
+    await l2cap_channel.disconnect()
+
+    sent_bytes = b''.join(messages)
+    received_bytes = b''.join(received)
+    assert sent_bytes == received_bytes
+
+
[email protected]
+async def test_transfer():
+    for max_credits in (1, 10, 100, 10000):
+        for mtu in (50, 255, 256, 1000):
+            for mps in (50, 255, 256, 1000):
+                # print(max_credits, mtu, mps)
+                await transfer_payload(max_credits, mtu, mps)
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_bidirectional_transfer():
+    devices = await setup_connection()
+
+    client_received = []
+    server_received = []
+    server_channel  = None
+
+    def on_server_coc(channel):
+        nonlocal server_channel
+        server_channel = channel
+
+        def on_server_data(data):
+            server_received.append(data)
+
+        channel.sink = on_server_data
+
+    def on_client_data(data):
+        client_received.append(data)
+
+    psm = devices.devices[1].register_l2cap_channel_server(psm=0, server=on_server_coc)
+    client_channel = await devices.connections[0].open_l2cap_channel(psm)
+    client_channel.sink = on_client_data
+
+    messages = [
+        bytes([1, 2, 3, 4, 5, 6, 7]) * x
+        for x in (3, 10, 100)
+    ]
+    for message in messages:
+        client_channel.write(message)
+        await client_channel.drain()
+        await asyncio.sleep(0)
+        server_channel.write(message)
+        await server_channel.drain()
+
+    await client_channel.disconnect()
+
+    message_bytes = b''.join(messages)
+    client_received_bytes = b''.join(client_received)
+    server_received_bytes = b''.join(server_received)
+    assert client_received_bytes == message_bytes
+    assert server_received_bytes == message_bytes
+
+
+# -----------------------------------------------------------------------------
+async def run():
+    test_helpers()
+    await test_basic_connection()
+    await test_transfer()
+    await test_bidirectional_transfer()
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run())
diff --git a/web/scanner.py b/web/scanner.py
index 9ab9f47..e734dbf 100644
--- a/web/scanner.py
+++ b/web/scanner.py
@@ -21,9 +21,9 @@
 
 # -----------------------------------------------------------------------------
 class ScannerListener(Device.Listener):
-    def on_advertisement(self, address, ad_data, rssi, connectable):
-        address_type_string = ('P', 'R', 'PI', 'RI')[address.address_type]
-        print(f'>>> {address} [{address_type_string}]: RSSI={rssi}, {ad_data}')
+    def on_advertisement(self, advertisement):
+        address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type]
+        print(f'>>> {advertisement.address} [{address_type_string}]: RSSI={advertisement.rssi}, {advertisement.ad_data}')
 
 
 class HciSource: