Snap for 10927977 from 62618d11a2e7875c438c5131abb6847da26aa6f4 to mainline-odp-release

Change-Id: Ie4fba125fb044f85f5069a5af0fc910a4210d2f8
diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml
index b6cf8fd..021b1e4 100644
--- a/.github/workflows/code-check.yml
+++ b/.github/workflows/code-check.yml
@@ -14,6 +14,10 @@
   check:
     name: Check Code
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
+      fail-fast: false
 
     steps:
     - name: Check out from Git
diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml
index 72c7b43..4cc3e73 100644
--- a/.github/workflows/python-build-test.yml
+++ b/.github/workflows/python-build-test.yml
@@ -12,11 +12,11 @@
 
 jobs:
   build:
-
-    runs-on: ubuntu-latest
+    runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        python-version: ["3.8", "3.9", "3.10"]
+        os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
       fail-fast: false
 
     steps:
@@ -41,3 +41,40 @@
       run: |
         inv build
         inv build.mkdocs
+
+  build-rust:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [ "3.8", "3.9", "3.10", "3.11" ]
+        rust-version: [ "1.70.0", "stable" ]
+      fail-fast: false
+    steps:
+      - name: Check out from Git
+        uses: actions/checkout@v3
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install ".[build,test,development,documentation]"
+      - name: Install Rust toolchain
+        uses: actions-rust-lang/setup-rust-toolchain@v1
+        with:
+          components: clippy,rustfmt
+          toolchain: ${{ matrix.rust-version }}
+      - name: Check License Headers
+        run: cd rust && cargo run --features dev-tools --bin file-header check-all
+      - name: Rust Build
+        run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
+      # Lints after build so what clippy needs is already built
+      - name: Rust Lints
+        run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
+      - name: Rust Tests
+        run: cd rust && cargo test
+      # At some point, hook up publishing the binary. For now, just make sure it builds.
+      # Once we're ready to publish binaries, this should be built with `--release`.
+      - name: Build Bumble CLI
+        run: cd rust && cargo build --features bumble-tools --bin bumble
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 43b6410..97dc64d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@
 # generated by setuptools_scm
 bumble/_version.py
 .vscode/launch.json
+/.idea
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 864fe69..57e682a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -39,10 +39,12 @@
         "libusb",
         "MITM",
         "NDIS",
+        "netsim",
         "NONBLOCK",
         "NONCONN",
         "OXIMETER",
         "popleft",
+        "protobuf",
         "psms",
         "pyee",
         "pyusb",
diff --git a/Android.bp b/Android.bp
index 640bded..9cb3d08 100644
--- a/Android.bp
+++ b/Android.bp
@@ -19,6 +19,7 @@
     name: "bumble",
     srcs: [
         "bumble/*.py",
+        "bumble/drivers/*.py",
         "bumble/profiles/*.py",
         "bumble/transport/*.py",
     ],
diff --git a/METADATA b/METADATA
index 30444f1..48086d7 100644
--- a/METADATA
+++ b/METADATA
@@ -11,7 +11,7 @@
     type: GIT
     value: "https://github.com/google/bumble"
   }
-  version: "c66b357de6908cf3680d83a73c6744451e2d0fa0"
-  last_upgrade_date { year: 2022 month: 7 day: 25 }
+  version: "783b2d70a517a4c5fd828a0f6b8b2a46fe8750c5"
+  last_upgrade_date { year: 2023 month: 9 day: 12 }
   license_type: NOTICE
 }
diff --git a/apps/console.py b/apps/console.py
index 0ea9e5b..9a529dd 100644
--- a/apps/console.py
+++ b/apps/console.py
@@ -1172,7 +1172,7 @@
             name = ''
 
         # Remove any '/P' qualifier suffix from the address string
-        address_str = str(self.address).replace('/P', '')
+        address_str = self.address.to_string(with_type_qualifier=False)
 
         # RSSI bar
         bar_string = rssi_bar(self.rssi)
diff --git a/apps/controller_info.py b/apps/controller_info.py
index 4707983..5be4f3d 100644
--- a/apps/controller_info.py
+++ b/apps/controller_info.py
@@ -63,7 +63,8 @@
         if command_succeeded(response):
             print()
             print(
-                color('Classic Address:', 'yellow'), response.return_parameters.bd_addr
+                color('Classic Address:', 'yellow'),
+                response.return_parameters.bd_addr.to_string(False),
             )
 
     if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
diff --git a/apps/l2cap_bridge.py b/apps/l2cap_bridge.py
index 17623e4..83379a0 100644
--- a/apps/l2cap_bridge.py
+++ b/apps/l2cap_bridge.py
@@ -105,7 +105,7 @@
                             asyncio.create_task(self.pipe.l2cap_channel.disconnect())
 
                     def data_received(self, data):
-                        print(f'<<< Received on TCP: {len(data)}')
+                        print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
                         self.pipe.l2cap_channel.write(data)
 
                 try:
@@ -123,6 +123,7 @@
                     await self.l2cap_channel.disconnect()
 
             def on_l2cap_close(self):
+                print(color('*** L2CAP channel closed', 'red'))
                 self.l2cap_channel = None
                 if self.tcp_transport is not None:
                     self.tcp_transport.close()
diff --git a/apps/pair.py b/apps/pair.py
index a7844fe..2cc8188 100644
--- a/apps/pair.py
+++ b/apps/pair.py
@@ -157,6 +157,26 @@
         self.print(f'### PIN: {number:0{digits}}')
         self.print('###-----------------------------------')
 
+    async def get_string(self, max_length: int):
+        await self.update_peer_name()
+
+        # Prompt a PIN (for legacy pairing in classic)
+        self.print('###-----------------------------------')
+        self.print(f'### Pairing with {self.peer_name}')
+        self.print('###-----------------------------------')
+        count = 0
+        while True:
+            response = await self.prompt('>>> Enter PIN (1-6 chars):')
+            if len(response) == 0:
+                count += 1
+                if count > 3:
+                    self.print('too many tries, stopping the pairing')
+                    return None
+
+                self.print('no PIN was entered, try again')
+                continue
+            return response
+
 
 # -----------------------------------------------------------------------------
 async def get_peer_name(peer, mode):
@@ -207,7 +227,7 @@
 
     # Listen for pairing events
     connection.on('pairing_start', on_pairing_start)
-    connection.on('pairing', on_pairing)
+    connection.on('pairing', lambda keys: on_pairing(connection.peer_address, keys))
     connection.on('pairing_failure', on_pairing_failure)
 
     # Listen for encryption changes
@@ -242,9 +262,9 @@
 
 
 # -----------------------------------------------------------------------------
-def on_pairing(keys):
+def on_pairing(address, keys):
     print(color('***-----------------------------------', 'cyan'))
-    print(color('*** Paired!', 'cyan'))
+    print(color(f'*** Paired! (peer identity={address})', 'cyan'))
     keys.print(prefix=color('*** ', 'cyan'))
     print(color('***-----------------------------------', 'cyan'))
     Waiter.instance.terminate()
@@ -283,17 +303,6 @@
         # Create a device to manage the host
         device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
 
-        # Set a custom keystore if specified on the command line
-        if keystore_file:
-            device.keystore = JsonKeyStore(namespace=None, filename=keystore_file)
-
-        # Print the existing keys before pairing
-        if print_keys and device.keystore:
-            print(color('@@@-----------------------------------', 'blue'))
-            print(color('@@@ Pairing Keys:', 'blue'))
-            await device.keystore.print(prefix=color('@@@ ', 'blue'))
-            print(color('@@@-----------------------------------', 'blue'))
-
         # Expose a GATT characteristic that can be used to trigger pairing by
         # responding with an authentication error when read
         if mode == 'le':
@@ -323,6 +332,17 @@
         # Get things going
         await device.power_on()
 
+        # Set a custom keystore if specified on the command line
+        if keystore_file:
+            device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
+
+        # Print the existing keys before pairing
+        if print_keys and device.keystore:
+            print(color('@@@-----------------------------------', 'blue'))
+            print(color('@@@ Pairing Keys:', 'blue'))
+            await device.keystore.print(prefix=color('@@@ ', 'blue'))
+            print(color('@@@-----------------------------------', 'blue'))
+
         # Set up a pairing config factory
         device.pairing_config_factory = lambda connection: PairingConfig(
             sc, mitm, bond, Delegate(mode, connection, io, prompt)
diff --git a/apps/pandora_server.py b/apps/pandora_server.py
index 5f92309..16bc211 100644
--- a/apps/pandora_server.py
+++ b/apps/pandora_server.py
@@ -1,8 +1,10 @@
 import asyncio
 import click
 import logging
+import json
 
-from bumble.pandora import PandoraDevice, serve
+from bumble.pandora import PandoraDevice, Config, serve
+from typing import Dict, Any
 
 BUMBLE_SERVER_GRPC_PORT = 7999
 ROOTCANAL_PORT_CUTTLEFISH = 7300
@@ -18,12 +20,31 @@
     help='HCI transport',
     default=f'tcp-client:127.0.0.1:<rootcanal-port>',
 )
-def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
+@click.option(
+    '--config',
+    help='Bumble json configuration file',
+)
+def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> None:
     if '<rootcanal-port>' in transport:
         transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
-    device = PandoraDevice({'transport': transport})
+
+    bumble_config = retrieve_config(config)
+    bumble_config.setdefault('transport', transport)
+    device = PandoraDevice(bumble_config)
+
+    server_config = Config()
+    server_config.load_from_dict(bumble_config.get('server', {}))
+
     logging.basicConfig(level=logging.DEBUG)
-    asyncio.run(serve(device, port=grpc_port))
+    asyncio.run(serve(device, config=server_config, port=grpc_port))
+
+
+def retrieve_config(config: str) -> Dict[str, Any]:
+    if not config:
+        return {}
+
+    with open(config, 'r') as f:
+        return json.load(f)
 
 
 if __name__ == '__main__':
diff --git a/apps/scan.py b/apps/scan.py
index dac7a2c..268912f 100644
--- a/apps/scan.py
+++ b/apps/scan.py
@@ -133,15 +133,16 @@
                 'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
             )
 
+        await device.power_on()
+
         if keystore_file:
-            keystore = JsonKeyStore(namespace=None, filename=keystore_file)
-            device.keystore = keystore
-        else:
-            resolver = None
+            device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
 
         if device.keystore:
             resolving_keys = await device.keystore.get_resolving_keys()
             resolver = AddressResolver(resolving_keys)
+        else:
+            resolver = None
 
         printer = AdvertisementPrinter(min_rssi, resolver)
         if raw:
@@ -149,8 +150,6 @@
         else:
             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:
diff --git a/apps/show.py b/apps/show.py
index bf01ead..f849e3a 100644
--- a/apps/show.py
+++ b/apps/show.py
@@ -102,9 +102,21 @@
     default='h4',
     help='Format of the input file',
 )
+@click.option(
+    '--vendors',
+    type=click.Choice(['android', 'zephyr']),
+    multiple=True,
+    help='Support vendor-specific commands (list one or more)',
+)
 @click.argument('filename')
 # pylint: disable=redefined-builtin
-def main(format, filename):
+def main(format, vendors, filename):
+    for vendor in vendors:
+        if vendor == 'android':
+            import bumble.vendor.android.hci
+        elif vendor == 'zephyr':
+            import bumble.vendor.zephyr.hci
+
     input = open(filename, 'rb')
     if format == 'h4':
         packet_reader = PacketReader(input)
@@ -124,7 +136,6 @@
             if packet is None:
                 break
             tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
-
         except Exception as error:
             print(color(f'!!! {error}', 'red'))
 
diff --git a/apps/speaker/__init__.py b/apps/speaker/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/apps/speaker/__init__.py
diff --git a/apps/speaker/logo.svg b/apps/speaker/logo.svg
new file mode 100644
index 0000000..70ef7a9
--- /dev/null
+++ b/apps/speaker/logo.svg
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
+<metadata>
+<vectornator:setting key="DimensionsVisible" value="1"/>
+<vectornator:setting key="PencilOnly" value="0"/>
+<vectornator:setting key="SnapToPoints" value="0"/>
+<vectornator:setting key="OutlineMode" value="0"/>
+<vectornator:setting key="CMYKEnabledKey" value="0"/>
+<vectornator:setting key="RulersVisible" value="1"/>
+<vectornator:setting key="SnapToEdges" value="0"/>
+<vectornator:setting key="GuidesVisible" value="1"/>
+<vectornator:setting key="DisplayWhiteBackground" value="0"/>
+<vectornator:setting key="doHistoryDisabled" value="0"/>
+<vectornator:setting key="SnapToGuides" value="1"/>
+<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
+<vectornator:setting key="Units" value="Pixels"/>
+<vectornator:setting key="DynamicGuides" value="0"/>
+<vectornator:setting key="IsolateActiveLayer" value="0"/>
+<vectornator:setting key="SnapToGrid" value="0"/>
+</metadata>
+<defs/>
+<g id="Layer 1" vectornator:layerName="Layer 1">
+<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
+<g opacity="1">
+<g opacity="1">
+<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+</g>
+<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<g opacity="1">
+<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+</g>
+</g>
+</g>
+</svg>
diff --git a/apps/speaker/speaker.css b/apps/speaker/speaker.css
new file mode 100644
index 0000000..dd4c799
--- /dev/null
+++ b/apps/speaker/speaker.css
@@ -0,0 +1,76 @@
+body, h1, h2, h3, h4, h5, h6 {
+    font-family: sans-serif;
+}
+
+#controlsDiv {
+    margin: 6px;
+}
+
+#connectionText {
+    background-color: rgb(239, 89, 75);
+    border: none;
+    border-radius: 4px;
+    padding: 8px;
+    display: inline-block;
+    margin: 4px;
+}
+
+#startButton {
+    padding: 4px;
+    margin: 6px;
+}
+
+#fftCanvas {
+    border-radius: 16px;
+    margin: 6px;
+}
+
+#bandwidthCanvas {
+    border: grey;
+    border-style: solid;
+    border-radius: 8px;
+    margin: 6px;
+}
+
+#streamStateText {
+    background-color: rgb(93, 165, 93);
+    border: none;
+    border-radius: 8px;
+    padding: 10px 20px;
+    display: inline-block;
+    margin: 6px;
+}
+
+#connectionStateText {
+    background-color: rgb(112, 146, 206);
+    border: none;
+    border-radius: 8px;
+    padding: 10px 20px;
+    display: inline-block;
+    margin: 6px;
+}
+
+#propertiesTable {
+    border: grey;
+    border-style: solid;
+    border-radius: 4px;
+    padding: 4px;
+    margin: 6px;
+    margin-left: 0;
+}
+
+th, td {
+    padding-left: 6px;
+    padding-right: 6px;
+}
+
+.properties td:nth-child(even) {
+    background-color: #d6eeee;
+    font-family: monospace;
+}
+
+.properties td:nth-child(odd) {
+    font-weight: bold;
+}
+
+.properties tr td:nth-child(2) { width: 150px; }
\ No newline at end of file
diff --git a/apps/speaker/speaker.html b/apps/speaker/speaker.html
new file mode 100644
index 0000000..550049b
--- /dev/null
+++ b/apps/speaker/speaker.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Bumble Speaker</title>
+  <script src="speaker.js"></script>
+  <link rel="stylesheet" href="speaker.css">
+</head>
+<body>
+  <h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
+  <div id="connectionText"></div>
+  <div id="speaker">
+    <table><tr>
+      <td>
+        <table id="propertiesTable" class="properties">
+          <tr><td>Codec</td><td><span id="codecText"></span></td></tr>
+          <tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
+          <tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
+        </table>
+      </td>
+      <td>
+        <canvas id="bandwidthCanvas" width="500", height="100">Bandwidth Graph</canvas>
+      </td>
+    </tr></table>
+    <span id="streamStateText">IDLE</span>
+    <span id="connectionStateText">NOT CONNECTED</span>
+    <div id="controlsDiv">
+        <button id="audioOnButton">Audio On</button>
+        <span id="audioSupportMessageText"></span>
+    </div>
+    <canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
+    <audio id="audio"></audio>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/apps/speaker/speaker.js b/apps/speaker/speaker.js
new file mode 100644
index 0000000..77cb1ff
--- /dev/null
+++ b/apps/speaker/speaker.js
@@ -0,0 +1,315 @@
+(function () {
+    'use strict';
+
+const channelUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/channel";
+let channelSocket;
+let connectionText;
+let codecText;
+let packetsReceivedText;
+let bytesReceivedText;
+let streamStateText;
+let connectionStateText;
+let controlsDiv;
+let audioOnButton;
+let mediaSource;
+let sourceBuffer;
+let audioElement;
+let audioContext;
+let audioAnalyzer;
+let audioFrequencyBinCount;
+let audioFrequencyData;
+let packetsReceived = 0;
+let bytesReceived = 0;
+let audioState = "stopped";
+let streamState = "IDLE";
+let audioSupportMessageText;
+let fftCanvas;
+let fftCanvasContext;
+let bandwidthCanvas;
+let bandwidthCanvasContext;
+let bandwidthBinCount;
+let bandwidthBins = [];
+
+const FFT_WIDTH = 800;
+const FFT_HEIGHT = 256;
+const BANDWIDTH_WIDTH = 500;
+const BANDWIDTH_HEIGHT = 100;
+
+function hexToBytes(hex) {
+    return Uint8Array.from(hex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
+}
+
+function init() {
+    initUI();
+    initMediaSource();
+    initAudioElement();
+    initAnalyzer();
+
+    connect();
+}
+
+function initUI() {
+    controlsDiv = document.getElementById("controlsDiv");
+    controlsDiv.style.visibility = "hidden";
+    connectionText = document.getElementById("connectionText");
+    audioOnButton = document.getElementById("audioOnButton");
+    codecText = document.getElementById("codecText");
+    packetsReceivedText = document.getElementById("packetsReceivedText");
+    bytesReceivedText = document.getElementById("bytesReceivedText");
+    streamStateText = document.getElementById("streamStateText");
+    connectionStateText = document.getElementById("connectionStateText");
+    audioSupportMessageText = document.getElementById("audioSupportMessageText");
+
+    audioOnButton.onclick = () => startAudio();
+
+    setConnectionText("");
+
+    requestAnimationFrame(onAnimationFrame);
+}
+
+function initMediaSource() {
+    mediaSource = new MediaSource();
+    mediaSource.onsourceopen = onMediaSourceOpen;
+    mediaSource.onsourceclose = onMediaSourceClose;
+    mediaSource.onsourceended = onMediaSourceEnd;
+}
+
+function initAudioElement() {
+    audioElement = document.getElementById("audio");
+    audioElement.src = URL.createObjectURL(mediaSource);
+    // audioElement.controls = true;
+}
+
+function initAnalyzer() {
+    fftCanvas = document.getElementById("fftCanvas");
+    fftCanvas.width = FFT_WIDTH
+    fftCanvas.height = FFT_HEIGHT
+    fftCanvasContext = fftCanvas.getContext('2d');
+    fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
+    fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
+
+    bandwidthCanvas = document.getElementById("bandwidthCanvas");
+    bandwidthCanvas.width = BANDWIDTH_WIDTH
+    bandwidthCanvas.height = BANDWIDTH_HEIGHT
+    bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
+    bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
+    bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
+}
+
+function startAnalyzer() {
+    // FFT
+    if (audioElement.captureStream !== undefined) {
+        audioContext = new AudioContext();
+        audioAnalyzer = audioContext.createAnalyser();
+        audioAnalyzer.fftSize = 128;
+        audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
+        audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
+        const stream = audioElement.captureStream();
+        const source = audioContext.createMediaStreamSource(stream);
+        source.connect(audioAnalyzer);
+    }
+
+    // Bandwidth
+    bandwidthBinCount = BANDWIDTH_WIDTH / 2;
+    bandwidthBins = [];
+}
+
+function setConnectionText(message) {
+    connectionText.innerText = message;
+    if (message.length == 0) {
+        connectionText.style.display = "none";
+    } else {
+        connectionText.style.display = "inline-block";
+    }
+}
+
+function setStreamState(state) {
+    streamState = state;
+    streamStateText.innerText = streamState;
+}
+
+function onAnimationFrame() {
+    // FFT
+    if (audioAnalyzer !== undefined) {
+        audioAnalyzer.getByteFrequencyData(audioFrequencyData);
+        fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
+        fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
+        const barCount = audioFrequencyBinCount;
+        const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
+        for (let bar = 0; bar < barCount; bar++) {
+            const barHeight = audioFrequencyData[bar];
+            fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
+            fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
+        }
+    }
+
+    // Bandwidth
+    bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
+    bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
+    bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
+    for (let t = 0; t < bandwidthBins.length; t++) {
+        const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
+        bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
+    }
+
+    // Display again at the next frame
+    requestAnimationFrame(onAnimationFrame);
+}
+
+function onMediaSourceOpen() {
+    console.log(this.readyState);
+    sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
+}
+
+function onMediaSourceClose() {
+    console.log(this.readyState);
+}
+
+function onMediaSourceEnd() {
+    console.log(this.readyState);
+}
+
+async function startAudio() {
+    try {
+        console.log("starting audio...");
+        audioOnButton.disabled = true;
+        audioState = "starting";
+        await audioElement.play();
+        console.log("audio started");
+        audioState = "playing";
+        startAnalyzer();
+    } catch(error) {
+        console.error(`play failed: ${error}`);
+        audioState = "stopped";
+        audioOnButton.disabled = false;
+    }
+}
+
+function onAudioPacket(packet) {
+    if (audioState != "stopped") {
+        // Queue the audio packet.
+        sourceBuffer.appendBuffer(packet);
+    }
+
+    packetsReceived += 1;
+    packetsReceivedText.innerText = packetsReceived;
+    bytesReceived += packet.byteLength;
+    bytesReceivedText.innerText = bytesReceived;
+
+    bandwidthBins[bandwidthBins.length] = packet.byteLength;
+    if (bandwidthBins.length > bandwidthBinCount) {
+        bandwidthBins.shift();
+    }
+}
+
+function onChannelOpen() {
+    console.log('channel OPEN');
+    setConnectionText("");
+    controlsDiv.style.visibility = "visible";
+
+    // Handshake with the backend.
+    sendMessage({
+        type: "hello"
+    });
+}
+
+function onChannelClose() {
+    console.log('channel CLOSED');
+    setConnectionText("Connection to CLI app closed, restart it and reload this page.");
+    controlsDiv.style.visibility = "hidden";
+}
+
+function onChannelError(error) {
+    console.log(`channel ERROR: ${error}`);
+    setConnectionText(`Connection to CLI app error ({${error}}), restart it and reload this page.`);
+    controlsDiv.style.visibility = "hidden";
+}
+
+function onChannelMessage(message) {
+    if (typeof message.data === 'string' || message.data instanceof String) {
+        // JSON message.
+        const jsonMessage = JSON.parse(message.data);
+        console.log(`channel MESSAGE: ${message.data}`);
+
+        // Dispatch the message.
+        const handlerName = `on${jsonMessage.type.charAt(0).toUpperCase()}${jsonMessage.type.slice(1)}Message`
+        const handler = messageHandlers[handlerName];
+        if (handler !== undefined) {
+            const params = jsonMessage.params;
+            if (params === undefined) {
+                params = {};
+            }
+            handler(params);
+        } else {
+            console.warn(`unhandled message: ${jsonMessage.type}`)
+        }
+    } else {
+        // BINARY audio data.
+        onAudioPacket(message.data);
+    }
+}
+
+function onHelloMessage(params) {
+    codecText.innerText = params.codec;
+    if (params.codec != "aac") {
+        audioOnButton.disabled = true;
+        audioSupportMessageText.innerText = "Only AAC can be played, audio will be disabled";
+        audioSupportMessageText.style.display = "inline-block";
+    } else {
+        audioSupportMessageText.innerText = "";
+        audioSupportMessageText.style.display = "none";
+    }
+    if (params.streamState) {
+        setStreamState(params.streamState);
+    }
+}
+
+function onStartMessage(params) {
+    setStreamState("STARTED");
+}
+
+function onStopMessage(params) {
+    setStreamState("STOPPED");
+}
+
+function onSuspendMessage(params) {
+    setStreamState("SUSPENDED");
+}
+
+function onConnectionMessage(params) {
+    connectionStateText.innerText = `CONNECTED: ${params.peer_name} (${params.peer_address})`;
+}
+
+function onDisconnectionMessage(params) {
+    connectionStateText.innerText = "DISCONNECTED";
+}
+
+function sendMessage(message) {
+    channelSocket.send(JSON.stringify(message));
+}
+
+function connect() {
+    console.log("connecting to CLI app");
+
+    channelSocket = new WebSocket(channelUrl);
+    channelSocket.binaryType = "arraybuffer";
+    channelSocket.onopen = onChannelOpen;
+    channelSocket.onclose = onChannelClose;
+    channelSocket.onerror = onChannelError;
+    channelSocket.onmessage = onChannelMessage;
+}
+
+const messageHandlers = {
+    onHelloMessage,
+    onStartMessage,
+    onStopMessage,
+    onSuspendMessage,
+    onConnectionMessage,
+    onDisconnectionMessage
+}
+
+window.onload = (event) => {
+    init();
+}
+
+}());
\ No newline at end of file
diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py
new file mode 100644
index 0000000..e451c04
--- /dev/null
+++ b/apps/speaker/speaker.py
@@ -0,0 +1,737 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import asyncio
+import asyncio.subprocess
+from importlib import resources
+import enum
+import json
+import os
+import logging
+import pathlib
+import subprocess
+from typing import Dict, List, Optional
+import weakref
+
+import click
+import aiohttp
+from aiohttp import web
+
+import bumble
+from bumble.colors import color
+from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
+from bumble.device import Connection, Device, DeviceConfiguration
+from bumble.hci import HCI_StatusError
+from bumble.pairing import PairingConfig
+from bumble.sdp import ServiceAttribute
+from bumble.transport import open_transport
+from bumble.avdtp import (
+    AVDTP_AUDIO_MEDIA_TYPE,
+    Listener,
+    MediaCodecCapabilities,
+    MediaPacket,
+    Protocol,
+)
+from bumble.a2dp import (
+    MPEG_2_AAC_LC_OBJECT_TYPE,
+    make_audio_sink_service_sdp_records,
+    A2DP_SBC_CODEC_TYPE,
+    A2DP_MPEG_2_4_AAC_CODEC_TYPE,
+    SBC_MONO_CHANNEL_MODE,
+    SBC_DUAL_CHANNEL_MODE,
+    SBC_SNR_ALLOCATION_METHOD,
+    SBC_LOUDNESS_ALLOCATION_METHOD,
+    SBC_STEREO_CHANNEL_MODE,
+    SBC_JOINT_STEREO_CHANNEL_MODE,
+    SbcMediaCodecInformation,
+    AacMediaCodecInformation,
+)
+from bumble.utils import AsyncRunner
+from bumble.codecs import AacAudioRtpPacket
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+DEFAULT_UI_PORT = 7654
+
+# -----------------------------------------------------------------------------
+class AudioExtractor:
+    @staticmethod
+    def create(codec: str):
+        if codec == 'aac':
+            return AacAudioExtractor()
+        if codec == 'sbc':
+            return SbcAudioExtractor()
+
+    def extract_audio(self, packet: MediaPacket) -> bytes:
+        raise NotImplementedError()
+
+
+# -----------------------------------------------------------------------------
+class AacAudioExtractor:
+    def extract_audio(self, packet: MediaPacket) -> bytes:
+        return AacAudioRtpPacket(packet.payload).to_adts()
+
+
+# -----------------------------------------------------------------------------
+class SbcAudioExtractor:
+    def extract_audio(self, packet: MediaPacket) -> bytes:
+        # header = packet.payload[0]
+        # fragmented = header >> 7
+        # start = (header >> 6) & 0x01
+        # last = (header >> 5) & 0x01
+        # number_of_frames = header & 0x0F
+
+        # TODO: support fragmented payloads
+        return packet.payload[1:]
+
+
+# -----------------------------------------------------------------------------
+class Output:
+    async def start(self) -> None:
+        pass
+
+    async def stop(self) -> None:
+        pass
+
+    async def suspend(self) -> None:
+        pass
+
+    async def on_connection(self, connection: Connection) -> None:
+        pass
+
+    async def on_disconnection(self, reason: int) -> None:
+        pass
+
+    def on_rtp_packet(self, packet: MediaPacket) -> None:
+        pass
+
+
+# -----------------------------------------------------------------------------
+class FileOutput(Output):
+    filename: str
+    codec: str
+    extractor: AudioExtractor
+
+    def __init__(self, filename, codec):
+        self.filename = filename
+        self.codec = codec
+        self.file = open(filename, 'wb')
+        self.extractor = AudioExtractor.create(codec)
+
+    def on_rtp_packet(self, packet: MediaPacket) -> None:
+        self.file.write(self.extractor.extract_audio(packet))
+
+
+# -----------------------------------------------------------------------------
+class QueuedOutput(Output):
+    MAX_QUEUE_SIZE = 32768
+
+    packets: asyncio.Queue
+    extractor: AudioExtractor
+    packet_pump_task: Optional[asyncio.Task]
+    started: bool
+
+    def __init__(self, extractor):
+        self.extractor = extractor
+        self.packets = asyncio.Queue()
+        self.packet_pump_task = None
+        self.started = False
+
+    async def start(self):
+        if self.started:
+            return
+
+        self.packet_pump_task = asyncio.create_task(self.pump_packets())
+
+    async def pump_packets(self):
+        while True:
+            packet = await self.packets.get()
+            await self.on_audio_packet(packet)
+
+    async def on_audio_packet(self, packet: bytes) -> None:
+        pass
+
+    def on_rtp_packet(self, packet: MediaPacket) -> None:
+        if self.packets.qsize() > self.MAX_QUEUE_SIZE:
+            logger.debug("queue full, dropping")
+            return
+
+        self.packets.put_nowait(self.extractor.extract_audio(packet))
+
+
+# -----------------------------------------------------------------------------
+class WebSocketOutput(QueuedOutput):
+    def __init__(self, codec, send_audio, send_message):
+        super().__init__(AudioExtractor.create(codec))
+        self.send_audio = send_audio
+        self.send_message = send_message
+
+    async def on_connection(self, connection: Connection) -> None:
+        try:
+            await connection.request_remote_name()
+        except HCI_StatusError:
+            pass
+        peer_name = '' if connection.peer_name is None else connection.peer_name
+        peer_address = connection.peer_address.to_string(False)
+        await self.send_message(
+            'connection',
+            peer_address=peer_address,
+            peer_name=peer_name,
+        )
+
+    async def on_disconnection(self, reason) -> None:
+        await self.send_message('disconnection')
+
+    async def on_audio_packet(self, packet: bytes) -> None:
+        await self.send_audio(packet)
+
+    async def start(self):
+        await super().start()
+        await self.send_message('start')
+
+    async def stop(self):
+        await super().stop()
+        await self.send_message('stop')
+
+    async def suspend(self):
+        await super().suspend()
+        await self.send_message('suspend')
+
+
+# -----------------------------------------------------------------------------
+class FfplayOutput(QueuedOutput):
+    MAX_QUEUE_SIZE = 32768
+
+    subprocess: Optional[asyncio.subprocess.Process]
+    ffplay_task: Optional[asyncio.Task]
+
+    def __init__(self, codec: str) -> None:
+        super().__init__(AudioExtractor.create(codec))
+        self.subprocess = None
+        self.ffplay_task = None
+        self.codec = codec
+
+    async def start(self):
+        if self.started:
+            return
+
+        await super().start()
+
+        self.subprocess = await asyncio.create_subprocess_shell(
+            f'ffplay -f {self.codec} pipe:0',
+            stdin=asyncio.subprocess.PIPE,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        self.ffplay_task = asyncio.create_task(self.monitor_ffplay())
+
+    async def stop(self):
+        # TODO
+        pass
+
+    async def suspend(self):
+        # TODO
+        pass
+
+    async def monitor_ffplay(self):
+        async def read_stream(name, stream):
+            while True:
+                data = await stream.read()
+                logger.debug(f'{name}:', data)
+
+        await asyncio.wait(
+            [
+                asyncio.create_task(
+                    read_stream('[ffplay stdout]', self.subprocess.stdout)
+                ),
+                asyncio.create_task(
+                    read_stream('[ffplay stderr]', self.subprocess.stderr)
+                ),
+                asyncio.create_task(self.subprocess.wait()),
+            ]
+        )
+        logger.debug("FFPLAY done")
+
+    async def on_audio_packet(self, packet):
+        try:
+            self.subprocess.stdin.write(packet)
+        except Exception:
+            logger.warning('!!!! exception while sending audio to ffplay pipe')
+
+
+# -----------------------------------------------------------------------------
+class UiServer:
+    speaker: weakref.ReferenceType[Speaker]
+    port: int
+
+    def __init__(self, speaker: Speaker, port: int) -> None:
+        self.speaker = weakref.ref(speaker)
+        self.port = port
+        self.channel_socket = None
+
+    async def start_http(self) -> None:
+        """Start the UI HTTP server."""
+
+        app = web.Application()
+        app.add_routes(
+            [
+                web.get('/', self.get_static),
+                web.get('/speaker.html', self.get_static),
+                web.get('/speaker.js', self.get_static),
+                web.get('/speaker.css', self.get_static),
+                web.get('/logo.svg', self.get_static),
+                web.get('/channel', self.get_channel),
+            ]
+        )
+
+        runner = web.AppRunner(app)
+        await runner.setup()
+        site = web.TCPSite(runner, 'localhost', self.port)
+        print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green'))
+        await site.start()
+
+    async def get_static(self, request):
+        path = request.path
+        if path == '/':
+            path = '/speaker.html'
+        if path.endswith('.html'):
+            content_type = 'text/html'
+        elif path.endswith('.js'):
+            content_type = 'text/javascript'
+        elif path.endswith('.css'):
+            content_type = 'text/css'
+        elif path.endswith('.svg'):
+            content_type = 'image/svg+xml'
+        else:
+            content_type = 'text/plain'
+        text = (
+            resources.files("bumble.apps.speaker")
+            .joinpath(pathlib.Path(path).relative_to('/'))
+            .read_text(encoding="utf-8")
+        )
+        return aiohttp.web.Response(text=text, content_type=content_type)
+
+    async def get_channel(self, request):
+        ws = web.WebSocketResponse()
+        await ws.prepare(request)
+
+        # Process messages until the socket is closed.
+        self.channel_socket = ws
+        async for message in ws:
+            if message.type == aiohttp.WSMsgType.TEXT:
+                logger.debug(f'<<< received message: {message.data}')
+                await self.on_message(message.data)
+            elif message.type == aiohttp.WSMsgType.ERROR:
+                logger.debug(
+                    f'channel connection closed with exception {ws.exception()}'
+                )
+
+        self.channel_socket = None
+        logger.debug('--- channel connection closed')
+
+        return ws
+
+    async def on_message(self, message_str: str):
+        # Parse the message as JSON
+        message = json.loads(message_str)
+
+        # Dispatch the message
+        message_type = message['type']
+        message_params = message.get('params', {})
+        handler = getattr(self, f'on_{message_type}_message')
+        if handler:
+            await handler(**message_params)
+
+    async def on_hello_message(self):
+        await self.send_message(
+            'hello',
+            bumble_version=bumble.__version__,
+            codec=self.speaker().codec,
+            streamState=self.speaker().stream_state.name,
+        )
+        if connection := self.speaker().connection:
+            await self.send_message(
+                'connection',
+                peer_address=connection.peer_address.to_string(False),
+                peer_name=connection.peer_name,
+            )
+
+    async def send_message(self, message_type: str, **kwargs) -> None:
+        if self.channel_socket is None:
+            return
+
+        message = {'type': message_type, 'params': kwargs}
+        await self.channel_socket.send_json(message)
+
+    async def send_audio(self, data: bytes) -> None:
+        if self.channel_socket is None:
+            return
+
+        try:
+            await self.channel_socket.send_bytes(data)
+        except Exception as error:
+            logger.warning(f'exception while sending audio packet: {error}')
+
+
+# -----------------------------------------------------------------------------
+class Speaker:
+    class StreamState(enum.Enum):
+        IDLE = 0
+        STOPPED = 1
+        STARTED = 2
+        SUSPENDED = 3
+
+    def __init__(self, device_config, transport, codec, discover, outputs, ui_port):
+        self.device_config = device_config
+        self.transport = transport
+        self.codec = codec
+        self.discover = discover
+        self.ui_port = ui_port
+        self.device = None
+        self.connection = None
+        self.listener = None
+        self.packets_received = 0
+        self.bytes_received = 0
+        self.stream_state = Speaker.StreamState.IDLE
+        self.outputs = []
+        for output in outputs:
+            if output == '@ffplay':
+                self.outputs.append(FfplayOutput(codec))
+                continue
+
+            # Default to FileOutput
+            self.outputs.append(FileOutput(output, codec))
+
+        # Create an HTTP server for the UI
+        self.ui_server = UiServer(speaker=self, port=ui_port)
+
+    def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
+        service_record_handle = 0x00010001
+        return {
+            service_record_handle: make_audio_sink_service_sdp_records(
+                service_record_handle
+            )
+        }
+
+    def codec_capabilities(self) -> MediaCodecCapabilities:
+        if self.codec == 'aac':
+            return self.aac_codec_capabilities()
+
+        if self.codec == 'sbc':
+            return self.sbc_codec_capabilities()
+
+        raise RuntimeError('unsupported codec')
+
+    def aac_codec_capabilities(self) -> MediaCodecCapabilities:
+        return MediaCodecCapabilities(
+            media_type=AVDTP_AUDIO_MEDIA_TYPE,
+            media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
+            media_codec_information=AacMediaCodecInformation.from_lists(
+                object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
+                sampling_frequencies=[48000, 44100],
+                channels=[1, 2],
+                vbr=1,
+                bitrate=256000,
+            ),
+        )
+
+    def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
+        return MediaCodecCapabilities(
+            media_type=AVDTP_AUDIO_MEDIA_TYPE,
+            media_codec_type=A2DP_SBC_CODEC_TYPE,
+            media_codec_information=SbcMediaCodecInformation.from_lists(
+                sampling_frequencies=[48000, 44100, 32000, 16000],
+                channel_modes=[
+                    SBC_MONO_CHANNEL_MODE,
+                    SBC_DUAL_CHANNEL_MODE,
+                    SBC_STEREO_CHANNEL_MODE,
+                    SBC_JOINT_STEREO_CHANNEL_MODE,
+                ],
+                block_lengths=[4, 8, 12, 16],
+                subbands=[4, 8],
+                allocation_methods=[
+                    SBC_LOUDNESS_ALLOCATION_METHOD,
+                    SBC_SNR_ALLOCATION_METHOD,
+                ],
+                minimum_bitpool_value=2,
+                maximum_bitpool_value=53,
+            ),
+        )
+
+    async def dispatch_to_outputs(self, function):
+        for output in self.outputs:
+            await function(output)
+
+    def on_bluetooth_connection(self, connection):
+        print(f'Connection: {connection}')
+        self.connection = connection
+        connection.on('disconnection', self.on_bluetooth_disconnection)
+        AsyncRunner.spawn(
+            self.dispatch_to_outputs(lambda output: output.on_connection(connection))
+        )
+
+    def on_bluetooth_disconnection(self, reason):
+        print(f'Disconnection ({reason})')
+        self.connection = None
+        AsyncRunner.spawn(self.advertise())
+        AsyncRunner.spawn(
+            self.dispatch_to_outputs(lambda output: output.on_disconnection(reason))
+        )
+
+    def on_avdtp_connection(self, protocol):
+        print('Audio Stream Open')
+
+        # Add a sink endpoint to the server
+        sink = protocol.add_sink(self.codec_capabilities())
+        sink.on('start', self.on_sink_start)
+        sink.on('stop', self.on_sink_stop)
+        sink.on('suspend', self.on_sink_suspend)
+        sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
+        sink.on('rtp_packet', self.on_rtp_packet)
+        sink.on('rtp_channel_open', self.on_rtp_channel_open)
+        sink.on('rtp_channel_close', self.on_rtp_channel_close)
+
+        # Listen for close events
+        protocol.on('close', self.on_avdtp_close)
+
+        # Discover all endpoints on the remote device is requested
+        if self.discover:
+            AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
+
+    def on_avdtp_close(self):
+        print("Audio Stream Closed")
+
+    def on_sink_start(self):
+        print("Sink Started\u001b[0K")
+        self.stream_state = self.StreamState.STARTED
+        AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.start()))
+
+    def on_sink_stop(self):
+        print("Sink Stopped\u001b[0K")
+        self.stream_state = self.StreamState.STOPPED
+        AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.stop()))
+
+    def on_sink_suspend(self):
+        print("Sink Suspended\u001b[0K")
+        self.stream_state = self.StreamState.SUSPENDED
+        AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.suspend()))
+
+    def on_sink_configuration(self, config):
+        print("Sink Configuration:")
+        print('\n'.join(["  " + str(capability) for capability in config]))
+
+    def on_rtp_channel_open(self):
+        print("RTP Channel Open")
+
+    def on_rtp_channel_close(self):
+        print("RTP Channel Closed")
+        self.stream_state = self.StreamState.IDLE
+
+    def on_rtp_packet(self, packet):
+        self.packets_received += 1
+        self.bytes_received += len(packet.payload)
+        print(
+            f'[{self.bytes_received} bytes in {self.packets_received} packets] {packet}',
+            end='\r',
+        )
+
+        for output in self.outputs:
+            output.on_rtp_packet(packet)
+
+    async def advertise(self):
+        await self.device.set_discoverable(True)
+        await self.device.set_connectable(True)
+
+    async def connect(self, address):
+        # Connect to the source
+        print(f'=== Connecting to {address}...')
+        connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
+        print(f'=== Connected to {connection.peer_address}')
+
+        # Request authentication
+        print('*** Authenticating...')
+        await connection.authenticate()
+        print('*** Authenticated')
+
+        # Enable encryption
+        print('*** Enabling encryption...')
+        await connection.encrypt()
+        print('*** Encryption on')
+
+        protocol = await Protocol.connect(connection)
+        self.listener.set_server(connection, protocol)
+        self.on_avdtp_connection(protocol)
+
+    async def discover_remote_endpoints(self, protocol):
+        endpoints = await protocol.discover_remote_endpoints()
+        print(f'@@@ Found {len(endpoints)} endpoints')
+        for endpoint in endpoints:
+            print('@@@', endpoint)
+
+    async def run(self, connect_address):
+        await self.ui_server.start_http()
+        self.outputs.append(
+            WebSocketOutput(
+                self.codec, self.ui_server.send_audio, self.ui_server.send_message
+            )
+        )
+
+        async with await open_transport(self.transport) as (hci_source, hci_sink):
+            # Create a device
+            device_config = DeviceConfiguration()
+            if self.device_config:
+                device_config.load_from_file(self.device_config)
+            else:
+                device_config.name = "Bumble Speaker"
+                device_config.class_of_device = 0x240414
+                device_config.keystore = "JsonKeyStore"
+
+            device_config.classic_enabled = True
+            device_config.le_enabled = False
+            self.device = Device.from_config_with_hci(
+                device_config, hci_source, hci_sink
+            )
+
+            # Setup the SDP to expose the sink service
+            self.device.sdp_service_records = self.sdp_records()
+
+            # Don't require MITM when pairing.
+            self.device.pairing_config_factory = lambda connection: PairingConfig(
+                mitm=False
+            )
+
+            # Start the controller
+            await self.device.power_on()
+
+            # Print some of the config/properties
+            print("Speaker Name:", color(device_config.name, 'yellow'))
+            print(
+                "Speaker Bluetooth Address:",
+                color(
+                    self.device.public_address.to_string(with_type_qualifier=False),
+                    'yellow',
+                ),
+            )
+
+            # Listen for Bluetooth connections
+            self.device.on('connection', self.on_bluetooth_connection)
+
+            # Create a listener to wait for AVDTP connections
+            self.listener = Listener(Listener.create_registrar(self.device))
+            self.listener.on('connection', self.on_avdtp_connection)
+
+            print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')
+
+            if connect_address:
+                # Connect to the source
+                try:
+                    await self.connect(connect_address)
+                except CommandTimeoutError:
+                    print(color("Connection timed out", "red"))
+                    return
+            else:
+                # Start being discoverable and connectable
+                print("Waiting for connection...")
+                await self.advertise()
+
+            await hci_source.wait_for_termination()
+
+        for output in self.outputs:
+            await output.stop()
+
+
+# -----------------------------------------------------------------------------
+@click.group()
+@click.pass_context
+def speaker_cli(ctx, device_config):
+    ctx.ensure_object(dict)
+    ctx.obj['device_config'] = device_config
+
+
+@click.command()
+@click.option(
+    '--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True
+)
+@click.option(
+    '--discover', is_flag=True, help='Discover remote endpoints once connected'
+)
+@click.option(
+    '--output',
+    multiple=True,
+    metavar='NAME',
+    help=(
+        'Send audio to this named output '
+        '(may be used more than once for multiple outputs)'
+    ),
+)
+@click.option(
+    '--ui-port',
+    'ui_port',
+    metavar='HTTP_PORT',
+    default=DEFAULT_UI_PORT,
+    show_default=True,
+    help='HTTP port for the UI server',
+)
+@click.option(
+    '--connect',
+    'connect_address',
+    metavar='ADDRESS_OR_NAME',
+    help='Address or name to connect to',
+)
+@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
+@click.argument('transport')
+def speaker(
+    transport, codec, connect_address, discover, output, ui_port, device_config
+):
+    """Run the speaker."""
+
+    if '@ffplay' in output:
+        # Check if ffplay is installed
+        try:
+            subprocess.run(['ffplay', '-version'], capture_output=True, check=True)
+        except FileNotFoundError:
+            print(
+                color('ffplay not installed, @ffplay output will be disabled', 'yellow')
+            )
+            output = list(filter(lambda x: x != '@ffplay', output))
+
+    asyncio.run(
+        Speaker(device_config, transport, codec, discover, output, ui_port).run(
+            connect_address
+        )
+    )
+
+
+# -----------------------------------------------------------------------------
+def main():
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
+    speaker()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == "__main__":
+    main()  # pylint: disable=no-value-for-parameter
diff --git a/apps/unbond.py b/apps/unbond.py
index 105d9a4..5ffd746 100644
--- a/apps/unbond.py
+++ b/apps/unbond.py
@@ -22,40 +22,58 @@
 
 from bumble.device import Device
 from bumble.keys import JsonKeyStore
+from bumble.transport import open_transport
+
+# -----------------------------------------------------------------------------
+async def unbond_with_keystore(keystore, address):
+    if address is None:
+        return await keystore.print()
+
+    try:
+        await keystore.delete(address)
+    except KeyError:
+        print('!!! pairing not found')
 
 
 # -----------------------------------------------------------------------------
-async def unbond(keystore_file, device_config, address):
-    # Create a device to manage the host
-    device = Device.from_config_file(device_config)
-
-    # Get all entries in the keystore
+async def unbond(keystore_file, device_config, hci_transport, address):
+    # With a keystore file, we can instantiate the keystore directly
     if keystore_file:
-        keystore = JsonKeyStore(None, keystore_file)
-    else:
-        keystore = device.keystore
+        return await unbond_with_keystore(JsonKeyStore(None, keystore_file), address)
 
-    if keystore is None:
-        print('no keystore')
-        return
+    # Without a keystore file, we need to obtain the keystore from the device
+    async with await open_transport(hci_transport) as (hci_source, hci_sink):
+        # Create a device to manage the host
+        device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
 
-    if address is None:
-        await keystore.print()
-    else:
-        try:
-            await keystore.delete(address)
-        except KeyError:
-            print('!!! pairing not found')
+        # Power-on the device to ensure we have a key store
+        await device.power_on()
+
+        return await unbond_with_keystore(device.keystore, address)
 
 
 # -----------------------------------------------------------------------------
 @click.command()
-@click.option('--keystore-file', help='File in which to store the pairing keys')
-@click.argument('device-config')
+@click.option('--keystore-file', help='File in which the pairing keys are stored')
+@click.option('--hci-transport', help='HCI transport for the controller')
+@click.argument('device-config', required=False)
 @click.argument('address', required=False)
-def main(keystore_file, device_config, address):
+def main(keystore_file, hci_transport, device_config, address):
+    """
+    Remove pairing keys for a device, given its address.
+
+    If no keystore file is specified, the --hci-transport option must be used to
+    connect to a controller, so that the keystore for that controller can be
+    instantiated.
+    If no address is passed, the existing pairing keys for all addresses are printed.
+    """
     logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
-    asyncio.run(unbond(keystore_file, device_config, address))
+
+    if not keystore_file and not hci_transport:
+        print('either --keystore-file or --hci-transport must be specified.')
+        return
+
+    asyncio.run(unbond(keystore_file, device_config, hci_transport, address))
 
 
 # -----------------------------------------------------------------------------
diff --git a/bumble/a2dp.py b/bumble/a2dp.py
index 772846a..eeecb1e 100644
--- a/bumble/a2dp.py
+++ b/bumble/a2dp.py
@@ -432,6 +432,7 @@
                 cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
             ),
             channels=sum(cls.CHANNELS_BITS[x] for x in channels),
+            rfa=0,
             vbr=vbr,
             bitrate=bitrate,
         )
diff --git a/bumble/at.py b/bumble/at.py
new file mode 100644
index 0000000..78a4b08
--- /dev/null
+++ b/bumble/at.py
@@ -0,0 +1,85 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import List, Union
+
+
+def tokenize_parameters(buffer: bytes) -> List[bytes]:
+    """Split input parameters into tokens.
+    Removes space characters outside of double quote blocks:
+    T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
+    are ignored [..], unless they are embedded in numeric or string constants"
+    Raises ValueError in case of invalid input string."""
+
+    tokens = []
+    in_quotes = False
+    token = bytearray()
+    for b in buffer:
+        char = bytearray([b])
+
+        if in_quotes:
+            token.extend(char)
+            if char == b'\"':
+                in_quotes = False
+                tokens.append(token[1:-1])
+                token = bytearray()
+        else:
+            if char == b' ':
+                pass
+            elif char == b',' or char == b')':
+                tokens.append(token)
+                tokens.append(char)
+                token = bytearray()
+            elif char == b'(':
+                if len(token) > 0:
+                    raise ValueError("open_paren following regular character")
+                tokens.append(char)
+            elif char == b'"':
+                if len(token) > 0:
+                    raise ValueError("quote following regular character")
+                in_quotes = True
+                token.extend(char)
+            else:
+                token.extend(char)
+
+    tokens.append(token)
+    return [bytes(token) for token in tokens if len(token) > 0]
+
+
+def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
+    """Parse the parameters using the comma and parenthesis separators.
+    Raises ValueError in case of invalid input string."""
+
+    tokens = tokenize_parameters(buffer)
+    accumulator: List[list] = [[]]
+    current: Union[bytes, list] = bytes()
+
+    for token in tokens:
+        if token == b',':
+            accumulator[-1].append(current)
+            current = bytes()
+        elif token == b'(':
+            accumulator.append([])
+        elif token == b')':
+            if len(accumulator) < 2:
+                raise ValueError("close_paren without matching open_paren")
+            accumulator[-1].append(current)
+            current = accumulator.pop()
+        else:
+            current = token
+
+    accumulator[-1].append(current)
+    if len(accumulator) > 1:
+        raise ValueError("missing close_paren")
+    return accumulator[0]
diff --git a/bumble/att.py b/bumble/att.py
index 55ae8a5..db8d2ba 100644
--- a/bumble/att.py
+++ b/bumble/att.py
@@ -23,13 +23,14 @@
 # Imports
 # -----------------------------------------------------------------------------
 from __future__ import annotations
+import enum
 import functools
 import struct
 from pyee import EventEmitter
-from typing import Dict, Type, TYPE_CHECKING
+from typing import Dict, Type, List, Protocol, Union, Optional, Any, TYPE_CHECKING
 
-from bumble.core import UUID, name_or_number, get_dict_key_by_value, ProtocolError
-from bumble.hci import HCI_Object, key_with_value, HCI_Constant
+from bumble.core import UUID, name_or_number, ProtocolError
+from bumble.hci import HCI_Object, key_with_value
 from bumble.colors import color
 
 if TYPE_CHECKING:
@@ -182,6 +183,7 @@
 # pylint: enable=line-too-long
 # pylint: disable=invalid-name
 
+
 # -----------------------------------------------------------------------------
 # Exceptions
 # -----------------------------------------------------------------------------
@@ -209,7 +211,7 @@
 
     pdu_classes: Dict[int, Type[ATT_PDU]] = {}
     op_code = 0
-    name = None
+    name: str
 
     @staticmethod
     def from_bytes(pdu):
@@ -720,47 +722,67 @@
 
 
 # -----------------------------------------------------------------------------
+class ConnectionValue(Protocol):
+    def read(self, connection) -> bytes:
+        ...
+
+    def write(self, connection, value: bytes) -> None:
+        ...
+
+
+# -----------------------------------------------------------------------------
 class Attribute(EventEmitter):
-    # Permission flags
-    READABLE = 0x01
-    WRITEABLE = 0x02
-    READ_REQUIRES_ENCRYPTION = 0x04
-    WRITE_REQUIRES_ENCRYPTION = 0x08
-    READ_REQUIRES_AUTHENTICATION = 0x10
-    WRITE_REQUIRES_AUTHENTICATION = 0x20
-    READ_REQUIRES_AUTHORIZATION = 0x40
-    WRITE_REQUIRES_AUTHORIZATION = 0x80
+    class Permissions(enum.IntFlag):
+        READABLE = 0x01
+        WRITEABLE = 0x02
+        READ_REQUIRES_ENCRYPTION = 0x04
+        WRITE_REQUIRES_ENCRYPTION = 0x08
+        READ_REQUIRES_AUTHENTICATION = 0x10
+        WRITE_REQUIRES_AUTHENTICATION = 0x20
+        READ_REQUIRES_AUTHORIZATION = 0x40
+        WRITE_REQUIRES_AUTHORIZATION = 0x80
 
-    PERMISSION_NAMES = {
-        READABLE: 'READABLE',
-        WRITEABLE: 'WRITEABLE',
-        READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION',
-        WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION',
-        READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION',
-        WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION',
-        READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION',
-        WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION',
-    }
+        @classmethod
+        def from_string(cls, permissions_str: str) -> Attribute.Permissions:
+            try:
+                return functools.reduce(
+                    lambda x, y: x | Attribute.Permissions[y],
+                    permissions_str.replace('|', ',').split(","),
+                    Attribute.Permissions(0),
+                )
+            except TypeError as exc:
+                # The check for `p.name is not None` here is needed because for InFlag
+                # enums, the .name property can be None, when the enum value is 0,
+                # so the type hint for .name is Optional[str].
+                enum_list: List[str] = [p.name for p in cls if p.name is not None]
+                enum_list_str = ",".join(enum_list)
+                raise TypeError(
+                    f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str  }\nGot: {permissions_str}"
+                ) from exc
 
-    @staticmethod
-    def string_to_permissions(permissions_str: str):
-        try:
-            return functools.reduce(
-                lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y),
-                permissions_str.split(","),
-                0,
-            )
-        except TypeError as exc:
-            raise TypeError(
-                f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
-            ) from exc
+    # Permission flags(legacy-use only)
+    READABLE = Permissions.READABLE
+    WRITEABLE = Permissions.WRITEABLE
+    READ_REQUIRES_ENCRYPTION = Permissions.READ_REQUIRES_ENCRYPTION
+    WRITE_REQUIRES_ENCRYPTION = Permissions.WRITE_REQUIRES_ENCRYPTION
+    READ_REQUIRES_AUTHENTICATION = Permissions.READ_REQUIRES_AUTHENTICATION
+    WRITE_REQUIRES_AUTHENTICATION = Permissions.WRITE_REQUIRES_AUTHENTICATION
+    READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
+    WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
 
-    def __init__(self, attribute_type, permissions, value=b''):
+    value: Union[str, bytes, ConnectionValue]
+
+    def __init__(
+        self,
+        attribute_type: Union[str, bytes, UUID],
+        permissions: Union[str, Attribute.Permissions],
+        value: Union[str, bytes, ConnectionValue] = b'',
+    ) -> None:
         EventEmitter.__init__(self)
         self.handle = 0
         self.end_group_handle = 0
         if isinstance(permissions, str):
-            self.permissions = self.string_to_permissions(permissions)
+            self.permissions = Attribute.Permissions.from_string(permissions)
         else:
             self.permissions = permissions
 
@@ -778,22 +800,26 @@
         else:
             self.value = value
 
-    def encode_value(self, value):
+    def encode_value(self, value: Any) -> bytes:
         return value
 
-    def decode_value(self, value_bytes):
+    def decode_value(self, value_bytes: bytes) -> Any:
         return value_bytes
 
-    def read_value(self, connection: Connection):
+    def read_value(self, connection: Optional[Connection]) -> bytes:
         if (
-            self.permissions & self.READ_REQUIRES_ENCRYPTION
-        ) and not connection.encryption:
+            (self.permissions & self.READ_REQUIRES_ENCRYPTION)
+            and connection is not None
+            and not connection.encryption
+        ):
             raise ATT_Error(
                 error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
             )
         if (
-            self.permissions & self.READ_REQUIRES_AUTHENTICATION
-        ) and not connection.authenticated:
+            (self.permissions & self.READ_REQUIRES_AUTHENTICATION)
+            and connection is not None
+            and not connection.authenticated
+        ):
             raise ATT_Error(
                 error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
             )
@@ -803,9 +829,9 @@
                 error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
             )
 
-        if read := getattr(self.value, 'read', None):
+        if hasattr(self.value, 'read'):
             try:
-                value = read(connection)  # pylint: disable=not-callable
+                value = self.value.read(connection)
             except ATT_Error as error:
                 raise ATT_Error(
                     error_code=error.error_code, att_handle=self.handle
@@ -815,7 +841,7 @@
 
         return self.encode_value(value)
 
-    def write_value(self, connection: Connection, value_bytes):
+    def write_value(self, connection: Connection, value_bytes: bytes) -> None:
         if (
             self.permissions & self.WRITE_REQUIRES_ENCRYPTION
         ) and not connection.encryption:
@@ -836,9 +862,9 @@
 
         value = self.decode_value(value_bytes)
 
-        if write := getattr(self.value, 'write', None):
+        if hasattr(self.value, 'write'):
             try:
-                write(connection, value)  # pylint: disable=not-callable
+                self.value.write(connection, value)  # pylint: disable=not-callable
             except ATT_Error as error:
                 raise ATT_Error(
                     error_code=error.error_code, att_handle=self.handle
diff --git a/bumble/avdtp.py b/bumble/avdtp.py
index 238036d..3988f30 100644
--- a/bumble/avdtp.py
+++ b/bumble/avdtp.py
@@ -1207,7 +1207,7 @@
 
 
 # -----------------------------------------------------------------------------
-class Protocol:
+class Protocol(EventEmitter):
     SINGLE_PACKET = 0
     START_PACKET = 1
     CONTINUE_PACKET = 2
@@ -1234,6 +1234,7 @@
         return protocol
 
     def __init__(self, l2cap_channel, version=(1, 3)):
+        super().__init__()
         self.l2cap_channel = l2cap_channel
         self.version = version
         self.rtx_sig_timer = AVDTP_DEFAULT_RTX_SIG_TIMER
@@ -1250,6 +1251,7 @@
         # Register to receive PDUs from the channel
         l2cap_channel.sink = self.on_pdu
         l2cap_channel.on('open', self.on_l2cap_channel_open)
+        l2cap_channel.on('close', self.on_l2cap_channel_close)
 
     def get_local_endpoint_by_seid(self, seid):
         if 0 < seid <= len(self.local_endpoints):
@@ -1392,11 +1394,18 @@
 
     def on_l2cap_connection(self, channel):
         # Forward the channel to the endpoint that's expecting it
-        if self.channel_acceptor:
-            self.channel_acceptor.on_l2cap_connection(channel)
+        if self.channel_acceptor is None:
+            logger.warning(color('!!! l2cap connection with no acceptor', 'red'))
+            return
+        self.channel_acceptor.on_l2cap_connection(channel)
 
     def on_l2cap_channel_open(self):
         logger.debug(color('<<< L2CAP channel open', 'magenta'))
+        self.emit('open')
+
+    def on_l2cap_channel_close(self):
+        logger.debug(color('<<< L2CAP channel close', 'magenta'))
+        self.emit('close')
 
     def send_message(self, transaction_label, message):
         logger.debug(
@@ -1651,6 +1660,10 @@
     def set_server(self, connection, server):
         self.servers[connection.handle] = server
 
+    def remove_server(self, connection):
+        if connection.handle in self.servers:
+            del self.servers[connection.handle]
+
     def __init__(self, registrar, version=(1, 3)):
         super().__init__()
         self.version = version
@@ -1669,11 +1682,17 @@
         else:
             # This is a new command/response channel
             def on_channel_open():
+                logger.debug('setting up new Protocol for the connection')
                 server = Protocol(channel, self.version)
                 self.set_server(channel.connection, server)
                 self.emit('connection', server)
 
+            def on_channel_close():
+                logger.debug('removing Protocol for the connection')
+                self.remove_server(channel.connection)
+
             channel.on('open', on_channel_open)
+            channel.on('close', on_channel_close)
 
 
 # -----------------------------------------------------------------------------
@@ -1967,11 +1986,12 @@
 
 
 # -----------------------------------------------------------------------------
-class LocalStreamEndPoint(StreamEndPoint):
+class LocalStreamEndPoint(StreamEndPoint, EventEmitter):
     def __init__(
         self, protocol, seid, media_type, tsep, capabilities, configuration=None
     ):
-        super().__init__(seid, media_type, tsep, 0, capabilities)
+        StreamEndPoint.__init__(self, seid, media_type, tsep, 0, capabilities)
+        EventEmitter.__init__(self)
         self.protocol = protocol
         self.configuration = configuration if configuration is not None else []
         self.stream = None
@@ -1988,40 +2008,47 @@
     def on_reconfigure_command(self, command):
         pass
 
+    def on_set_configuration_command(self, configuration):
+        logger.debug(
+            '<<< received configuration: '
+            f'{",".join([str(capability) for capability in configuration])}'
+        )
+        self.configuration = configuration
+        self.emit('configuration')
+
     def on_get_configuration_command(self):
         return Get_Configuration_Response(self.configuration)
 
     def on_open_command(self):
-        pass
+        self.emit('open')
 
     def on_start_command(self):
-        pass
+        self.emit('start')
 
     def on_suspend_command(self):
-        pass
+        self.emit('suspend')
 
     def on_close_command(self):
-        pass
+        self.emit('close')
 
     def on_abort_command(self):
-        pass
+        self.emit('abort')
 
     def on_rtp_channel_open(self):
-        pass
+        self.emit('rtp_channel_open')
 
     def on_rtp_channel_close(self):
-        pass
+        self.emit('rtp_channel_close')
 
 
 # -----------------------------------------------------------------------------
-class LocalSource(LocalStreamEndPoint, EventEmitter):
+class LocalSource(LocalStreamEndPoint):
     def __init__(self, protocol, seid, codec_capabilities, packet_pump):
         capabilities = [
             ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
             codec_capabilities,
         ]
-        LocalStreamEndPoint.__init__(
-            self,
+        super().__init__(
             protocol,
             seid,
             codec_capabilities.media_type,
@@ -2029,14 +2056,13 @@
             capabilities,
             capabilities,
         )
-        EventEmitter.__init__(self)
         self.packet_pump = packet_pump
 
     async def start(self):
         if self.packet_pump:
             return await self.packet_pump.start(self.stream.rtp_channel)
 
-        self.emit('start', self.stream.rtp_channel)
+        self.emit('start')
 
     async def stop(self):
         if self.packet_pump:
@@ -2044,11 +2070,6 @@
 
         self.emit('stop')
 
-    def on_set_configuration_command(self, configuration):
-        # For now, blindly accept the configuration
-        logger.debug(f'<<< received source configuration: {configuration}')
-        self.configuration = configuration
-
     def on_start_command(self):
         asyncio.create_task(self.start())
 
@@ -2057,30 +2078,28 @@
 
 
 # -----------------------------------------------------------------------------
-class LocalSink(LocalStreamEndPoint, EventEmitter):
+class LocalSink(LocalStreamEndPoint):
     def __init__(self, protocol, seid, codec_capabilities):
         capabilities = [
             ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
             codec_capabilities,
         ]
-        LocalStreamEndPoint.__init__(
-            self,
+        super().__init__(
             protocol,
             seid,
             codec_capabilities.media_type,
             AVDTP_TSEP_SNK,
             capabilities,
         )
-        EventEmitter.__init__(self)
-
-    def on_set_configuration_command(self, configuration):
-        # For now, blindly accept the configuration
-        logger.debug(f'<<< received sink configuration: {configuration}')
-        self.configuration = configuration
 
     def on_rtp_channel_open(self):
         logger.debug(color('<<< RTP channel open', 'magenta'))
         self.stream.rtp_channel.sink = self.on_avdtp_packet
+        super().on_rtp_channel_open()
+
+    def on_rtp_channel_close(self):
+        logger.debug(color('<<< RTP channel close', 'magenta'))
+        super().on_rtp_channel_close()
 
     def on_avdtp_packet(self, packet):
         rtp_packet = MediaPacket.from_bytes(packet)
diff --git a/bumble/codecs.py b/bumble/codecs.py
new file mode 100644
index 0000000..1d7ae82
--- /dev/null
+++ b/bumble/codecs.py
@@ -0,0 +1,381 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+from dataclasses import dataclass
+
+
+# -----------------------------------------------------------------------------
+class BitReader:
+    """Simple but not optimized bit stream reader."""
+
+    data: bytes
+    bytes_position: int
+    bit_position: int
+    cache: int
+    bits_cached: int
+
+    def __init__(self, data: bytes):
+        self.data = data
+        self.byte_position = 0
+        self.bit_position = 0
+        self.cache = 0
+        self.bits_cached = 0
+
+    def read(self, bits: int) -> int:
+        """ "Read up to 32 bits."""
+
+        if bits > 32:
+            raise ValueError('maximum read size is 32')
+
+        if self.bits_cached >= bits:
+            # We have enough bits.
+            self.bits_cached -= bits
+            self.bit_position += bits
+            return (self.cache >> self.bits_cached) & ((1 << bits) - 1)
+
+        # Read more cache, up to 32 bits
+        feed_bytes = self.data[self.byte_position : self.byte_position + 4]
+        feed_size = len(feed_bytes)
+        feed_int = int.from_bytes(feed_bytes, byteorder='big')
+        if 8 * feed_size + self.bits_cached < bits:
+            raise ValueError('trying to read past the data')
+        self.byte_position += feed_size
+
+        # Combine the new cache and the old cache
+        cache = self.cache & ((1 << self.bits_cached) - 1)
+        new_bits = bits - self.bits_cached
+        self.bits_cached = 8 * feed_size - new_bits
+        result = (feed_int >> self.bits_cached) | (cache << new_bits)
+        self.cache = feed_int
+
+        self.bit_position += bits
+        return result
+
+    def read_bytes(self, count: int):
+        if self.bit_position + 8 * count > 8 * len(self.data):
+            raise ValueError('not enough data')
+
+        if self.bit_position % 8:
+            # Not byte aligned
+            result = bytearray(count)
+            for i in range(count):
+                result[i] = self.read(8)
+            return bytes(result)
+
+        # Byte aligned
+        self.byte_position = self.bit_position // 8
+        self.bits_cached = 0
+        self.cache = 0
+        offset = self.bit_position // 8
+        self.bit_position += 8 * count
+        return self.data[offset : offset + count]
+
+    def bits_left(self) -> int:
+        return (8 * len(self.data)) - self.bit_position
+
+    def skip(self, bits: int) -> None:
+        # Slow, but simple...
+        while bits:
+            if bits > 32:
+                self.read(32)
+                bits -= 32
+            else:
+                self.read(bits)
+                break
+
+
+# -----------------------------------------------------------------------------
+class AacAudioRtpPacket:
+    """AAC payload encapsulated in an RTP packet payload"""
+
+    @staticmethod
+    def latm_value(reader: BitReader) -> int:
+        bytes_for_value = reader.read(2)
+        value = 0
+        for _ in range(bytes_for_value + 1):
+            value = value * 256 + reader.read(8)
+        return value
+
+    @staticmethod
+    def program_config_element(reader: BitReader):
+        raise ValueError('program_config_element not supported')
+
+    @dataclass
+    class GASpecificConfig:
+        def __init__(
+            self, reader: BitReader, channel_configuration: int, audio_object_type: int
+        ) -> None:
+            # GASpecificConfig - ISO/EIC 14496-3 Table 4.1
+            frame_length_flag = reader.read(1)
+            depends_on_core_coder = reader.read(1)
+            if depends_on_core_coder:
+                self.core_coder_delay = reader.read(14)
+            extension_flag = reader.read(1)
+            if not channel_configuration:
+                AacAudioRtpPacket.program_config_element(reader)
+            if audio_object_type in (6, 20):
+                self.layer_nr = reader.read(3)
+            if extension_flag:
+                if audio_object_type == 22:
+                    num_of_sub_frame = reader.read(5)
+                layer_length = reader.read(11)
+                if audio_object_type in (17, 19, 20, 23):
+                    aac_section_data_resilience_flags = reader.read(1)
+                    aac_scale_factor_data_resilience_flags = reader.read(1)
+                    aac_spectral_data_resilience_flags = reader.read(1)
+                extension_flag_3 = reader.read(1)
+                if extension_flag_3 == 1:
+                    raise ValueError('extensionFlag3 == 1 not supported')
+
+    @staticmethod
+    def audio_object_type(reader: BitReader):
+        # GetAudioObjectType - ISO/EIC 14496-3 Table 1.16
+        audio_object_type = reader.read(5)
+        if audio_object_type == 31:
+            audio_object_type = 32 + reader.read(6)
+
+        return audio_object_type
+
+    @dataclass
+    class AudioSpecificConfig:
+        audio_object_type: int
+        sampling_frequency_index: int
+        sampling_frequency: int
+        channel_configuration: int
+        sbr_present_flag: int
+        ps_present_flag: int
+        extension_audio_object_type: int
+        extension_sampling_frequency_index: int
+        extension_sampling_frequency: int
+        extension_channel_configuration: int
+
+        SAMPLING_FREQUENCIES = [
+            96000,
+            88200,
+            64000,
+            48000,
+            44100,
+            32000,
+            24000,
+            22050,
+            16000,
+            12000,
+            11025,
+            8000,
+            7350,
+        ]
+
+        def __init__(self, reader: BitReader) -> None:
+            # AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15
+            self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
+            self.sampling_frequency_index = reader.read(4)
+            if self.sampling_frequency_index == 0xF:
+                self.sampling_frequency = reader.read(24)
+            else:
+                self.sampling_frequency = self.SAMPLING_FREQUENCIES[
+                    self.sampling_frequency_index
+                ]
+            self.channel_configuration = reader.read(4)
+            self.sbr_present_flag = -1
+            self.ps_present_flag = -1
+            if self.audio_object_type in (5, 29):
+                self.extension_audio_object_type = 5
+                self.sbc_present_flag = 1
+                if self.audio_object_type == 29:
+                    self.ps_present_flag = 1
+                self.extension_sampling_frequency_index = reader.read(4)
+                if self.extension_sampling_frequency_index == 0xF:
+                    self.extension_sampling_frequency = reader.read(24)
+                else:
+                    self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[
+                        self.extension_sampling_frequency_index
+                    ]
+                self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
+                if self.audio_object_type == 22:
+                    self.extension_channel_configuration = reader.read(4)
+            else:
+                self.extension_audio_object_type = 0
+
+            if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
+                ga_specific_config = AacAudioRtpPacket.GASpecificConfig(
+                    reader, self.channel_configuration, self.audio_object_type
+                )
+            else:
+                raise ValueError(
+                    f'audioObjectType {self.audio_object_type} not supported'
+                )
+
+            # if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
+            #     sync_extension_type = reader.read(11)
+            #     if sync_extension_type == 0x2B7:
+            #         self.extension_audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
+            #         if self.extension_audio_object_type == 5:
+            #             self.sbr_present_flag = reader.read(1)
+            #             if self.sbr_present_flag:
+            #                 self.extension_sampling_frequency_index = reader.read(4)
+            #                 if self.extension_sampling_frequency_index == 0xF:
+            #                     self.extension_sampling_frequency = reader.read(24)
+            #                 else:
+            #                     self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
+            #                 if bits_to_decode >= 12:
+            #                     sync_extension_type = reader.read(11)
+            #                     if sync_extension_type == 0x548:
+            #                         self.ps_present_flag = reader.read(1)
+            #         elif self.extension_audio_object_type == 22:
+            #             self.sbr_present_flag = reader.read(1)
+            #             if self.sbr_present_flag:
+            #                 self.extension_sampling_frequency_index = reader.read(4)
+            #                 if self.extension_sampling_frequency_index == 0xF:
+            #                     self.extension_sampling_frequency = reader.read(24)
+            #                 else:
+            #                     self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
+            #             self.extension_channel_configuration = reader.read(4)
+
+    @dataclass
+    class StreamMuxConfig:
+        other_data_present: int
+        other_data_len_bits: int
+        audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig
+
+        def __init__(self, reader: BitReader) -> None:
+            # StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
+            audio_mux_version = reader.read(1)
+            if audio_mux_version == 1:
+                audio_mux_version_a = reader.read(1)
+            else:
+                audio_mux_version_a = 0
+            if audio_mux_version_a != 0:
+                raise ValueError('audioMuxVersionA != 0 not supported')
+            if audio_mux_version == 1:
+                tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
+            stream_cnt = 0
+            all_streams_same_time_framing = reader.read(1)
+            num_sub_frames = reader.read(6)
+            num_program = reader.read(4)
+            if num_program != 0:
+                raise ValueError('num_program != 0 not supported')
+            num_layer = reader.read(3)
+            if num_layer != 0:
+                raise ValueError('num_layer != 0 not supported')
+            if audio_mux_version == 0:
+                self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
+                    reader
+                )
+            else:
+                asc_len = AacAudioRtpPacket.latm_value(reader)
+                marker = reader.bit_position
+                self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
+                    reader
+                )
+                audio_specific_config_len = reader.bit_position - marker
+                if asc_len < audio_specific_config_len:
+                    raise ValueError('audio_specific_config_len > asc_len')
+                asc_len -= audio_specific_config_len
+                reader.skip(asc_len)
+            frame_length_type = reader.read(3)
+            if frame_length_type == 0:
+                latm_buffer_fullness = reader.read(8)
+            elif frame_length_type == 1:
+                frame_length = reader.read(9)
+            else:
+                raise ValueError(f'frame_length_type {frame_length_type} not supported')
+
+            self.other_data_present = reader.read(1)
+            if self.other_data_present:
+                if audio_mux_version == 1:
+                    self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader)
+                else:
+                    self.other_data_len_bits = 0
+                    while True:
+                        self.other_data_len_bits *= 256
+                        other_data_len_esc = reader.read(1)
+                        self.other_data_len_bits += reader.read(8)
+                        if other_data_len_esc == 0:
+                            break
+            crc_check_present = reader.read(1)
+            if crc_check_present:
+                crc_checksum = reader.read(8)
+
+    @dataclass
+    class AudioMuxElement:
+        payload: bytes
+        stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
+
+        def __init__(self, reader: BitReader, mux_config_present: int):
+            if mux_config_present == 0:
+                raise ValueError('muxConfigPresent == 0 not supported')
+
+            # AudioMuxElement - ISO/EIC 14496-3 Table 1.41
+            use_same_stream_mux = reader.read(1)
+            if use_same_stream_mux:
+                raise ValueError('useSameStreamMux == 1 not supported')
+            self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
+
+            # We only support:
+            # allStreamsSameTimeFraming == 1
+            # audioMuxVersionA == 0,
+            # numProgram == 0
+            # numSubFrames == 0
+            # numLayer == 0
+
+            mux_slot_length_bytes = 0
+            while True:
+                tmp = reader.read(8)
+                mux_slot_length_bytes += tmp
+                if tmp != 255:
+                    break
+
+            self.payload = reader.read_bytes(mux_slot_length_bytes)
+
+            if self.stream_mux_config.other_data_present:
+                reader.skip(self.stream_mux_config.other_data_len_bits)
+
+            # ByteAlign
+            while reader.bit_position % 8:
+                reader.read(1)
+
+    def __init__(self, data: bytes) -> None:
+        # Parse the bit stream
+        reader = BitReader(data)
+        self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1)
+
+    def to_adts(self):
+        # pylint: disable=line-too-long
+        sampling_frequency_index = (
+            self.audio_mux_element.stream_mux_config.audio_specific_config.sampling_frequency_index
+        )
+        channel_configuration = (
+            self.audio_mux_element.stream_mux_config.audio_specific_config.channel_configuration
+        )
+        frame_size = len(self.audio_mux_element.payload)
+        return (
+            bytes(
+                [
+                    0xFF,
+                    0xF1,  # 0xF9 (MPEG2)
+                    0x40
+                    | (sampling_frequency_index << 2)
+                    | (channel_configuration >> 2),
+                    ((channel_configuration & 0x3) << 6) | ((frame_size + 7) >> 11),
+                    ((frame_size + 7) >> 3) & 0xFF,
+                    (((frame_size + 7) << 5) & 0xFF) | 0x1F,
+                    0xFC,
+                ]
+            )
+            + self.audio_mux_element.payload
+        )
diff --git a/bumble/controller.py b/bumble/controller.py
index cd7de3d..9b2960a 100644
--- a/bumble/controller.py
+++ b/bumble/controller.py
@@ -15,6 +15,8 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
+
 import logging
 import asyncio
 import itertools
@@ -58,8 +60,10 @@
     HCI_Packet,
     HCI_Role_Change_Event,
 )
-from typing import Optional, Union, Dict
+from typing import Optional, Union, Dict, TYPE_CHECKING
 
+if TYPE_CHECKING:
+    from bumble.transport.common import TransportSink, TransportSource
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -104,7 +108,7 @@
         self,
         name,
         host_source=None,
-        host_sink=None,
+        host_sink: Optional[TransportSink] = None,
         link=None,
         public_address: Optional[Union[bytes, str, Address]] = None,
     ):
@@ -188,6 +192,8 @@
         if link:
             link.add_controller(self)
 
+        self.terminated = asyncio.get_running_loop().create_future()
+
     @property
     def host(self):
         return self.hci_sink
@@ -288,10 +294,9 @@
         if self.host:
             self.host.on_packet(packet.to_bytes())
 
-    # This method allow the controller to emulate the same API as a transport source
+    # This method allows the controller to emulate the same API as a transport source
     async def wait_for_termination(self):
-        # For now, just wait forever
-        await asyncio.get_running_loop().create_future()
+        await self.terminated
 
     ############################################################
     # Link connections
@@ -654,7 +659,7 @@
 
     def on_hci_create_connection_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
+        See Bluetooth spec Vol 4, Part E - 7.1.5 Create Connection command
         '''
 
         if self.link is None:
@@ -685,7 +690,7 @@
 
     def on_hci_disconnect_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.1.6 Disconnect Command
+        See Bluetooth spec Vol 4, Part E - 7.1.6 Disconnect Command
         '''
         # First, say that the disconnection is pending
         self.send_hci_packet(
@@ -719,7 +724,7 @@
 
     def on_hci_accept_connection_request_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command
+        See Bluetooth spec Vol 4, Part E - 7.1.8 Accept Connection Request command
         '''
 
         if self.link is None:
@@ -735,7 +740,7 @@
 
     def on_hci_switch_role_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command
+        See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
         '''
 
         if self.link is None:
@@ -751,21 +756,21 @@
 
     def on_hci_set_event_mask_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.1 Set Event Mask Command
+        See Bluetooth spec Vol 4, Part E - 7.3.1 Set Event Mask Command
         '''
         self.event_mask = command.event_mask
         return bytes([HCI_SUCCESS])
 
     def on_hci_reset_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.2 Reset Command
+        See Bluetooth spec Vol 4, Part E - 7.3.2 Reset Command
         '''
         # TODO: cleanup what needs to be reset
         return bytes([HCI_SUCCESS])
 
     def on_hci_write_local_name_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.11 Write Local Name Command
+        See Bluetooth spec Vol 4, Part E - 7.3.11 Write Local Name Command
         '''
         local_name = command.local_name
         if len(local_name):
@@ -780,7 +785,7 @@
 
     def on_hci_read_local_name_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.12 Read Local Name Command
+        See Bluetooth spec Vol 4, Part E - 7.3.12 Read Local Name Command
         '''
         local_name = bytes(self.local_name, 'utf-8')[:248]
         if len(local_name) < 248:
@@ -790,19 +795,19 @@
 
     def on_hci_read_class_of_device_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.25 Read Class of Device Command
+        See Bluetooth spec Vol 4, Part E - 7.3.25 Read Class of Device Command
         '''
         return bytes([HCI_SUCCESS, 0, 0, 0])
 
     def on_hci_write_class_of_device_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.26 Write Class of Device Command
+        See Bluetooth spec Vol 4, Part E - 7.3.26 Write Class of Device Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_read_synchronous_flow_control_enable_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.36 Read Synchronous Flow Control Enable
+        See Bluetooth spec Vol 4, Part E - 7.3.36 Read Synchronous Flow Control Enable
         Command
         '''
         if self.sync_flow_control:
@@ -813,7 +818,7 @@
 
     def on_hci_write_synchronous_flow_control_enable_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.37 Write Synchronous Flow Control Enable
+        See Bluetooth spec Vol 4, Part E - 7.3.37 Write Synchronous Flow Control Enable
         Command
         '''
         ret = HCI_SUCCESS
@@ -825,41 +830,59 @@
             ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
         return bytes([ret])
 
+    def on_hci_set_controller_to_host_flow_control_command(self, _command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.3.38 Set Controller To Host Flow Control
+        Command
+        '''
+        # For now we just accept the command but ignore the values.
+        # TODO: respect the passed in values.
+        return bytes([HCI_SUCCESS])
+
+    def on_hci_host_buffer_size_command(self, _command):
+        '''
+        See Bluetooth spec Vol 4, Part E - 7.3.39 Host Buffer Size Command
+        '''
+        # For now we just accept the command but ignore the values.
+        # TODO: respect the passed in values.
+        return bytes([HCI_SUCCESS])
+
     def on_hci_write_extended_inquiry_response_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
+        See Bluetooth spec Vol 4, Part E - 7.3.56 Write Extended Inquiry Response
+        Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_write_simple_pairing_mode_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
+        See Bluetooth spec Vol 4, Part E - 7.3.59 Write Simple Pairing Mode Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_set_event_mask_page_2_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.69 Set Event Mask Page 2 Command
+        See Bluetooth spec Vol 4, Part E - 7.3.69 Set Event Mask Page 2 Command
         '''
         self.event_mask_page_2 = command.event_mask_page_2
         return bytes([HCI_SUCCESS])
 
     def on_hci_read_le_host_support_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.78 Write LE Host Support Command
+        See Bluetooth spec Vol 4, Part E - 7.3.78 Write LE Host Support Command
         '''
         return bytes([HCI_SUCCESS, 1, 0])
 
     def on_hci_write_le_host_support_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.79 Write LE Host Support Command
+        See Bluetooth spec Vol 4, Part E - 7.3.79 Write LE Host Support Command
         '''
         # TODO / Just ignore for now
         return bytes([HCI_SUCCESS])
 
     def on_hci_write_authenticated_payload_timeout_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.3.94 Write Authenticated Payload Timeout
+        See Bluetooth spec Vol 4, Part E - 7.3.94 Write Authenticated Payload Timeout
         Command
         '''
         # TODO
@@ -867,7 +890,7 @@
 
     def on_hci_read_local_version_information_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
+        See Bluetooth spec Vol 4, Part E - 7.4.1 Read Local Version Information Command
         '''
         return struct.pack(
             '<BBHBHH',
@@ -881,19 +904,19 @@
 
     def on_hci_read_local_supported_commands_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.4.2 Read Local Supported Commands Command
+        See Bluetooth spec Vol 4, Part E - 7.4.2 Read Local Supported Commands Command
         '''
         return bytes([HCI_SUCCESS]) + self.supported_commands
 
     def on_hci_read_local_supported_features_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.4.3 Read Local Supported Features Command
+        See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
         '''
         return bytes([HCI_SUCCESS]) + self.lmp_features
 
     def on_hci_read_bd_addr_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.4.6 Read BD_ADDR Command
+        See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
         '''
         bd_addr = (
             self._public_address.to_bytes()
@@ -904,14 +927,14 @@
 
     def on_hci_le_set_event_mask_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.1 LE Set Event Mask Command
+        See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command
         '''
         self.le_event_mask = command.le_event_mask
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_read_buffer_size_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.2 LE Read Buffer Size Command
+        See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
         '''
         return struct.pack(
             '<BHB',
@@ -922,49 +945,49 @@
 
     def on_hci_le_read_local_supported_features_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.3 LE Read Local Supported Features
+        See Bluetooth spec Vol 4, Part E - 7.8.3 LE Read Local Supported Features
         Command
         '''
         return bytes([HCI_SUCCESS]) + self.le_features
 
     def on_hci_le_set_random_address_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.4 LE Set Random Address Command
+        See Bluetooth spec Vol 4, Part E - 7.8.4 LE Set Random Address Command
         '''
         self.random_address = command.random_address
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_set_advertising_parameters_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.5 LE Set Advertising Parameters Command
+        See Bluetooth spec Vol 4, Part E - 7.8.5 LE Set Advertising Parameters Command
         '''
         self.advertising_parameters = command
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Physical Channel
+        See Bluetooth spec Vol 4, Part E - 7.8.6 LE Read Advertising Physical Channel
         Tx Power Command
         '''
         return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
 
     def on_hci_le_set_advertising_data_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.7 LE Set Advertising Data Command
+        See Bluetooth spec Vol 4, Part E - 7.8.7 LE Set Advertising Data Command
         '''
         self.advertising_data = command.advertising_data
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_set_scan_response_data_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.8 LE Set Scan Response Data Command
+        See Bluetooth spec Vol 4, Part E - 7.8.8 LE Set Scan Response Data Command
         '''
         self.le_scan_response_data = command.scan_response_data
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_set_advertising_enable_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.9 LE Set Advertising Enable Command
+        See Bluetooth spec Vol 4, Part E - 7.8.9 LE Set Advertising Enable Command
         '''
         if command.advertising_enable:
             self.start_advertising()
@@ -975,7 +998,7 @@
 
     def on_hci_le_set_scan_parameters_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.10 LE Set Scan Parameters Command
+        See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
         '''
         self.le_scan_type = command.le_scan_type
         self.le_scan_interval = command.le_scan_interval
@@ -986,7 +1009,7 @@
 
     def on_hci_le_set_scan_enable_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.11 LE Set Scan Enable Command
+        See Bluetooth spec Vol 4, Part E - 7.8.11 LE Set Scan Enable Command
         '''
         self.le_scan_enable = command.le_scan_enable
         self.filter_duplicates = command.filter_duplicates
@@ -994,7 +1017,7 @@
 
     def on_hci_le_create_connection_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.12 LE Create Connection Command
+        See Bluetooth spec Vol 4, Part E - 7.8.12 LE Create Connection Command
         '''
 
         if not self.link:
@@ -1027,40 +1050,40 @@
 
     def on_hci_le_create_connection_cancel_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.13 LE Create Connection Cancel Command
+        See Bluetooth spec Vol 4, Part E - 7.8.13 LE Create Connection Cancel Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_read_filter_accept_list_size_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read Filter Accept List Size
+        See Bluetooth spec Vol 4, Part E - 7.8.14 LE Read Filter Accept List Size
         Command
         '''
         return bytes([HCI_SUCCESS, self.filter_accept_list_size])
 
     def on_hci_le_clear_filter_accept_list_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear Filter Accept List Command
+        See Bluetooth spec Vol 4, Part E - 7.8.15 LE Clear Filter Accept List Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_add_device_to_filter_accept_list_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To Filter Accept List
+        See Bluetooth spec Vol 4, Part E - 7.8.16 LE Add Device To Filter Accept List
         Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_remove_device_from_filter_accept_list_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From Filter Accept
+        See Bluetooth spec Vol 4, Part E - 7.8.17 LE Remove Device From Filter Accept
         List Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_read_remote_features_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.21 LE Read Remote Features Command
+        See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
         '''
 
         # First, say that the command is pending
@@ -1083,13 +1106,13 @@
 
     def on_hci_le_rand_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.23 LE Rand Command
+        See Bluetooth spec Vol 4, Part E - 7.8.23 LE Rand Command
         '''
         return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
 
     def on_hci_le_enable_encryption_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.24 LE Enable Encryption Command
+        See Bluetooth spec Vol 4, Part E - 7.8.24 LE Enable Encryption Command
         '''
 
         # Check the parameters
@@ -1122,13 +1145,13 @@
 
     def on_hci_le_read_supported_states_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.27 LE Read Supported States Command
+        See Bluetooth spec Vol 4, Part E - 7.8.27 LE Read Supported States Command
         '''
         return bytes([HCI_SUCCESS]) + self.le_states
 
     def on_hci_le_read_suggested_default_data_length_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.34 LE Read Suggested Default Data Length
+        See Bluetooth spec Vol 4, Part E - 7.8.34 LE Read Suggested Default Data Length
         Command
         '''
         return struct.pack(
@@ -1140,7 +1163,7 @@
 
     def on_hci_le_write_suggested_default_data_length_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.35 LE Write Suggested Default Data Length
+        See Bluetooth spec Vol 4, Part E - 7.8.35 LE Write Suggested Default Data Length
         Command
         '''
         self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack(
@@ -1150,33 +1173,33 @@
 
     def on_hci_le_read_local_p_256_public_key_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.36 LE Read P-256 Public Key Command
+        See Bluetooth spec Vol 4, Part E - 7.8.36 LE Read P-256 Public Key Command
         '''
         # TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_add_device_to_resolving_list_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.38 LE Add Device To Resolving List
+        See Bluetooth spec Vol 4, Part E - 7.8.38 LE Add Device To Resolving List
         Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_clear_resolving_list_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.40 LE Clear Resolving List Command
+        See Bluetooth spec Vol 4, Part E - 7.8.40 LE Clear Resolving List Command
         '''
         return bytes([HCI_SUCCESS])
 
     def on_hci_le_read_resolving_list_size_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.41 LE Read Resolving List Size Command
+        See Bluetooth spec Vol 4, Part E - 7.8.41 LE Read Resolving List Size Command
         '''
         return bytes([HCI_SUCCESS, self.resolving_list_size])
 
     def on_hci_le_set_address_resolution_enable_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable
+        See Bluetooth spec Vol 4, Part E - 7.8.44 LE Set Address Resolution Enable
         Command
         '''
         ret = HCI_SUCCESS
@@ -1190,7 +1213,7 @@
 
     def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.45 LE Set Resolvable Private Address
+        See Bluetooth spec Vol 4, Part E - 7.8.45 LE Set Resolvable Private Address
         Timeout Command
         '''
         self.le_rpa_timeout = command.rpa_timeout
@@ -1198,7 +1221,7 @@
 
     def on_hci_le_read_maximum_data_length_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
+        See Bluetooth spec Vol 4, Part E - 7.8.46 LE Read Maximum Data Length Command
         '''
         return struct.pack(
             '<BHHHH',
@@ -1211,7 +1234,7 @@
 
     def on_hci_le_read_phy_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.47 LE Read PHY Command
+        See Bluetooth spec Vol 4, Part E - 7.8.47 LE Read PHY Command
         '''
         return struct.pack(
             '<BHBB',
@@ -1223,7 +1246,7 @@
 
     def on_hci_le_set_default_phy_command(self, command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.48 LE Set Default PHY Command
+        See Bluetooth spec Vol 4, Part E - 7.8.48 LE Set Default PHY Command
         '''
         self.default_phy = {
             'all_phys': command.all_phys,
@@ -1234,6 +1257,6 @@
 
     def on_hci_le_read_transmit_power_command(self, _command):
         '''
-        See Bluetooth spec Vol 2, Part E - 7.8.74 LE Read Transmit Power Command
+        See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
         '''
         return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
diff --git a/bumble/core.py b/bumble/core.py
index 1cc10ec..4a67d6e 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -17,7 +17,7 @@
 # -----------------------------------------------------------------------------
 from __future__ import annotations
 import struct
-from typing import List, Optional, Tuple, Union, cast
+from typing import List, Optional, Tuple, Union, cast, Dict
 
 from .company_ids import COMPANY_IDENTIFIERS
 
@@ -53,7 +53,7 @@
     return names
 
 
-def name_or_number(dictionary, number, width=2):
+def name_or_number(dictionary: Dict[int, str], number: int, width: int = 2) -> str:
     name = dictionary.get(number)
     if name is not None:
         return name
@@ -78,7 +78,13 @@
 class BaseError(Exception):
     """Base class for errors with an error code, error name and namespace"""
 
-    def __init__(self, error_code, error_namespace='', error_name='', details=''):
+    def __init__(
+        self,
+        error_code: Optional[int],
+        error_namespace: str = '',
+        error_name: str = '',
+        details: str = '',
+    ):
         super().__init__()
         self.error_code = error_code
         self.error_namespace = error_namespace
@@ -90,12 +96,14 @@
             namespace = f'{self.error_namespace}/'
         else:
             namespace = ''
-        if self.error_name:
-            name = f'{self.error_name} [0x{self.error_code:X}]'
-        else:
-            name = f'0x{self.error_code:X}'
+        error_text = {
+            (True, True): f'{self.error_name} [0x{self.error_code:X}]',
+            (True, False): self.error_name,
+            (False, True): f'0x{self.error_code:X}',
+            (False, False): '',
+        }[(self.error_name != '', self.error_code is not None)]
 
-        return f'{type(self).__name__}({namespace}{name})'
+        return f'{type(self).__name__}({namespace}{error_text})'
 
 
 class ProtocolError(BaseError):
@@ -134,6 +142,10 @@
         self.peer_address = peer_address
 
 
+class ConnectionParameterUpdateError(BaseError):
+    """Connection Parameter Update Error"""
+
+
 # -----------------------------------------------------------------------------
 # UUID
 #
@@ -562,11 +574,82 @@
         PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
     }
 
+    WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
+    WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS    = 0x01
+    WEARABLE_PAGER_MINOR_DEVICE_CLASS         = 0x02
+    WEARABLE_JACKET_MINOR_DEVICE_CLASS        = 0x03
+    WEARABLE_HELMET_MINOR_DEVICE_CLASS        = 0x04
+    WEARABLE_GLASSES_MINOR_DEVICE_CLASS       = 0x05
+
+    WEARABLE_MINOR_DEVICE_CLASS_NAMES = {
+        WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
+        WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS:    'Wristwatch',
+        WEARABLE_PAGER_MINOR_DEVICE_CLASS:         'Pager',
+        WEARABLE_JACKET_MINOR_DEVICE_CLASS:        'Jacket',
+        WEARABLE_HELMET_MINOR_DEVICE_CLASS:        'Helmet',
+        WEARABLE_GLASSES_MINOR_DEVICE_CLASS:       'Glasses',
+    }
+
+    TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS      = 0x00
+    TOY_ROBOT_MINOR_DEVICE_CLASS              = 0x01
+    TOY_VEHICLE_MINOR_DEVICE_CLASS            = 0x02
+    TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS = 0x03
+    TOY_CONTROLLER_MINOR_DEVICE_CLASS         = 0x04
+    TOY_GAME_MINOR_DEVICE_CLASS               = 0x05
+
+    TOY_MINOR_DEVICE_CLASS_NAMES = {
+        TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS:      'Uncategorized',
+        TOY_ROBOT_MINOR_DEVICE_CLASS:              'Robot',
+        TOY_VEHICLE_MINOR_DEVICE_CLASS:            'Vehicle',
+        TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS: 'Doll/Action figure',
+        TOY_CONTROLLER_MINOR_DEVICE_CLASS:         'Controller',
+        TOY_GAME_MINOR_DEVICE_CLASS:               'Game',
+    }
+
+    HEALTH_UNDEFINED_MINOR_DEVICE_CLASS                 = 0x00
+    HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS    = 0x01
+    HEALTH_THERMOMETER_MINOR_DEVICE_CLASS               = 0x02
+    HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS            = 0x03
+    HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS             = 0x04
+    HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS            = 0x05
+    HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS  = 0x06
+    HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS       = 0x07
+    HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS              = 0x08
+    HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS = 0x09
+    HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS         = 0x0A
+    HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS        = 0x0B
+    HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS           = 0x0C
+    HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS          = 0x0D
+    HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS    = 0x0E
+    HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS  = 0x0F
+
+    HEALTH_MINOR_DEVICE_CLASS_NAMES = {
+        HEALTH_UNDEFINED_MINOR_DEVICE_CLASS:                 'Undefined',
+        HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS:    'Blood Pressure Monitor',
+        HEALTH_THERMOMETER_MINOR_DEVICE_CLASS:               'Thermometer',
+        HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS:            'Weighing Scale',
+        HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS:             'Glucose Meter',
+        HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS:            'Pulse Oximeter',
+        HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS:  'Heart/Pulse Rate Monitor',
+        HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS:       'Health Data Display',
+        HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS:              'Step Counter',
+        HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS: 'Body Composition Analyzer',
+        HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS:         'Peak Flow Monitor',
+        HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS:        'Medication Monitor',
+        HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS:           'Knee Prosthesis',
+        HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS:          'Ankle Prosthesis',
+        HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS:    'Generic Health Manager',
+        HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS:  'Personal Mobility Device',
+    }
+
     MINOR_DEVICE_CLASS_NAMES = {
         COMPUTER_MAJOR_DEVICE_CLASS:    COMPUTER_MINOR_DEVICE_CLASS_NAMES,
         PHONE_MAJOR_DEVICE_CLASS:       PHONE_MINOR_DEVICE_CLASS_NAMES,
         AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
-        PERIPHERAL_MAJOR_DEVICE_CLASS:  PERIPHERAL_MINOR_DEVICE_CLASS_NAMES
+        PERIPHERAL_MAJOR_DEVICE_CLASS:  PERIPHERAL_MINOR_DEVICE_CLASS_NAMES,
+        WEARABLE_MAJOR_DEVICE_CLASS:    WEARABLE_MINOR_DEVICE_CLASS_NAMES,
+        TOY_MAJOR_DEVICE_CLASS:         TOY_MINOR_DEVICE_CLASS_NAMES,
+        HEALTH_MAJOR_DEVICE_CLASS:      HEALTH_MINOR_DEVICE_CLASS_NAMES,
     }
 
     # fmt: on
diff --git a/bumble/crypto.py b/bumble/crypto.py
index 757594f..852c675 100644
--- a/bumble/crypto.py
+++ b/bumble/crypto.py
@@ -23,22 +23,18 @@
 # -----------------------------------------------------------------------------
 import logging
 import operator
-import platform
 
-if platform.system() != 'Emscripten':
-    import secrets
-    from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
-    from cryptography.hazmat.primitives.asymmetric.ec import (
-        generate_private_key,
-        ECDH,
-        EllipticCurvePublicNumbers,
-        EllipticCurvePrivateNumbers,
-        SECP256R1,
-    )
-    from cryptography.hazmat.primitives import cmac
-else:
-    # TODO: implement stubs
-    pass
+import secrets
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from cryptography.hazmat.primitives.asymmetric.ec import (
+    generate_private_key,
+    ECDH,
+    EllipticCurvePublicNumbers,
+    EllipticCurvePrivateNumbers,
+    SECP256R1,
+)
+from cryptography.hazmat.primitives import cmac
+
 
 # -----------------------------------------------------------------------------
 # Logging
diff --git a/bumble/device.py b/bumble/device.py
index 258a43d..b01dc58 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -23,7 +23,18 @@
 import logging
 from contextlib import asynccontextmanager, AsyncExitStack
 from dataclasses import dataclass
-from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, Union
+from typing import (
+    Any,
+    Callable,
+    ClassVar,
+    Dict,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    Union,
+    TYPE_CHECKING,
+)
 
 from .colors import color
 from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
@@ -86,6 +97,7 @@
     HCI_LE_Extended_Create_Connection_Command,
     HCI_LE_Rand_Command,
     HCI_LE_Read_PHY_Command,
+    HCI_LE_Set_Address_Resolution_Enable_Command,
     HCI_LE_Set_Advertising_Data_Command,
     HCI_LE_Set_Advertising_Enable_Command,
     HCI_LE_Set_Advertising_Parameters_Command,
@@ -129,6 +141,7 @@
     BT_LE_TRANSPORT,
     BT_PERIPHERAL_ROLE,
     AdvertisingData,
+    ConnectionParameterUpdateError,
     CommandTimeoutError,
     ConnectionPHY,
     InvalidStateError,
@@ -151,6 +164,9 @@
 from . import l2cap
 from . import core
 
+if TYPE_CHECKING:
+    from .transport.common import TransportSource, TransportSink
+
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -651,7 +667,7 @@
     def is_incomplete(self) -> bool:
         return self.handle is None
 
-    def send_l2cap_pdu(self, cid, pdu):
+    def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
         self.device.send_l2cap_pdu(self.handle, cid, pdu)
 
     def create_l2cap_connector(self, psm):
@@ -708,6 +724,7 @@
         connection_interval_max,
         max_latency,
         supervision_timeout,
+        use_l2cap=False,
     ):
         return await self.device.update_connection_parameters(
             self,
@@ -715,6 +732,7 @@
             connection_interval_max,
             max_latency,
             supervision_timeout,
+            use_l2cap=use_l2cap,
         )
 
     async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
@@ -778,6 +796,7 @@
         self.irk = bytes(16)  # This really must be changed for any level of security
         self.keystore = None
         self.gatt_services: List[Dict[str, Any]] = []
+        self.address_resolution_offload = False
 
     def load_from_dict(self, config: Dict[str, Any]) -> None:
         # Load simple properties
@@ -826,6 +845,12 @@
         advertising_data = config.get('advertising_data')
         if advertising_data:
             self.advertising_data = bytes.fromhex(advertising_data)
+        elif config.get('name') is not None:
+            self.advertising_data = bytes(
+                AdvertisingData(
+                    [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
+                )
+            )
 
     def load_from_file(self, filename):
         with open(filename, 'r', encoding='utf-8') as file:
@@ -934,7 +959,13 @@
             pass
 
     @classmethod
-    def with_hci(cls, name, address, hci_source, hci_sink):
+    def with_hci(
+        cls,
+        name: str,
+        address: Address,
+        hci_source: TransportSource,
+        hci_sink: TransportSink,
+    ) -> Device:
         '''
         Create a Device instance with a Host configured to communicate with a controller
         through an HCI source/sink
@@ -943,18 +974,29 @@
         return cls(name=name, address=address, host=host)
 
     @classmethod
-    def from_config_file(cls, filename):
+    def from_config_file(cls, filename: str) -> Device:
         config = DeviceConfiguration()
         config.load_from_file(filename)
         return cls(config=config)
 
     @classmethod
-    def from_config_file_with_hci(cls, filename, hci_source, hci_sink):
-        config = DeviceConfiguration()
-        config.load_from_file(filename)
+    def from_config_with_hci(
+        cls,
+        config: DeviceConfiguration,
+        hci_source: TransportSource,
+        hci_sink: TransportSink,
+    ) -> Device:
         host = Host(controller_source=hci_source, controller_sink=hci_sink)
         return cls(config=config, host=host)
 
+    @classmethod
+    def from_config_file_with_hci(
+        cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
+    ) -> Device:
+        config = DeviceConfiguration()
+        config.load_from_file(filename)
+        return cls.from_config_with_hci(config, hci_source, hci_sink)
+
     def __init__(
         self,
         name: Optional[str] = None,
@@ -1019,6 +1061,7 @@
         self.discoverable = config.discoverable
         self.connectable = config.connectable
         self.classic_accept_any = config.classic_accept_any
+        self.address_resolution_offload = config.address_resolution_offload
 
         for service in config.gatt_services:
             characteristics = []
@@ -1083,7 +1126,7 @@
         return self._host
 
     @host.setter
-    def host(self, host):
+    def host(self, host: Host) -> None:
         # Unsubscribe from events from the current host
         if self._host:
             for event_name in device_host_event_handlers:
@@ -1143,8 +1186,8 @@
     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_server(self, psm, server) -> int:
+        return self.l2cap_channel_manager.register_server(psm, server)
 
     def register_l2cap_channel_server(
         self,
@@ -1170,7 +1213,7 @@
             connection, psm, max_credits, mtu, mps
         )
 
-    def send_l2cap_pdu(self, connection_handle, cid, pdu):
+    def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
         self.host.send_l2cap_pdu(connection_handle, cid, pdu)
 
     async def send_command(self, command, check_result=False):
@@ -1246,31 +1289,16 @@
                 )
 
             # Load the address resolving list
-            if self.keystore and self.host.supports_command(
-                HCI_LE_CLEAR_RESOLVING_LIST_COMMAND
-            ):
-                await self.send_command(HCI_LE_Clear_Resolving_List_Command())  # type: ignore[call-arg]
+            if self.keystore:
+                await self.refresh_resolving_list()
 
-                resolving_keys = await self.keystore.get_resolving_keys()
-                for irk, address in resolving_keys:
-                    await self.send_command(
-                        HCI_LE_Add_Device_To_Resolving_List_Command(
-                            peer_identity_address_type=address.address_type,
-                            peer_identity_address=address,
-                            peer_irk=irk,
-                            local_irk=self.irk,
-                        )  # type: ignore[call-arg]
-                    )
-
-                # Enable address resolution
-                # await self.send_command(
-                #     HCI_LE_Set_Address_Resolution_Enable_Command(
-                #         address_resolution_enable=1)
-                #     )
-                # )
-
-                # Create a host-side address resolver
-                self.address_resolver = smp.AddressResolver(resolving_keys)
+            # Enable address resolution
+            if self.address_resolution_offload:
+                await self.send_command(
+                    HCI_LE_Set_Address_Resolution_Enable_Command(
+                        address_resolution_enable=1
+                    )  # type: ignore[call-arg]
+                )
 
         if self.classic_enabled:
             await self.send_command(
@@ -1300,6 +1328,26 @@
             await self.host.flush()
             self.powered_on = False
 
+    async def refresh_resolving_list(self) -> None:
+        assert self.keystore is not None
+
+        resolving_keys = await self.keystore.get_resolving_keys()
+        # Create a host-side address resolver
+        self.address_resolver = smp.AddressResolver(resolving_keys)
+
+        if self.address_resolution_offload:
+            await self.send_command(HCI_LE_Clear_Resolving_List_Command())  # type: ignore[call-arg]
+
+            for irk, address in resolving_keys:
+                await self.send_command(
+                    HCI_LE_Add_Device_To_Resolving_List_Command(
+                        peer_identity_address_type=address.address_type,
+                        peer_identity_address=address,
+                        peer_irk=irk,
+                        local_irk=self.irk,
+                    )  # type: ignore[call-arg]
+                )
+
     def supports_le_feature(self, feature):
         return self.host.supports_le_feature(feature)
 
@@ -2065,11 +2113,30 @@
         supervision_timeout,
         min_ce_length=0,
         max_ce_length=0,
-    ):
+        use_l2cap=False,
+    ) -> None:
         '''
         NOTE: the name of the parameters may look odd, but it just follows the names
         used in the Bluetooth spec.
         '''
+
+        if use_l2cap:
+            if connection.role != BT_PERIPHERAL_ROLE:
+                raise InvalidStateError(
+                    'only peripheral can update connection parameters with l2cap'
+                )
+            l2cap_result = (
+                await self.l2cap_channel_manager.update_connection_parameters(
+                    connection,
+                    connection_interval_min,
+                    connection_interval_max,
+                    max_latency,
+                    supervision_timeout,
+                )
+            )
+            if l2cap_result != l2cap.L2CAP_CONNECTION_PARAMETERS_ACCEPTED_RESULT:
+                raise ConnectionParameterUpdateError(l2cap_result)
+
         result = await self.send_command(
             HCI_LE_Connection_Update_Command(
                 connection_handle=connection.handle,
@@ -2079,7 +2146,7 @@
                 supervision_timeout=supervision_timeout,
                 min_ce_length=min_ce_length,
                 max_ce_length=max_ce_length,
-            )
+            )  # type: ignore[call-arg]
         )
         if result.status != HCI_Command_Status_Event.PENDING:
             raise HCI_StatusError(result)
@@ -2220,9 +2287,11 @@
     def request_pairing(self, connection):
         return self.smp_manager.request_pairing(connection)
 
-    async def get_long_term_key(self, connection_handle, rand, ediv):
+    async def get_long_term_key(
+        self, connection_handle: int, rand: bytes, ediv: int
+    ) -> Optional[bytes]:
         if (connection := self.lookup_connection(connection_handle)) is None:
-            return
+            return None
 
         # Start by looking for the key in an SMP session
         ltk = self.smp_manager.get_long_term_key(connection, rand, ediv)
@@ -2242,19 +2311,24 @@
 
                 if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
                     return keys.ltk_peripheral.value
+        return None
 
     async def get_link_key(self, address: Address) -> Optional[bytes]:
-        # Look for the key in the keystore
-        if self.keystore is not None:
-            keys = await self.keystore.get(str(address))
-            if keys is not None:
-                logger.debug('found keys in the key store')
-                if keys.link_key is None:
-                    logger.warning('no link key')
-                    return None
+        if self.keystore is None:
+            return None
 
-                return keys.link_key.value
-        return None
+        # Look for the key in the keystore
+        keys = await self.keystore.get(str(address))
+        if keys is None:
+            logger.debug(f'no keys found for {address}')
+            return None
+
+        logger.debug('found keys in the key store')
+        if keys.link_key is None:
+            logger.warning('no link key')
+            return None
+
+        return keys.link_key.value
 
     # [Classic only]
     async def authenticate(self, connection):
@@ -2373,6 +2447,18 @@
                 'connection_encryption_failure', on_encryption_failure
             )
 
+    async def update_keys(self, address: str, keys: PairingKeys) -> None:
+        if self.keystore is None:
+            return
+
+        try:
+            await self.keystore.update(address, keys)
+            await self.refresh_resolving_list()
+        except Exception as error:
+            logger.warning(f'!!! error while storing keys: {error}')
+        else:
+            self.emit('key_store_update')
+
     # [Classic only]
     async def switch_role(self, connection: Connection, role: int):
         pending_role_change = asyncio.get_running_loop().create_future()
@@ -2435,7 +2521,7 @@
 
             if result.status != HCI_COMMAND_STATUS_PENDING:
                 logger.warning(
-                    'HCI_Set_Connection_Encryption_Command failed: '
+                    'HCI_Remote_Name_Request_Command failed: '
                     f'{HCI_Constant.error_name(result.status)}'
                 )
                 raise HCI_StatusError(result)
@@ -2467,13 +2553,7 @@
                 value=link_key, authenticated=authenticated
             )
 
-            async def store_keys():
-                try:
-                    await self.keystore.update(str(bd_addr), pairing_keys)
-                except Exception as error:
-                    logger.warning(f'!!! error while storing keys: {error}')
-
-            self.abort_on('flush', store_keys())
+            self.abort_on('flush', self.update_keys(str(bd_addr), pairing_keys))
 
         if connection := self.find_connection_by_bd_addr(
             bd_addr, transport=BT_BR_EDR_TRANSPORT
@@ -2678,7 +2758,9 @@
             self.abort_on(
                 'flush',
                 self.start_advertising(
-                    advertising_type=self.advertising_type, auto_restart=True
+                    advertising_type=self.advertising_type,
+                    own_address_type=self.advertising_own_address_type,
+                    auto_restart=True,
                 ),
             )
 
@@ -2725,20 +2807,6 @@
         )
         connection.emit('connection_authentication_failure', error)
 
-    @host_event_handler
-    @with_connection_from_address
-    def on_ssp_complete(self, connection):
-        # On Secure Simple Pairing complete, in case:
-        # - Connection isn't already authenticated
-        # - AND we are not the initiator of the authentication
-        # We must trigger authentication to know if we are truly authenticated
-        if not connection.authenticating and not connection.authenticated:
-            logger.debug(
-                f'*** Trigger Connection Authentication: [0x{connection.handle:04X}] '
-                f'{connection.peer_address}'
-            )
-            asyncio.create_task(connection.authenticate())
-
     # [Classic only]
     @host_event_handler
     @with_connection_from_address
@@ -2841,18 +2909,22 @@
         method = methods[peer_io_capability][io_capability]
 
         async def reply() -> None:
-            if await connection.abort_on('disconnection', method()):
-                await self.host.send_command(
-                    HCI_User_Confirmation_Request_Reply_Command(  # type: ignore[call-arg]
-                        bd_addr=connection.peer_address
+            try:
+                if await connection.abort_on('disconnection', method()):
+                    await self.host.send_command(
+                        HCI_User_Confirmation_Request_Reply_Command(  # type: ignore[call-arg]
+                            bd_addr=connection.peer_address
+                        )
                     )
+                    return
+            except Exception as error:
+                logger.warning(f'exception while confirming: {error}')
+
+            await self.host.send_command(
+                HCI_User_Confirmation_Request_Negative_Reply_Command(  # type: ignore[call-arg]
+                    bd_addr=connection.peer_address
                 )
-            else:
-                await self.host.send_command(
-                    HCI_User_Confirmation_Request_Negative_Reply_Command(  # type: ignore[call-arg]
-                        bd_addr=connection.peer_address
-                    )
-                )
+            )
 
         AsyncRunner.spawn(reply())
 
@@ -2864,21 +2936,25 @@
         pairing_config = self.pairing_config_factory(connection)
 
         async def reply() -> None:
-            number = await connection.abort_on(
-                'disconnection', pairing_config.delegate.get_number()
+            try:
+                number = await connection.abort_on(
+                    'disconnection', pairing_config.delegate.get_number()
+                )
+                if number is not None:
+                    await self.host.send_command(
+                        HCI_User_Passkey_Request_Reply_Command(  # type: ignore[call-arg]
+                            bd_addr=connection.peer_address, numeric_value=number
+                        )
+                    )
+                    return
+            except Exception as error:
+                logger.warning(f'exception while asking for pass-key: {error}')
+
+            await self.host.send_command(
+                HCI_User_Passkey_Request_Negative_Reply_Command(  # type: ignore[call-arg]
+                    bd_addr=connection.peer_address
+                )
             )
-            if number is not None:
-                await self.host.send_command(
-                    HCI_User_Passkey_Request_Reply_Command(  # type: ignore[call-arg]
-                        bd_addr=connection.peer_address, numeric_value=number
-                    )
-                )
-            else:
-                await self.host.send_command(
-                    HCI_User_Passkey_Request_Negative_Reply_Command(  # type: ignore[call-arg]
-                        bd_addr=connection.peer_address
-                    )
-                )
 
         AsyncRunner.spawn(reply())
 
@@ -3085,10 +3161,31 @@
             connection.emit('role_change_failure', error)
         self.emit('role_change_failure', address, error)
 
+    # [Classic only]
+    @host_event_handler
+    @with_connection_from_address
+    def on_classic_pairing(self, connection: Connection) -> None:
+        connection.emit('classic_pairing')
+
+    # [Classic only]
+    @host_event_handler
+    @with_connection_from_address
+    def on_classic_pairing_failure(self, connection: Connection, status) -> None:
+        connection.emit('classic_pairing_failure', status)
+
     def on_pairing_start(self, connection: Connection) -> None:
         connection.emit('pairing_start')
 
-    def on_pairing(self, connection: Connection, keys: PairingKeys, sc: bool) -> None:
+    def on_pairing(
+        self,
+        connection: Connection,
+        identity_address: Optional[Address],
+        keys: PairingKeys,
+        sc: bool,
+    ) -> None:
+        if identity_address is not None:
+            connection.peer_resolvable_address = connection.peer_address
+            connection.peer_address = identity_address
         connection.sc = sc
         connection.authenticated = True
         connection.emit('pairing', keys)
@@ -3124,7 +3221,7 @@
 
     @host_event_handler
     @with_connection_from_handle
-    def on_l2cap_pdu(self, connection, cid, pdu):
+    def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes):
         self.l2cap_channel_manager.on_pdu(connection, cid, pdu)
 
     def __str__(self):
diff --git a/bumble/drivers/__init__.py b/bumble/drivers/__init__.py
new file mode 100644
index 0000000..d8ea06e
--- /dev/null
+++ b/bumble/drivers/__init__.py
@@ -0,0 +1,91 @@
+# 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.
+"""
+Drivers that can be used to customize the interaction between a host and a controller,
+like loading firmware after a cold start.
+"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import abc
+import logging
+import pathlib
+import platform
+from . import rtk
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
+class Driver(abc.ABC):
+    """Base class for drivers."""
+
+    @staticmethod
+    async def for_host(_host):
+        """Return a driver instance for a host.
+
+        Args:
+            host: Host object for which a driver should be created.
+
+        Returns:
+            A Driver instance if a driver should be instantiated for this host, or
+            None if no driver instance of this class is needed.
+        """
+        return None
+
+    @abc.abstractmethod
+    async def init_controller(self):
+        """Initialize the controller."""
+
+
+# -----------------------------------------------------------------------------
+# Functions
+# -----------------------------------------------------------------------------
+async def get_driver_for_host(host):
+    """Probe all known diver classes until one returns a valid instance for a host,
+    or none is found.
+    """
+    if driver := await rtk.Driver.for_host(host):
+        logger.debug("Instantiated RTK driver")
+        return driver
+
+    return None
+
+
+def project_data_dir() -> pathlib.Path:
+    """
+    Returns:
+        A path to an OS-specific directory for bumble data. The directory is created if
+         it doesn't exist.
+    """
+    import platformdirs
+
+    if platform.system() == 'Darwin':
+        # platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
+        # out of author & project
+        return platformdirs.user_data_path(
+            appname='com.google.bumble', ensure_exists=True
+        )
+    else:
+        # windows and linux don't use the com qualifier
+        return platformdirs.user_data_path(
+            appname='bumble', appauthor='google', ensure_exists=True
+        )
diff --git a/bumble/drivers/rtk.py b/bumble/drivers/rtk.py
new file mode 100644
index 0000000..f78a14d
--- /dev/null
+++ b/bumble/drivers/rtk.py
@@ -0,0 +1,659 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Support for Realtek USB dongles.
+Based on various online bits of information, including the Linux kernel.
+(see `drivers/bluetooth/btrtl.c`)
+"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from dataclasses import dataclass
+import asyncio
+import enum
+import logging
+import math
+import os
+import pathlib
+import platform
+import struct
+from typing import Tuple
+import weakref
+
+
+from bumble.hci import (
+    hci_vendor_command_op_code,
+    STATUS_SPEC,
+    HCI_SUCCESS,
+    HCI_Command,
+    HCI_Reset_Command,
+    HCI_Read_Local_Version_Information_Command,
+)
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+RTK_ROM_LMP_8723A = 0x1200
+RTK_ROM_LMP_8723B = 0x8723
+RTK_ROM_LMP_8821A = 0x8821
+RTK_ROM_LMP_8761A = 0x8761
+RTK_ROM_LMP_8822B = 0x8822
+RTK_ROM_LMP_8852A = 0x8852
+RTK_CONFIG_MAGIC = 0x8723AB55
+
+RTK_EPATCH_SIGNATURE = b"Realtech"
+
+RTK_FRAGMENT_LENGTH = 252
+
+RTK_FIRMWARE_DIR_ENV = "BUMBLE_RTK_FIRMWARE_DIR"
+RTK_LINUX_FIRMWARE_DIR = "/lib/firmware/rtl_bt"
+
+
+class RtlProjectId(enum.IntEnum):
+    PROJECT_ID_8723A = 0
+    PROJECT_ID_8723B = 1
+    PROJECT_ID_8821A = 2
+    PROJECT_ID_8761A = 3
+    PROJECT_ID_8822B = 8
+    PROJECT_ID_8723D = 9
+    PROJECT_ID_8821C = 10
+    PROJECT_ID_8822C = 13
+    PROJECT_ID_8761B = 14
+    PROJECT_ID_8852A = 18
+    PROJECT_ID_8852B = 20
+    PROJECT_ID_8852C = 25
+
+
+RTK_PROJECT_ID_TO_ROM = {
+    0: RTK_ROM_LMP_8723A,
+    1: RTK_ROM_LMP_8723B,
+    2: RTK_ROM_LMP_8821A,
+    3: RTK_ROM_LMP_8761A,
+    8: RTK_ROM_LMP_8822B,
+    9: RTK_ROM_LMP_8723B,
+    10: RTK_ROM_LMP_8821A,
+    13: RTK_ROM_LMP_8822B,
+    14: RTK_ROM_LMP_8761A,
+    18: RTK_ROM_LMP_8852A,
+    20: RTK_ROM_LMP_8852A,
+    25: RTK_ROM_LMP_8852A,
+}
+
+# List of USB (VendorID, ProductID) for Realtek-based devices.
+RTK_USB_PRODUCTS = {
+    # Realtek 8723AE
+    (0x0930, 0x021D),
+    (0x13D3, 0x3394),
+    # Realtek 8723BE
+    (0x0489, 0xE085),
+    (0x0489, 0xE08B),
+    (0x04F2, 0xB49F),
+    (0x13D3, 0x3410),
+    (0x13D3, 0x3416),
+    (0x13D3, 0x3459),
+    (0x13D3, 0x3494),
+    # Realtek 8723BU
+    (0x7392, 0xA611),
+    # Realtek 8723DE
+    (0x0BDA, 0xB009),
+    (0x2FF8, 0xB011),
+    # Realtek 8761BUV
+    (0x0B05, 0x190E),
+    (0x0BDA, 0x8771),
+    (0x2230, 0x0016),
+    (0x2357, 0x0604),
+    (0x2550, 0x8761),
+    (0x2B89, 0x8761),
+    (0x7392, 0xC611),
+    (0x0BDA, 0x877B),
+    # Realtek 8821AE
+    (0x0B05, 0x17DC),
+    (0x13D3, 0x3414),
+    (0x13D3, 0x3458),
+    (0x13D3, 0x3461),
+    (0x13D3, 0x3462),
+    # Realtek 8821CE
+    (0x0BDA, 0xB00C),
+    (0x0BDA, 0xC822),
+    (0x13D3, 0x3529),
+    # Realtek 8822BE
+    (0x0B05, 0x185C),
+    (0x13D3, 0x3526),
+    # Realtek 8822CE
+    (0x04C5, 0x161F),
+    (0x04CA, 0x4005),
+    (0x0B05, 0x18EF),
+    (0x0BDA, 0xB00C),
+    (0x0BDA, 0xC123),
+    (0x0BDA, 0xC822),
+    (0x0CB5, 0xC547),
+    (0x1358, 0xC123),
+    (0x13D3, 0x3548),
+    (0x13D3, 0x3549),
+    (0x13D3, 0x3553),
+    (0x13D3, 0x3555),
+    (0x2FF8, 0x3051),
+    # Realtek 8822CU
+    (0x13D3, 0x3549),
+    # Realtek 8852AE
+    (0x04C5, 0x165C),
+    (0x04CA, 0x4006),
+    (0x0BDA, 0x2852),
+    (0x0BDA, 0x385A),
+    (0x0BDA, 0x4852),
+    (0x0BDA, 0xC852),
+    (0x0CB8, 0xC549),
+    # Realtek 8852BE
+    (0x0BDA, 0x887B),
+    (0x0CB8, 0xC559),
+    (0x13D3, 0x3571),
+    # Realtek 8852CE
+    (0x04C5, 0x1675),
+    (0x04CA, 0x4007),
+    (0x0CB8, 0xC558),
+    (0x13D3, 0x3586),
+    (0x13D3, 0x3587),
+    (0x13D3, 0x3592),
+}
+
+# -----------------------------------------------------------------------------
+# HCI Commands
+# -----------------------------------------------------------------------------
+HCI_RTK_READ_ROM_VERSION_COMMAND = hci_vendor_command_op_code(0x6D)
+HCI_RTK_DOWNLOAD_COMMAND = hci_vendor_command_op_code(0x20)
+HCI_RTK_DROP_FIRMWARE_COMMAND = hci_vendor_command_op_code(0x66)
+HCI_Command.register_commands(globals())
+
+
+@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
+class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
+    pass
+
+
+@HCI_Command.command(
+    fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
+    return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
+)
+class HCI_RTK_Download_Command(HCI_Command):
+    pass
+
+
+@HCI_Command.command()
+class HCI_RTK_Drop_Firmware_Command(HCI_Command):
+    pass
+
+
+# -----------------------------------------------------------------------------
+class Firmware:
+    def __init__(self, firmware):
+        extension_sig = bytes([0x51, 0x04, 0xFD, 0x77])
+
+        if not firmware.startswith(RTK_EPATCH_SIGNATURE):
+            raise ValueError("Firmware does not start with epatch signature")
+
+        if not firmware.endswith(extension_sig):
+            raise ValueError("Firmware does not end with extension sig")
+
+        # The firmware should start with a 14 byte header.
+        epatch_header_size = 14
+        if len(firmware) < epatch_header_size:
+            raise ValueError("Firmware too short")
+
+        # Look for the "project ID", starting from the end.
+        offset = len(firmware) - len(extension_sig)
+        project_id = -1
+        while offset >= epatch_header_size:
+            length, opcode = firmware[offset - 2 : offset]
+            offset -= 2
+
+            if opcode == 0xFF:
+                # End
+                break
+
+            if length == 0:
+                raise ValueError("Invalid 0-length instruction")
+
+            if opcode == 0 and length == 1:
+                project_id = firmware[offset - 1]
+                break
+
+            offset -= length
+
+        if project_id < 0:
+            raise ValueError("Project ID not found")
+
+        self.project_id = project_id
+
+        # Read the patch tables info.
+        self.version, num_patches = struct.unpack("<IH", firmware[8:14])
+        self.patches = []
+
+        # The patches tables are laid out as:
+        # <ChipID_1><ChipID_2>...<ChipID_N>  (16 bits each)
+        # <PatchLength_1><PatchLength_2>...<PatchLength_N> (16 bits each)
+        # <PatchOffset_1><PatchOffset_2>...<PatchOffset_N> (32 bits each)
+        if epatch_header_size + 8 * num_patches > len(firmware):
+            raise ValueError("Firmware too short")
+        chip_id_table_offset = epatch_header_size
+        patch_length_table_offset = chip_id_table_offset + 2 * num_patches
+        patch_offset_table_offset = chip_id_table_offset + 4 * num_patches
+        for patch_index in range(num_patches):
+            chip_id_offset = chip_id_table_offset + 2 * patch_index
+            (chip_id,) = struct.unpack_from("<H", firmware, chip_id_offset)
+            (patch_length,) = struct.unpack_from(
+                "<H", firmware, patch_length_table_offset + 2 * patch_index
+            )
+            (patch_offset,) = struct.unpack_from(
+                "<I", firmware, patch_offset_table_offset + 4 * patch_index
+            )
+            if patch_offset + patch_length > len(firmware):
+                raise ValueError("Firmware too short")
+
+            # Get the SVN version for the patch
+            (svn_version,) = struct.unpack_from(
+                "<I", firmware, patch_offset + patch_length - 8
+            )
+
+            # Create a payload with the patch, replacing the last 4 bytes with
+            # the firmware version.
+            self.patches.append(
+                (
+                    chip_id,
+                    firmware[patch_offset : patch_offset + patch_length - 4]
+                    + struct.pack("<I", self.version),
+                    svn_version,
+                )
+            )
+
+
+class Driver:
+    @dataclass
+    class DriverInfo:
+        rom: int
+        hci: Tuple[int, int]
+        config_needed: bool
+        has_rom_version: bool
+        has_msft_ext: bool = False
+        fw_name: str = ""
+        config_name: str = ""
+
+    DRIVER_INFOS = [
+        # 8723A
+        DriverInfo(
+            rom=RTK_ROM_LMP_8723A,
+            hci=(0x0B, 0x06),
+            config_needed=False,
+            has_rom_version=False,
+            fw_name="rtl8723a_fw.bin",
+            config_name="",
+        ),
+        # 8723B
+        DriverInfo(
+            rom=RTK_ROM_LMP_8723B,
+            hci=(0x0B, 0x06),
+            config_needed=False,
+            has_rom_version=True,
+            fw_name="rtl8723b_fw.bin",
+            config_name="rtl8723b_config.bin",
+        ),
+        # 8723D
+        DriverInfo(
+            rom=RTK_ROM_LMP_8723B,
+            hci=(0x0D, 0x08),
+            config_needed=True,
+            has_rom_version=True,
+            fw_name="rtl8723d_fw.bin",
+            config_name="rtl8723d_config.bin",
+        ),
+        # 8821A
+        DriverInfo(
+            rom=RTK_ROM_LMP_8821A,
+            hci=(0x0A, 0x06),
+            config_needed=False,
+            has_rom_version=True,
+            fw_name="rtl8821a_fw.bin",
+            config_name="rtl8821a_config.bin",
+        ),
+        # 8821C
+        DriverInfo(
+            rom=RTK_ROM_LMP_8821A,
+            hci=(0x0C, 0x08),
+            config_needed=False,
+            has_rom_version=True,
+            has_msft_ext=True,
+            fw_name="rtl8821c_fw.bin",
+            config_name="rtl8821c_config.bin",
+        ),
+        # 8761A
+        DriverInfo(
+            rom=RTK_ROM_LMP_8761A,
+            hci=(0x0A, 0x06),
+            config_needed=False,
+            has_rom_version=True,
+            fw_name="rtl8761a_fw.bin",
+            config_name="rtl8761a_config.bin",
+        ),
+        # 8761BU
+        DriverInfo(
+            rom=RTK_ROM_LMP_8761A,
+            hci=(0x0B, 0x0A),
+            config_needed=False,
+            has_rom_version=True,
+            fw_name="rtl8761bu_fw.bin",
+            config_name="rtl8761bu_config.bin",
+        ),
+        # 8822C
+        DriverInfo(
+            rom=RTK_ROM_LMP_8822B,
+            hci=(0x0C, 0x0A),
+            config_needed=False,
+            has_rom_version=True,
+            has_msft_ext=True,
+            fw_name="rtl8822cu_fw.bin",
+            config_name="rtl8822cu_config.bin",
+        ),
+        # 8822B
+        DriverInfo(
+            rom=RTK_ROM_LMP_8822B,
+            hci=(0x0B, 0x07),
+            config_needed=True,
+            has_rom_version=True,
+            has_msft_ext=True,
+            fw_name="rtl8822b_fw.bin",
+            config_name="rtl8822b_config.bin",
+        ),
+        # 8852A
+        DriverInfo(
+            rom=RTK_ROM_LMP_8852A,
+            hci=(0x0A, 0x0B),
+            config_needed=False,
+            has_rom_version=True,
+            has_msft_ext=True,
+            fw_name="rtl8852au_fw.bin",
+            config_name="rtl8852au_config.bin",
+        ),
+        # 8852B
+        DriverInfo(
+            rom=RTK_ROM_LMP_8852A,
+            hci=(0xB, 0xB),
+            config_needed=False,
+            has_rom_version=True,
+            has_msft_ext=True,
+            fw_name="rtl8852bu_fw.bin",
+            config_name="rtl8852bu_config.bin",
+        ),
+        # 8852C
+        DriverInfo(
+            rom=RTK_ROM_LMP_8852A,
+            hci=(0x0C, 0x0C),
+            config_needed=False,
+            has_rom_version=True,
+            has_msft_ext=True,
+            fw_name="rtl8852cu_fw.bin",
+            config_name="rtl8852cu_config.bin",
+        ),
+    ]
+
+    POST_DROP_DELAY = 0.2
+
+    @staticmethod
+    def find_driver_info(hci_version, hci_subversion, lmp_subversion):
+        for driver_info in Driver.DRIVER_INFOS:
+            if driver_info.rom == lmp_subversion and driver_info.hci == (
+                hci_subversion,
+                hci_version,
+            ):
+                return driver_info
+
+        return None
+
+    @staticmethod
+    def find_binary_path(file_name):
+        # First check if an environment variable is set
+        if RTK_FIRMWARE_DIR_ENV in os.environ:
+            if (
+                path := pathlib.Path(os.environ[RTK_FIRMWARE_DIR_ENV]) / file_name
+            ).is_file():
+                logger.debug(f"{file_name} found in env dir")
+                return path
+
+            # When the environment variable is set, don't look elsewhere
+            return None
+
+        # Then, look where the firmware download tool writes by default
+        if (path := rtk_firmware_dir() / file_name).is_file():
+            logger.debug(f"{file_name} found in project data dir")
+            return path
+
+        # Then, look in the package's driver directory
+        if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
+            logger.debug(f"{file_name} found in package dir")
+            return path
+
+        # On Linux, check the system's FW directory
+        if (
+            platform.system() == "Linux"
+            and (path := pathlib.Path(RTK_LINUX_FIRMWARE_DIR) / file_name).is_file()
+        ):
+            logger.debug(f"{file_name} found in Linux system FW dir")
+            return path
+
+        # Finally look in the current directory
+        if (path := pathlib.Path.cwd() / file_name).is_file():
+            logger.debug(f"{file_name} found in CWD")
+            return path
+
+        return None
+
+    @staticmethod
+    def check(host):
+        if not host.hci_metadata:
+            logger.debug("USB metadata not found")
+            return False
+
+        vendor_id = host.hci_metadata.get("vendor_id", None)
+        product_id = host.hci_metadata.get("product_id", None)
+        if vendor_id is None or product_id is None:
+            logger.debug("USB metadata not sufficient")
+            return False
+
+        if (vendor_id, product_id) not in RTK_USB_PRODUCTS:
+            logger.debug(
+                f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
+            )
+            return False
+
+        return True
+
+    @classmethod
+    async def driver_info_for_host(cls, host):
+        response = await host.send_command(
+            HCI_Read_Local_Version_Information_Command(), check_result=True
+        )
+        local_version = response.return_parameters
+
+        logger.debug(
+            f"looking for a driver: 0x{local_version.lmp_subversion:04X} "
+            f"(0x{local_version.hci_version:02X}, "
+            f"0x{local_version.hci_subversion:04X})"
+        )
+
+        driver_info = cls.find_driver_info(
+            local_version.hci_version,
+            local_version.hci_subversion,
+            local_version.lmp_subversion,
+        )
+        if driver_info is None:
+            # TODO: it seems that the Linux driver will send command (0x3f, 0x66)
+            # in this case and then re-read the local version, then re-match.
+            logger.debug("firmware already loaded or no known driver for this device")
+
+        return driver_info
+
+    @classmethod
+    async def for_host(cls, host, force=False):
+        # Check that a driver is needed for this host
+        if not force and not cls.check(host):
+            return None
+
+        # Get the driver info
+        driver_info = await cls.driver_info_for_host(host)
+        if driver_info is None:
+            return None
+
+        # Load the firmware
+        firmware_path = cls.find_binary_path(driver_info.fw_name)
+        if not firmware_path:
+            logger.warning(f"Firmware file {driver_info.fw_name} not found")
+            logger.warning("See https://google.github.io/bumble/drivers/realtek.html")
+            return None
+        with open(firmware_path, "rb") as firmware_file:
+            firmware = firmware_file.read()
+
+        # Load the config
+        config = None
+        if driver_info.config_name:
+            config_path = cls.find_binary_path(driver_info.config_name)
+            if config_path:
+                with open(config_path, "rb") as config_file:
+                    config = config_file.read()
+        if driver_info.config_needed and not config:
+            logger.warning("Config needed, but no config file available")
+            return None
+
+        return cls(host, driver_info, firmware, config)
+
+    def __init__(self, host, driver_info, firmware, config):
+        self.host = weakref.proxy(host)
+        self.driver_info = driver_info
+        self.firmware = firmware
+        self.config = config
+
+    @staticmethod
+    async def drop_firmware(host):
+        host.send_hci_packet(HCI_RTK_Drop_Firmware_Command())
+
+        # Wait for the command to be effective (no response is sent)
+        await asyncio.sleep(Driver.POST_DROP_DELAY)
+
+    async def download_for_rtl8723a(self):
+        # Check that the firmware image does not include an epatch signature.
+        if RTK_EPATCH_SIGNATURE in self.firmware:
+            logger.warning(
+                "epatch signature found in firmware, it is probably the wrong firmware"
+            )
+            return
+
+        # TODO: load the firmware
+
+    async def download_for_rtl8723b(self):
+        if self.driver_info.has_rom_version:
+            response = await self.host.send_command(
+                HCI_RTK_Read_ROM_Version_Command(), check_result=True
+            )
+            if response.return_parameters.status != HCI_SUCCESS:
+                logger.warning("can't get ROM version")
+                return
+            rom_version = response.return_parameters.version
+            logger.debug(f"ROM version before download: {rom_version:04X}")
+        else:
+            rom_version = 0
+
+        firmware = Firmware(self.firmware)
+        logger.debug(f"firmware: project_id=0x{firmware.project_id:04X}")
+        for patch in firmware.patches:
+            if patch[0] == rom_version + 1:
+                logger.debug(f"using patch {patch[0]}")
+                break
+        else:
+            logger.warning("no valid patch found for rom version {rom_version}")
+            return
+
+        # Append the config if there is one.
+        if self.config:
+            payload = patch[1] + self.config
+        else:
+            payload = patch[1]
+
+        # Download the payload, one fragment at a time.
+        fragment_count = math.ceil(len(payload) / RTK_FRAGMENT_LENGTH)
+        for fragment_index in range(fragment_count):
+            # NOTE: the Linux driver somehow adds 1 to the index after it wraps around.
+            # That's odd, but we"ll do the same here.
+            download_index = fragment_index & 0x7F
+            if download_index >= 0x80:
+                download_index += 1
+            if fragment_index == fragment_count - 1:
+                download_index |= 0x80  # End marker.
+            fragment_offset = fragment_index * RTK_FRAGMENT_LENGTH
+            fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
+            logger.debug(f"downloading fragment {fragment_index}")
+            await self.host.send_command(
+                HCI_RTK_Download_Command(
+                    index=download_index, payload=fragment, check_result=True
+                )
+            )
+
+        logger.debug("download complete!")
+
+        # Read the version again
+        response = await self.host.send_command(
+            HCI_RTK_Read_ROM_Version_Command(), check_result=True
+        )
+        if response.return_parameters.status != HCI_SUCCESS:
+            logger.warning("can't get ROM version")
+        else:
+            rom_version = response.return_parameters.version
+            logger.debug(f"ROM version after download: {rom_version:04X}")
+
+    async def download_firmware(self):
+        if self.driver_info.rom == RTK_ROM_LMP_8723A:
+            return await self.download_for_rtl8723a()
+
+        if self.driver_info.rom in (
+            RTK_ROM_LMP_8723B,
+            RTK_ROM_LMP_8821A,
+            RTK_ROM_LMP_8761A,
+            RTK_ROM_LMP_8822B,
+            RTK_ROM_LMP_8852A,
+        ):
+            return await self.download_for_rtl8723b()
+
+        raise ValueError("ROM not supported")
+
+    async def init_controller(self):
+        await self.download_firmware()
+        await self.host.send_command(HCI_Reset_Command(), check_result=True)
+        logger.info(f"loaded FW image {self.driver_info.fw_name}")
+
+
+def rtk_firmware_dir() -> pathlib.Path:
+    """
+    Returns:
+        A path to a subdir of the project data dir for Realtek firmware.
+         The directory is created if it doesn't exist.
+    """
+    from bumble.drivers import project_data_dir
+
+    p = project_data_dir() / "firmware" / "realtek"
+    p.mkdir(parents=True, exist_ok=True)
+    return p
diff --git a/bumble/gatt.py b/bumble/gatt.py
index ea2b690..fe3e85c 100644
--- a/bumble/gatt.py
+++ b/bumble/gatt.py
@@ -28,7 +28,7 @@
 import functools
 import logging
 import struct
-from typing import Optional, Sequence, List
+from typing import Optional, Sequence, Iterable, List, Union
 
 from .colors import color
 from .core import UUID, get_dict_key_by_value
@@ -187,7 +187,7 @@
 # -----------------------------------------------------------------------------
 
 
-def show_services(services):
+def show_services(services: Iterable[Service]) -> None:
     for service in services:
         print(color(str(service), 'cyan'))
 
@@ -210,11 +210,11 @@
 
     def __init__(
         self,
-        uuid,
+        uuid: Union[str, UUID],
         characteristics: List[Characteristic],
         primary=True,
         included_services: List[Service] = [],
-    ):
+    ) -> None:
         # Convert the uuid to a UUID object if it isn't already
         if isinstance(uuid, str):
             uuid = UUID(uuid)
@@ -239,7 +239,7 @@
         """
         return None
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'Service(handle=0x{self.handle:04X}, '
             f'end=0x{self.end_group_handle:04X}, '
@@ -255,9 +255,11 @@
     to expose their UUID as a class property
     '''
 
-    UUID: Optional[UUID] = None
+    UUID: UUID
 
-    def __init__(self, characteristics, primary=True):
+    def __init__(
+        self, characteristics: List[Characteristic], primary: bool = True
+    ) -> None:
         super().__init__(self.UUID, characteristics, primary)
 
 
@@ -269,7 +271,7 @@
 
     service: Service
 
-    def __init__(self, service):
+    def __init__(self, service: Service) -> None:
         declaration_bytes = struct.pack(
             '<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
         )
@@ -278,13 +280,12 @@
         )
         self.service = service
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
             f'group_starting_handle=0x{self.service.handle:04X}, '
             f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
-            f'uuid={self.service.uuid}, '
-            f'{self.service.properties!s})'
+            f'uuid={self.service.uuid})'
         )
 
 
@@ -309,31 +310,33 @@
         AUTHENTICATED_SIGNED_WRITES = 0x40
         EXTENDED_PROPERTIES = 0x80
 
-        @staticmethod
-        def from_string(properties_str: str) -> Characteristic.Properties:
-            property_names: List[str] = []
-            for property in Characteristic.Properties:
-                if property.name is None:
-                    raise TypeError()
-                property_names.append(property.name)
-
-            def string_to_property(property_string) -> Characteristic.Properties:
-                for property in zip(Characteristic.Properties, property_names):
-                    if property_string == property[1]:
-                        return property[0]
-                raise TypeError(f"Unable to convert {property_string} to Property")
-
+        @classmethod
+        def from_string(cls, properties_str: str) -> Characteristic.Properties:
             try:
                 return functools.reduce(
-                    lambda x, y: x | string_to_property(y),
-                    properties_str.split(","),
+                    lambda x, y: x | cls[y],
+                    properties_str.replace("|", ",").split(","),
                     Characteristic.Properties(0),
                 )
-            except TypeError:
+            except (TypeError, KeyError):
+                # The check for `p.name is not None` here is needed because for InFlag
+                # enums, the .name property can be None, when the enum value is 0,
+                # so the type hint for .name is Optional[str].
+                enum_list: List[str] = [p.name for p in cls if p.name is not None]
+                enum_list_str = ",".join(enum_list)
                 raise TypeError(
-                    f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\nGot: {properties_str}"
+                    f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}"
                 )
 
+        def __str__(self) -> str:
+            # NOTE: we override this method to offer a consistent result between python
+            # versions: the value returned by IntFlag.__str__() changed in version 11.
+            return '|'.join(
+                flag.name
+                for flag in Characteristic.Properties
+                if self.value & flag.value and flag.name is not None
+            )
+
     # For backwards compatibility these are defined here
     # For new code, please use Characteristic.Properties.X
     BROADCAST = Properties.BROADCAST
@@ -347,10 +350,10 @@
 
     def __init__(
         self,
-        uuid,
+        uuid: Union[str, bytes, UUID],
         properties: Characteristic.Properties,
-        permissions,
-        value=b'',
+        permissions: Union[str, Attribute.Permissions],
+        value: Union[str, bytes, CharacteristicValue] = b'',
         descriptors: Sequence[Descriptor] = (),
     ):
         super().__init__(uuid, permissions, value)
@@ -368,12 +371,12 @@
     def has_properties(self, properties: Characteristic.Properties) -> bool:
         return self.properties & properties == properties
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'Characteristic(handle=0x{self.handle:04X}, '
             f'end=0x{self.end_group_handle:04X}, '
             f'uuid={self.uuid}, '
-            f'{self.properties!s})'
+            f'{self.properties})'
         )
 
 
@@ -385,7 +388,7 @@
 
     characteristic: Characteristic
 
-    def __init__(self, characteristic, value_handle):
+    def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
         declaration_bytes = (
             struct.pack('<BH', characteristic.properties, value_handle)
             + characteristic.uuid.to_pdu_bytes()
@@ -396,12 +399,12 @@
         self.value_handle = value_handle
         self.characteristic = characteristic
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
             f'value_handle=0x{self.value_handle:04X}, '
             f'uuid={self.characteristic.uuid}, '
-            f'{self.characteristic.properties!s})'
+            f'{self.characteristic.properties})'
         )
 
 
@@ -519,7 +522,7 @@
 
         return self.wrapped_characteristic.unsubscribe(subscriber)
 
-    def __str__(self):
+    def __str__(self) -> str:
         wrapped = str(self.wrapped_characteristic)
         return f'{self.__class__.__name__}({wrapped})'
 
@@ -599,10 +602,10 @@
     Adapter that converts strings to/from bytes using UTF-8 encoding
     '''
 
-    def encode_value(self, value):
+    def encode_value(self, value: str) -> bytes:
         return value.encode('utf-8')
 
-    def decode_value(self, value):
+    def decode_value(self, value: bytes) -> str:
         return value.decode('utf-8')
 
 
@@ -612,7 +615,7 @@
     See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
     '''
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'Descriptor(handle=0x{self.handle:04X}, '
             f'type={self.type}, '
diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py
index a33039e..e3b8bb2 100644
--- a/bumble/gatt_client.py
+++ b/bumble/gatt_client.py
@@ -28,7 +28,18 @@
 import logging
 import struct
 from datetime import datetime
-from typing import List, Optional, Dict, Tuple, Callable, Union, Any
+from typing import (
+    List,
+    Optional,
+    Dict,
+    Tuple,
+    Callable,
+    Union,
+    Any,
+    Iterable,
+    Type,
+    TYPE_CHECKING,
+)
 
 from pyee import EventEmitter
 
@@ -66,8 +77,12 @@
     GATT_INCLUDE_ATTRIBUTE_TYPE,
     Characteristic,
     ClientCharacteristicConfigurationBits,
+    TemplateService,
 )
 
+if TYPE_CHECKING:
+    from bumble.device import Connection
+
 # -----------------------------------------------------------------------------
 # Logging
 # -----------------------------------------------------------------------------
@@ -78,16 +93,16 @@
 # Proxies
 # -----------------------------------------------------------------------------
 class AttributeProxy(EventEmitter):
-    client: Client
-
-    def __init__(self, client, handle, end_group_handle, attribute_type):
+    def __init__(
+        self, client: Client, handle: int, end_group_handle: int, attribute_type: UUID
+    ) -> None:
         EventEmitter.__init__(self)
         self.client = client
         self.handle = handle
         self.end_group_handle = end_group_handle
         self.type = attribute_type
 
-    async def read_value(self, no_long_read=False):
+    async def read_value(self, no_long_read: bool = False) -> bytes:
         return self.decode_value(
             await self.client.read_value(self.handle, no_long_read)
         )
@@ -97,13 +112,13 @@
             self.handle, self.encode_value(value), with_response
         )
 
-    def encode_value(self, value):
+    def encode_value(self, value: Any) -> bytes:
         return value
 
-    def decode_value(self, value_bytes):
+    def decode_value(self, value_bytes: bytes) -> Any:
         return value_bytes
 
-    def __str__(self):
+    def __str__(self) -> str:
         return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
 
 
@@ -136,14 +151,14 @@
     def get_characteristics_by_uuid(self, uuid):
         return self.client.get_characteristics_by_uuid(uuid, self)
 
-    def __str__(self):
+    def __str__(self) -> str:
         return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'
 
 
 class CharacteristicProxy(AttributeProxy):
     properties: Characteristic.Properties
     descriptors: List[DescriptorProxy]
-    subscribers: Dict[Any, Callable]
+    subscribers: Dict[Any, Callable[[bytes], Any]]
 
     def __init__(
         self,
@@ -171,7 +186,9 @@
         return await self.client.discover_descriptors(self)
 
     async def subscribe(
-        self, subscriber: Optional[Callable] = None, prefer_notify=True
+        self,
+        subscriber: Optional[Callable[[bytes], Any]] = None,
+        prefer_notify: bool = True,
     ):
         if subscriber is not None:
             if subscriber in self.subscribers:
@@ -195,7 +212,7 @@
 
         return await self.client.unsubscribe(self, subscriber)
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'Characteristic(handle=0x{self.handle:04X}, '
             f'uuid={self.uuid}, '
@@ -207,7 +224,7 @@
     def __init__(self, client, handle, descriptor_type):
         super().__init__(client, handle, 0, descriptor_type)
 
-    def __str__(self):
+    def __str__(self) -> str:
         return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})'
 
 
@@ -216,8 +233,10 @@
     Base class for profile-specific service proxies
     '''
 
+    SERVICE_CLASS: Type[TemplateService]
+
     @classmethod
-    def from_client(cls, client):
+    def from_client(cls, client: Client) -> ProfileServiceProxy:
         return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
 
 
@@ -227,8 +246,12 @@
 class Client:
     services: List[ServiceProxy]
     cached_values: Dict[int, Tuple[datetime, bytes]]
+    notification_subscribers: Dict[int, Callable[[bytes], Any]]
+    indication_subscribers: Dict[int, Callable[[bytes], Any]]
+    pending_response: Optional[asyncio.futures.Future[ATT_PDU]]
+    pending_request: Optional[ATT_PDU]
 
-    def __init__(self, connection):
+    def __init__(self, connection: Connection) -> None:
         self.connection = connection
         self.mtu_exchange_done = False
         self.request_semaphore = asyncio.Semaphore(1)
@@ -241,16 +264,16 @@
         self.services = []
         self.cached_values = {}
 
-    def send_gatt_pdu(self, pdu):
+    def send_gatt_pdu(self, pdu: bytes) -> None:
         self.connection.send_l2cap_pdu(ATT_CID, pdu)
 
-    async def send_command(self, command):
+    async def send_command(self, command: ATT_PDU) -> None:
         logger.debug(
             f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
         )
         self.send_gatt_pdu(command.to_bytes())
 
-    async def send_request(self, request):
+    async def send_request(self, request: ATT_PDU):
         logger.debug(
             f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
         )
@@ -279,14 +302,14 @@
 
         return response
 
-    def send_confirmation(self, confirmation):
+    def send_confirmation(self, confirmation: ATT_Handle_Value_Confirmation) -> None:
         logger.debug(
             f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
             f'{confirmation}'
         )
         self.send_gatt_pdu(confirmation.to_bytes())
 
-    async def request_mtu(self, mtu):
+    async def request_mtu(self, mtu: int) -> int:
         # Check the range
         if mtu < ATT_DEFAULT_MTU:
             raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
@@ -313,10 +336,12 @@
 
         return self.connection.att_mtu
 
-    def get_services_by_uuid(self, uuid):
+    def get_services_by_uuid(self, uuid: UUID) -> List[ServiceProxy]:
         return [service for service in self.services if service.uuid == uuid]
 
-    def get_characteristics_by_uuid(self, uuid, service=None):
+    def get_characteristics_by_uuid(
+        self, uuid: UUID, service: Optional[ServiceProxy] = None
+    ) -> List[CharacteristicProxy]:
         services = [service] if service else self.services
         return [
             c
@@ -363,7 +388,7 @@
         if not already_known:
             self.services.append(service)
 
-    async def discover_services(self, uuids=None) -> List[ServiceProxy]:
+    async def discover_services(self, uuids: Iterable[UUID] = []) -> List[ServiceProxy]:
         '''
         See Vol 3, Part G - 4.4.1 Discover All Primary Services
         '''
@@ -435,7 +460,7 @@
 
         return services
 
-    async def discover_service(self, uuid):
+    async def discover_service(self, uuid: Union[str, UUID]) -> List[ServiceProxy]:
         '''
         See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
         '''
@@ -468,7 +493,7 @@
                         f'{HCI_Constant.error_name(response.error_code)}'
                     )
                     # TODO raise appropriate exception
-                    return
+                    return []
                 break
 
             for attribute_handle, end_group_handle in response.handles_information:
@@ -480,7 +505,7 @@
                     logger.warning(
                         f'bogus handle values: {attribute_handle} {end_group_handle}'
                     )
-                    return
+                    return []
 
                 # Create a service proxy for this service
                 service = ServiceProxy(
@@ -721,7 +746,7 @@
 
         return descriptors
 
-    async def discover_attributes(self):
+    async def discover_attributes(self) -> List[AttributeProxy]:
         '''
         Discover all attributes, regardless of type
         '''
@@ -844,7 +869,9 @@
             # 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):
+    async def read_value(
+        self, attribute: Union[int, AttributeProxy], no_long_read: bool = False
+    ) -> Any:
         '''
         See Vol 3, Part G - 4.8.1 Read Characteristic Value
 
@@ -905,7 +932,9 @@
         # Return the value as bytes
         return attribute_value
 
-    async def read_characteristics_by_uuid(self, uuid, service):
+    async def read_characteristics_by_uuid(
+        self, uuid: UUID, service: Optional[ServiceProxy]
+    ) -> List[bytes]:
         '''
         See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
         '''
@@ -960,7 +989,12 @@
 
         return characteristics_values
 
-    async def write_value(self, attribute, value, with_response=False):
+    async def write_value(
+        self,
+        attribute: Union[int, AttributeProxy],
+        value: bytes,
+        with_response: bool = False,
+    ) -> None:
         '''
         See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic
         Value
@@ -990,7 +1024,7 @@
                 )
             )
 
-    def on_gatt_pdu(self, att_pdu):
+    def on_gatt_pdu(self, att_pdu: ATT_PDU) -> None:
         logger.debug(
             f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
         )
@@ -1013,6 +1047,7 @@
                     return
 
             # Return the response to the coroutine that is waiting for it
+            assert self.pending_response is not None
             self.pending_response.set_result(att_pdu)
         else:
             handler_name = f'on_{att_pdu.name.lower()}'
@@ -1060,7 +1095,7 @@
         # Confirm that we received the indication
         self.send_confirmation(ATT_Handle_Value_Confirmation())
 
-    def cache_value(self, attribute_handle: int, value: bytes):
+    def cache_value(self, attribute_handle: int, value: bytes) -> None:
         self.cached_values[attribute_handle] = (
             datetime.now(),
             value,
diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py
index 3624905..cdf1b5e 100644
--- a/bumble/gatt_server.py
+++ b/bumble/gatt_server.py
@@ -23,11 +23,12 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
 import asyncio
 import logging
 from collections import defaultdict
 import struct
-from typing import List, Tuple, Optional, TypeVar, Type
+from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
 from pyee import EventEmitter
 
 from .colors import color
@@ -42,6 +43,7 @@
     ATT_INVALID_OFFSET_ERROR,
     ATT_REQUEST_NOT_SUPPORTED_ERROR,
     ATT_REQUESTS,
+    ATT_PDU,
     ATT_UNLIKELY_ERROR_ERROR,
     ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
     ATT_Error,
@@ -73,6 +75,8 @@
     Service,
 )
 
+if TYPE_CHECKING:
+    from bumble.device import Device, Connection
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -91,8 +95,13 @@
 # -----------------------------------------------------------------------------
 class Server(EventEmitter):
     attributes: List[Attribute]
+    services: List[Service]
+    attributes_by_handle: Dict[int, Attribute]
+    subscribers: Dict[int, Dict[int, bytes]]
+    indication_semaphores: defaultdict[int, asyncio.Semaphore]
+    pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
 
-    def __init__(self, device):
+    def __init__(self, device: Device) -> None:
         super().__init__()
         self.device = device
         self.services = []
@@ -107,16 +116,16 @@
         self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
         self.pending_confirmations = defaultdict(lambda: None)
 
-    def __str__(self):
+    def __str__(self) -> str:
         return "\n".join(map(str, self.attributes))
 
-    def send_gatt_pdu(self, connection_handle, pdu):
+    def send_gatt_pdu(self, connection_handle: int, pdu: bytes) -> None:
         self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu)
 
-    def next_handle(self):
+    def next_handle(self) -> int:
         return 1 + len(self.attributes)
 
-    def get_advertising_service_data(self):
+    def get_advertising_service_data(self) -> Dict[Attribute, bytes]:
         return {
             attribute: data
             for attribute in self.attributes
@@ -124,7 +133,7 @@
             and (data := attribute.get_advertising_data())
         }
 
-    def get_attribute(self, handle):
+    def get_attribute(self, handle: int) -> Optional[Attribute]:
         attribute = self.attributes_by_handle.get(handle)
         if attribute:
             return attribute
@@ -173,12 +182,17 @@
 
         return next(
             (
-                (attribute, self.get_attribute(attribute.characteristic.handle))
+                (
+                    attribute,
+                    self.get_attribute(attribute.characteristic.handle),
+                )  # type: ignore
                 for attribute in map(
                     self.get_attribute,
                     range(service_handle.handle, service_handle.end_group_handle + 1),
                 )
-                if attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
+                if attribute is not None
+                and attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
+                and isinstance(attribute, CharacteristicDeclaration)
                 and attribute.characteristic.uuid == characteristic_uuid
             ),
             None,
@@ -197,7 +211,7 @@
 
         return next(
             (
-                attribute
+                attribute  # type: ignore
                 for attribute in map(
                     self.get_attribute,
                     range(
@@ -205,12 +219,12 @@
                         characteristic_value.end_group_handle + 1,
                     ),
                 )
-                if attribute.type == descriptor_uuid
+                if attribute is not None and attribute.type == descriptor_uuid
             ),
             None,
         )
 
-    def add_attribute(self, attribute):
+    def add_attribute(self, attribute: Attribute) -> None:
         # Assign a handle to this attribute
         attribute.handle = self.next_handle()
         attribute.end_group_handle = (
@@ -220,7 +234,7 @@
         # Add this attribute to the list
         self.attributes.append(attribute)
 
-    def add_service(self, service: Service):
+    def add_service(self, service: Service) -> None:
         # Add the service attribute to the DB
         self.add_attribute(service)
 
@@ -285,11 +299,13 @@
         service.end_group_handle = self.attributes[-1].handle
         self.services.append(service)
 
-    def add_services(self, services):
+    def add_services(self, services: Iterable[Service]) -> None:
         for service in services:
             self.add_service(service)
 
-    def read_cccd(self, connection, characteristic):
+    def read_cccd(
+        self, connection: Optional[Connection], characteristic: Characteristic
+    ) -> bytes:
         if connection is None:
             return bytes([0, 0])
 
@@ -300,7 +316,12 @@
 
         return cccd or bytes([0, 0])
 
-    def write_cccd(self, connection, characteristic, value):
+    def write_cccd(
+        self,
+        connection: Connection,
+        characteristic: Characteristic,
+        value: bytes,
+    ) -> None:
         logger.debug(
             f'Subscription update for connection=0x{connection.handle:04X}, '
             f'handle=0x{characteristic.handle:04X}: {value.hex()}'
@@ -327,13 +348,19 @@
             indicate_enabled,
         )
 
-    def send_response(self, connection, response):
+    def send_response(self, connection: Connection, response: ATT_PDU) -> None:
         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, value=None, force=False):
+    async def notify_subscriber(
+        self,
+        connection: Connection,
+        attribute: Attribute,
+        value: Optional[bytes] = None,
+        force: bool = False,
+    ) -> None:
         # Check if there's a subscriber
         if not force:
             subscribers = self.subscribers.get(connection.handle)
@@ -370,7 +397,13 @@
         )
         self.send_gatt_pdu(connection.handle, bytes(notification))
 
-    async def indicate_subscriber(self, connection, attribute, value=None, force=False):
+    async def indicate_subscriber(
+        self,
+        connection: Connection,
+        attribute: Attribute,
+        value: Optional[bytes] = None,
+        force: bool = False,
+    ) -> None:
         # Check if there's a subscriber
         if not force:
             subscribers = self.subscribers.get(connection.handle)
@@ -411,15 +444,13 @@
             assert self.pending_confirmations[connection.handle] is None
 
             # Create a future value to hold the eventual response
-            self.pending_confirmations[
+            pending_confirmation = self.pending_confirmations[
                 connection.handle
             ] = asyncio.get_running_loop().create_future()
 
             try:
                 self.send_gatt_pdu(connection.handle, indication.to_bytes())
-                await asyncio.wait_for(
-                    self.pending_confirmations[connection.handle], GATT_REQUEST_TIMEOUT
-                )
+                await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
             except asyncio.TimeoutError as error:
                 logger.warning(color('!!! GATT Indicate timeout', 'red'))
                 raise TimeoutError(f'GATT timeout for {indication.name}') from error
@@ -427,8 +458,12 @@
                 self.pending_confirmations[connection.handle] = None
 
     async def notify_or_indicate_subscribers(
-        self, indicate, attribute, value=None, force=False
-    ):
+        self,
+        indicate: bool,
+        attribute: Attribute,
+        value: Optional[bytes] = None,
+        force: bool = False,
+    ) -> None:
         # Get all the connections for which there's at least one subscription
         connections = [
             connection
@@ -450,13 +485,23 @@
                 ]
             )
 
-    async def notify_subscribers(self, attribute, value=None, force=False):
+    async def notify_subscribers(
+        self,
+        attribute: Attribute,
+        value: Optional[bytes] = None,
+        force: bool = False,
+    ):
         return await self.notify_or_indicate_subscribers(False, attribute, value, force)
 
-    async def indicate_subscribers(self, attribute, value=None, force=False):
+    async def indicate_subscribers(
+        self,
+        attribute: Attribute,
+        value: Optional[bytes] = None,
+        force: bool = False,
+    ):
         return await self.notify_or_indicate_subscribers(True, attribute, value, force)
 
-    def on_disconnection(self, connection):
+    def on_disconnection(self, connection: Connection) -> None:
         if connection.handle in self.subscribers:
             del self.subscribers[connection.handle]
         if connection.handle in self.indication_semaphores:
@@ -464,7 +509,7 @@
         if connection.handle in self.pending_confirmations:
             del self.pending_confirmations[connection.handle]
 
-    def on_gatt_pdu(self, connection, att_pdu):
+    def on_gatt_pdu(self, connection: Connection, att_pdu: ATT_PDU) -> None:
         logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}')
         handler_name = f'on_{att_pdu.name.lower()}'
         handler = getattr(self, handler_name, None)
@@ -506,7 +551,7 @@
     #######################################################
     # ATT handlers
     #######################################################
-    def on_att_request(self, connection, pdu):
+    def on_att_request(self, connection: Connection, pdu: ATT_PDU) -> None:
         '''
         Handler for requests without a more specific handler
         '''
@@ -679,7 +724,6 @@
             and attribute.handle <= request.ending_handle
             and pdu_space_available
         ):
-
             try:
                 attribute_value = attribute.read_value(connection)
             except ATT_Error as error:
diff --git a/bumble/hci.py b/bumble/hci.py
index 9b5793d..41deed2 100644
--- a/bumble/hci.py
+++ b/bumble/hci.py
@@ -16,11 +16,11 @@
 # Imports
 # -----------------------------------------------------------------------------
 from __future__ import annotations
-import struct
 import collections
-import logging
 import functools
-from typing import Dict, Type, Union
+import logging
+import struct
+from typing import Any, Dict, Callable, Optional, Type, Union
 
 from .colors import color
 from .core import (
@@ -47,6 +47,10 @@
     return ogf << 10 | ocf
 
 
+def hci_vendor_command_op_code(ocf):
+    return hci_command_op_code(HCI_VENDOR_OGF, ocf)
+
+
 def key_with_value(dictionary, target_value):
     for key, value in dictionary.items():
         if value == target_value:
@@ -62,7 +66,7 @@
     try:
         terminator = utf8_bytes.find(0)
         if terminator < 0:
-            return utf8_bytes
+            terminator = len(utf8_bytes)
         return utf8_bytes[0:terminator].decode('utf8')
     except UnicodeDecodeError:
         return utf8_bytes
@@ -101,6 +105,8 @@
 # fmt: off
 # pylint: disable=line-too-long
 
+HCI_VENDOR_OGF = 0x3F
+
 # HCI Version
 HCI_VERSION_BLUETOOTH_CORE_1_0B    = 0
 HCI_VERSION_BLUETOOTH_CORE_1_1     = 1
@@ -185,7 +191,7 @@
 HCI_IO_CAPABILITY_RESPONSE_EVENT                                 = 0x32
 HCI_USER_CONFIRMATION_REQUEST_EVENT                              = 0x33
 HCI_USER_PASSKEY_REQUEST_EVENT                                   = 0x34
-HCI_REMOTE_OOB_DATA_REQUEST                                      = 0x35
+HCI_REMOTE_OOB_DATA_REQUEST_EVENT                                = 0x35
 HCI_SIMPLE_PAIRING_COMPLETE_EVENT                                = 0x36
 HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT                       = 0x38
 HCI_ENHANCED_FLUSH_COMPLETE_EVENT                                = 0x39
@@ -206,10 +212,8 @@
 HCI_AUTHENTICATED_PAYLOAD_TIMEOUT_EXPIRED_EVENT                  = 0X57
 HCI_SAM_STATUS_CHANGE_EVENT                                      = 0X58
 
-HCI_EVENT_NAMES = {
-    event_code: event_name for (event_name, event_code) in globals().items()
-    if event_name.startswith('HCI_') and event_name.endswith('_EVENT')
-}
+HCI_VENDOR_EVENT = 0xFF
+
 
 # HCI Subevent Codes
 HCI_LE_CONNECTION_COMPLETE_EVENT                         = 0x01
@@ -248,10 +252,6 @@
 HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT                  = 0X22
 HCI_LE_SUBRATE_CHANGE_EVENT                              = 0X23
 
-HCI_SUBEVENT_NAMES = {
-    event_code: event_name for (event_name, event_code) in globals().items()
-    if event_name.startswith('HCI_LE_') and event_name.endswith('_EVENT') and event_code != HCI_LE_META_EVENT
-}
 
 # HCI Command
 HCI_INQUIRY_COMMAND                                                      = hci_command_op_code(0x01, 0x0001)
@@ -557,10 +557,6 @@
 HCI_LE_SET_DEFAULT_SUBRATE_COMMAND                                       = hci_command_op_code(0x08, 0x007D)
 HCI_LE_SUBRATE_REQUEST_COMMAND                                           = hci_command_op_code(0x08, 0x007E)
 
-HCI_COMMAND_NAMES = {
-    command_code: command_name for (command_name, command_code) in globals().items()
-    if command_name.startswith('HCI_') and command_name.endswith('_COMMAND')
-}
 
 # HCI Error Codes
 # See Bluetooth spec Vol 2, Part D - 1.3 LIST OF ERROR CODES
@@ -1445,8 +1441,14 @@
     @staticmethod
     def init_from_fields(hci_object, fields, values):
         if isinstance(values, dict):
-            for field_name, _ in fields:
-                setattr(hci_object, field_name, values[field_name])
+            for field in fields:
+                if isinstance(field, list):
+                    # The field is an array, up-level the array field names
+                    for sub_field_name, _ in field:
+                        setattr(hci_object, sub_field_name, values[sub_field_name])
+                else:
+                    field_name = field[0]
+                    setattr(hci_object, field_name, values[field_name])
         else:
             for field_name, field_value in zip(fields, values):
                 setattr(hci_object, field_name, field_value)
@@ -1457,132 +1459,160 @@
         HCI_Object.init_from_fields(hci_object, parsed.keys(), parsed.values())
 
     @staticmethod
+    def parse_field(data, offset, field_type):
+        # The field_type may be a dictionary with a mapper, parser, and/or size
+        if isinstance(field_type, dict):
+            if 'size' in field_type:
+                field_type = field_type['size']
+            elif 'parser' in field_type:
+                field_type = field_type['parser']
+
+        # Parse the field
+        if field_type == '*':
+            # The rest of the bytes
+            field_value = data[offset:]
+            return (field_value, len(field_value))
+        if field_type == 1:
+            # 8-bit unsigned
+            return (data[offset], 1)
+        if field_type == -1:
+            # 8-bit signed
+            return (struct.unpack_from('b', data, offset)[0], 1)
+        if field_type == 2:
+            # 16-bit unsigned
+            return (struct.unpack_from('<H', data, offset)[0], 2)
+        if field_type == '>2':
+            # 16-bit unsigned big-endian
+            return (struct.unpack_from('>H', data, offset)[0], 2)
+        if field_type == -2:
+            # 16-bit signed
+            return (struct.unpack_from('<h', data, offset)[0], 2)
+        if field_type == 3:
+            # 24-bit unsigned
+            padded = data[offset : offset + 3] + bytes([0])
+            return (struct.unpack('<I', padded)[0], 3)
+        if field_type == 4:
+            # 32-bit unsigned
+            return (struct.unpack_from('<I', data, offset)[0], 4)
+        if field_type == '>4':
+            # 32-bit unsigned big-endian
+            return (struct.unpack_from('>I', data, offset)[0], 4)
+        if isinstance(field_type, int) and 4 < field_type <= 256:
+            # Byte array (from 5 up to 256 bytes)
+            return (data[offset : offset + field_type], field_type)
+        if callable(field_type):
+            new_offset, field_value = field_type(data, offset)
+            return (field_value, new_offset - offset)
+
+        raise ValueError(f'unknown field type {field_type}')
+
+    @staticmethod
     def dict_from_bytes(data, offset, fields):
         result = collections.OrderedDict()
-        for (field_name, field_type) in fields:
-            # The field_type may be a dictionary with a mapper, parser, and/or size
-            if isinstance(field_type, dict):
-                if 'size' in field_type:
-                    field_type = field_type['size']
-                elif 'parser' in field_type:
-                    field_type = field_type['parser']
-
-            # Parse the field
-            if field_type == '*':
-                # The rest of the bytes
-                field_value = data[offset:]
-                offset += len(field_value)
-            elif field_type == 1:
-                # 8-bit unsigned
-                field_value = data[offset]
+        for field in fields:
+            if isinstance(field, list):
+                # This is an array field, starting with a 1-byte item count.
+                item_count = data[offset]
                 offset += 1
-            elif field_type == -1:
-                # 8-bit signed
-                field_value = struct.unpack_from('b', data, offset)[0]
-                offset += 1
-            elif field_type == 2:
-                # 16-bit unsigned
-                field_value = struct.unpack_from('<H', data, offset)[0]
-                offset += 2
-            elif field_type == '>2':
-                # 16-bit unsigned big-endian
-                field_value = struct.unpack_from('>H', data, offset)[0]
-                offset += 2
-            elif field_type == -2:
-                # 16-bit signed
-                field_value = struct.unpack_from('<h', data, offset)[0]
-                offset += 2
-            elif field_type == 3:
-                # 24-bit unsigned
-                padded = data[offset : offset + 3] + bytes([0])
-                field_value = struct.unpack('<I', padded)[0]
-                offset += 3
-            elif field_type == 4:
-                # 32-bit unsigned
-                field_value = struct.unpack_from('<I', data, offset)[0]
-                offset += 4
-            elif field_type == '>4':
-                # 32-bit unsigned big-endian
-                field_value = struct.unpack_from('>I', data, offset)[0]
-                offset += 4
-            elif isinstance(field_type, int) and 4 < field_type <= 256:
-                # Byte array (from 5 up to 256 bytes)
-                field_value = data[offset : offset + field_type]
-                offset += field_type
-            elif callable(field_type):
-                offset, field_value = field_type(data, offset)
-            else:
-                raise ValueError(f'unknown field type {field_type}')
+                for _ in range(item_count):
+                    for sub_field_name, sub_field_type in field:
+                        value, size = HCI_Object.parse_field(
+                            data, offset, sub_field_type
+                        )
+                        result.setdefault(sub_field_name, []).append(value)
+                        offset += size
+                continue
 
+            field_name, field_type = field
+            field_value, field_size = HCI_Object.parse_field(data, offset, field_type)
             result[field_name] = field_value
+            offset += field_size
 
         return result
 
     @staticmethod
+    def serialize_field(field_value, field_type):
+        # The field_type may be a dictionary with a mapper, parser, serializer,
+        # and/or size
+        serializer = None
+        if isinstance(field_type, dict):
+            if 'serializer' in field_type:
+                serializer = field_type['serializer']
+            if 'size' in field_type:
+                field_type = field_type['size']
+
+        # Serialize the field
+        if serializer:
+            field_bytes = serializer(field_value)
+        elif field_type == 1:
+            # 8-bit unsigned
+            field_bytes = bytes([field_value])
+        elif field_type == -1:
+            # 8-bit signed
+            field_bytes = struct.pack('b', field_value)
+        elif field_type == 2:
+            # 16-bit unsigned
+            field_bytes = struct.pack('<H', field_value)
+        elif field_type == '>2':
+            # 16-bit unsigned big-endian
+            field_bytes = struct.pack('>H', field_value)
+        elif field_type == -2:
+            # 16-bit signed
+            field_bytes = struct.pack('<h', field_value)
+        elif field_type == 3:
+            # 24-bit unsigned
+            field_bytes = struct.pack('<I', field_value)[0:3]
+        elif field_type == 4:
+            # 32-bit unsigned
+            field_bytes = struct.pack('<I', field_value)
+        elif field_type == '>4':
+            # 32-bit unsigned big-endian
+            field_bytes = struct.pack('>I', field_value)
+        elif field_type == '*':
+            if isinstance(field_value, int):
+                if 0 <= field_value <= 255:
+                    field_bytes = bytes([field_value])
+                else:
+                    raise ValueError('value too large for *-typed field')
+            else:
+                field_bytes = bytes(field_value)
+        elif isinstance(field_value, (bytes, bytearray)) or hasattr(
+            field_value, 'to_bytes'
+        ):
+            field_bytes = bytes(field_value)
+            if isinstance(field_type, int) and 4 < field_type <= 256:
+                # Truncate or pad with zeros if the field is too long or too short
+                if len(field_bytes) < field_type:
+                    field_bytes += bytes(field_type - len(field_bytes))
+                elif len(field_bytes) > field_type:
+                    field_bytes = field_bytes[:field_type]
+        else:
+            raise ValueError(f"don't know how to serialize type {type(field_value)}")
+
+        return field_bytes
+
+    @staticmethod
     def dict_to_bytes(hci_object, fields):
         result = bytearray()
-        for (field_name, field_type) in fields:
-            # The field_type may be a dictionary with a mapper, parser, serializer,
-            # and/or size
-            serializer = None
-            if isinstance(field_type, dict):
-                if 'serializer' in field_type:
-                    serializer = field_type['serializer']
-                if 'size' in field_type:
-                    field_type = field_type['size']
-
-            # Serialize the field
-            field_value = hci_object[field_name]
-            if serializer:
-                field_bytes = serializer(field_value)
-            elif field_type == 1:
-                # 8-bit unsigned
-                field_bytes = bytes([field_value])
-            elif field_type == -1:
-                # 8-bit signed
-                field_bytes = struct.pack('b', field_value)
-            elif field_type == 2:
-                # 16-bit unsigned
-                field_bytes = struct.pack('<H', field_value)
-            elif field_type == '>2':
-                # 16-bit unsigned big-endian
-                field_bytes = struct.pack('>H', field_value)
-            elif field_type == -2:
-                # 16-bit signed
-                field_bytes = struct.pack('<h', field_value)
-            elif field_type == 3:
-                # 24-bit unsigned
-                field_bytes = struct.pack('<I', field_value)[0:3]
-            elif field_type == 4:
-                # 32-bit unsigned
-                field_bytes = struct.pack('<I', field_value)
-            elif field_type == '>4':
-                # 32-bit unsigned big-endian
-                field_bytes = struct.pack('>I', field_value)
-            elif field_type == '*':
-                if isinstance(field_value, int):
-                    if 0 <= field_value <= 255:
-                        field_bytes = bytes([field_value])
-                    else:
-                        raise ValueError('value too large for *-typed field')
-                else:
-                    field_bytes = bytes(field_value)
-            elif isinstance(field_value, (bytes, bytearray)) or hasattr(
-                field_value, 'to_bytes'
-            ):
-                field_bytes = bytes(field_value)
-                if isinstance(field_type, int) and 4 < field_type <= 256:
-                    # Truncate or Pad with zeros if the field is too long or too short
-                    if len(field_bytes) < field_type:
-                        field_bytes += bytes(field_type - len(field_bytes))
-                    elif len(field_bytes) > field_type:
-                        field_bytes = field_bytes[:field_type]
-            else:
-                raise ValueError(
-                    f"don't know how to serialize type {type(field_value)}"
+        for field in fields:
+            if isinstance(field, list):
+                # The field is an array. The serialized form starts with a 1-byte
+                # item count. We use the length of the first array field as the
+                # array count, since all array fields have the same number of items.
+                item_count = len(hci_object[field[0][0]])
+                result += bytes([item_count]) + b''.join(
+                    b''.join(
+                        HCI_Object.serialize_field(
+                            hci_object[sub_field_name][i], sub_field_type
+                        )
+                        for sub_field_name, sub_field_type in field
+                    )
+                    for i in range(item_count)
                 )
+                continue
 
-            result += field_bytes
+            (field_name, field_type) = field
+            result += HCI_Object.serialize_field(hci_object[field_name], field_type)
 
         return bytes(result)
 
@@ -1617,46 +1647,73 @@
         return str(value)
 
     @staticmethod
-    def format_fields(hci_object, keys, indentation='', value_mappers=None):
-        if not keys:
-            return ''
+    def stringify_field(
+        field_name, field_type, field_value, indentation, value_mappers
+    ):
+        value_mapper = None
+        if isinstance(field_type, dict):
+            # Get the value mapper from the specifier
+            value_mapper = field_type.get('mapper')
 
-        # Measure the widest field name
-        max_field_name_length = max(
-            (len(key[0] if isinstance(key, tuple) else key) for key in keys)
+        # Check if there's a matching mapper passed
+        if value_mappers:
+            value_mapper = value_mappers.get(field_name, value_mapper)
+
+        # Map the value if we have a mapper
+        if value_mapper is not None:
+            field_value = value_mapper(field_value)
+
+        # Get the string representation of the value
+        return HCI_Object.format_field_value(
+            field_value, indentation=indentation + '  '
         )
 
+    @staticmethod
+    def format_fields(hci_object, fields, indentation='', value_mappers=None):
+        if not fields:
+            return ''
+
         # Build array of formatted key:value pairs
-        fields = []
-        for key in keys:
-            value_mapper = None
-            if isinstance(key, tuple):
-                # The key has an associated specifier
-                key, specifier = key
+        field_strings = []
+        for field in fields:
+            if isinstance(field, list):
+                for sub_field in field:
+                    sub_field_name, sub_field_type = sub_field
+                    item_count = len(hci_object[sub_field_name])
+                    for i in range(item_count):
+                        field_strings.append(
+                            (
+                                f'{sub_field_name}[{i}]',
+                                HCI_Object.stringify_field(
+                                    sub_field_name,
+                                    sub_field_type,
+                                    hci_object[sub_field_name][i],
+                                    indentation,
+                                    value_mappers,
+                                ),
+                            ),
+                        )
+                continue
 
-                # Get the value mapper from the specifier
-                if isinstance(specifier, dict):
-                    value_mapper = specifier.get('mapper')
-
-            # Get the value for the field
-            value = hci_object[key]
-
-            # Map the value if needed
-            if value_mappers:
-                value_mapper = value_mappers.get(key, value_mapper)
-            if value_mapper is not None:
-                value = value_mapper(value)
-
-            # Get the string representation of the value
-            value_str = HCI_Object.format_field_value(
-                value, indentation=indentation + '  '
+            field_name, field_type = field
+            field_value = hci_object[field_name]
+            field_strings.append(
+                (
+                    field_name,
+                    HCI_Object.stringify_field(
+                        field_name, field_type, field_value, indentation, value_mappers
+                    ),
+                ),
             )
 
-            # Add the field to the formatted result
-            key_str = color(f'{key + ":":{1 + max_field_name_length}}', 'cyan')
-            fields.append(f'{indentation}{key_str} {value_str}')
-
-        return '\n'.join(fields)
+        # Measure the widest field name
+        max_field_name_length = max(len(s[0]) for s in field_strings)
+        sep = ':'
+        return '\n'.join(
+            f'{indentation}'
+            f'{color(f"{field_name + sep:{1 + max_field_name_length}}", "cyan")} {field_value}'
+            for field_name, field_value in field_strings
+        )
 
     def __bytes__(self):
         return self.to_bytes()
@@ -1795,6 +1852,16 @@
     def to_bytes(self):
         return self.address_bytes
 
+    def to_string(self, with_type_qualifier=True):
+        '''
+        String representation of the address, MSB first, with an optional type
+        qualifier.
+        '''
+        result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
+        if not with_type_qualifier or not self.is_public:
+            return result
+        return result + '/P'
+
     def __bytes__(self):
         return self.to_bytes()
 
@@ -1808,13 +1875,7 @@
         )
 
     def __str__(self):
-        '''
-        String representation of the address, MSB first
-        '''
-        result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
-        if not self.is_public:
-            return result
-        return result + '/P'
+        return self.to_string()
 
 
 # Predefined address values
@@ -1853,7 +1914,7 @@
     hci_packet_type: int
 
     @staticmethod
-    def from_bytes(packet):
+    def from_bytes(packet: bytes) -> HCI_Packet:
         packet_type = packet[0]
 
         if packet_type == HCI_COMMAND_PACKET:
@@ -1895,6 +1956,7 @@
     '''
 
     hci_packet_type = HCI_COMMAND_PACKET
+    command_names: Dict[int, str] = {}
     command_classes: Dict[int, Type[HCI_Command]] = {}
 
     @staticmethod
@@ -1905,9 +1967,9 @@
 
         def inner(cls):
             cls.name = cls.__name__.upper()
-            cls.op_code = key_with_value(HCI_COMMAND_NAMES, cls.name)
+            cls.op_code = key_with_value(cls.command_names, cls.name)
             if cls.op_code is None:
-                raise KeyError(f'command {cls.name} not found in HCI_COMMAND_NAMES')
+                raise KeyError(f'command {cls.name} not found in command_names')
             cls.fields = fields
             cls.return_parameters_fields = return_parameters_fields
 
@@ -1927,7 +1989,19 @@
         return inner
 
     @staticmethod
-    def from_bytes(packet):
+    def command_map(symbols: Dict[str, Any]) -> Dict[int, str]:
+        return {
+            command_code: command_name
+            for (command_name, command_code) in symbols.items()
+            if command_name.startswith('HCI_') and command_name.endswith('_COMMAND')
+        }
+
+    @classmethod
+    def register_commands(cls, symbols: Dict[str, Any]) -> None:
+        cls.command_names.update(cls.command_map(symbols))
+
+    @staticmethod
+    def from_bytes(packet: bytes) -> HCI_Command:
         op_code, length = struct.unpack_from('<HB', packet, 1)
         parameters = packet[4:]
         if len(parameters) != length:
@@ -1946,11 +2020,11 @@
             HCI_Object.init_from_bytes(self, parameters, 0, fields)
             return self
 
-        return cls.from_parameters(parameters)
+        return cls.from_parameters(parameters)  # type: ignore
 
     @staticmethod
     def command_name(op_code):
-        name = HCI_COMMAND_NAMES.get(op_code)
+        name = HCI_Command.command_names.get(op_code)
         if name is not None:
             return name
         return f'[OGF=0x{op_code >> 10:02x}, OCF=0x{op_code & 0x3FF:04x}]'
@@ -1959,6 +2033,16 @@
     def create_return_parameters(cls, **kwargs):
         return HCI_Object(cls.return_parameters_fields, **kwargs)
 
+    @classmethod
+    def parse_return_parameters(cls, parameters):
+        if not cls.return_parameters_fields:
+            return None
+        return_parameters = HCI_Object.from_bytes(
+            parameters, 0, cls.return_parameters_fields
+        )
+        return_parameters.fields = cls.return_parameters_fields
+        return return_parameters
+
     def __init__(self, op_code, parameters=None, **kwargs):
         super().__init__(HCI_Command.command_name(op_code))
         if (fields := getattr(self, 'fields', None)) and kwargs:
@@ -1988,6 +2072,9 @@
         return result
 
 
+HCI_Command.register_commands(globals())
+
+
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
     [
@@ -2284,6 +2371,55 @@
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
+    fields=[
+        ('bd_addr', Address.parse_address),
+        ('c', 16),
+        ('r', 16),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('bd_addr', Address.parse_address),
+    ],
+)
+class HCI_Remote_OOB_Data_Request_Reply_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.34 Remote OOB Data Request Reply Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[('bd_addr', Address.parse_address)],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('bd_addr', Address.parse_address),
+    ],
+)
+class HCI_Remote_OOB_Data_Request_Negative_Reply_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.35 Remote OOB Data Request Negative Reply Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('bd_addr', Address.parse_address),
+        ('reason', 1),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('bd_addr', Address.parse_address),
+    ],
+)
+class HCI_IO_Capability_Request_Negative_Reply_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.36 IO Capability Request Negative Reply Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
     [
         ('connection_handle', 2),
         ('transmit_bandwidth', 4),
@@ -2320,6 +2456,161 @@
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
     [
+        ('bd_addr', Address.parse_address),
+        ('transmit_bandwidth', 4),
+        ('receive_bandwidth', 4),
+        ('transmit_coding_format', 5),
+        ('receive_coding_format', 5),
+        ('transmit_codec_frame_size', 2),
+        ('receive_codec_frame_size', 2),
+        ('input_bandwidth', 4),
+        ('output_bandwidth', 4),
+        ('input_coding_format', 5),
+        ('output_coding_format', 5),
+        ('input_coded_data_size', 2),
+        ('output_coded_data_size', 2),
+        ('input_pcm_data_format', 1),
+        ('output_pcm_data_format', 1),
+        ('input_pcm_sample_payload_msb_position', 1),
+        ('output_pcm_sample_payload_msb_position', 1),
+        ('input_data_path', 1),
+        ('output_data_path', 1),
+        ('input_transport_unit_size', 1),
+        ('output_transport_unit_size', 1),
+        ('max_latency', 2),
+        ('packet_type', 2),
+        ('retransmission_effort', 1),
+    ]
+)
+class HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.46 Enhanced Accept Synchronous Connection Request Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('bd_addr', Address.parse_address),
+        ('page_scan_repetition_mode', 1),
+        ('clock_offset', 2),
+    ]
+)
+class HCI_Truncated_Page_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.47 Truncated Page Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[('bd_addr', Address.parse_address)],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('bd_addr', Address.parse_address),
+    ],
+)
+class HCI_Truncated_Page_Cancel_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.48 Truncated Page Cancel Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('enable', 1),
+        ('lt_addr', 1),
+        ('lpo_allowed', 1),
+        ('packet_type', 2),
+        ('interval_min', 2),
+        ('interval_max', 2),
+        ('supervision_timeout', 2),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('lt_addr', 1),
+        ('interval', 2),
+    ],
+)
+class HCI_Set_Connectionless_Peripheral_Broadcast_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.49 Set Connectionless Peripheral Broadcast Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('enable', 1),
+        ('bd_addr', Address.parse_address),
+        ('lt_addr', 1),
+        ('interval', 2),
+        ('clock_offset', 4),
+        ('next_connectionless_peripheral_broadcast_clock', 4),
+        ('supervision_timeout', 2),
+        ('remote_timing_accuracy', 1),
+        ('skip', 1),
+        ('packet_type', 2),
+        ('afh_channel_map', 10),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('bd_addr', Address.parse_address),
+        ('lt_addr', 1),
+    ],
+)
+class HCI_Set_Connectionless_Peripheral_Broadcast_Receive_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.50 Set Connectionless Peripheral Broadcast Receive Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+class HCI_Start_Synchronization_Train_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.51 Start Synchronization Train Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('bd_addr', Address.parse_address),
+        ('sync_scan_timeout', 2),
+        ('sync_scan_window', 2),
+        ('sync_scan_interval', 2),
+    ],
+)
+class HCI_Receive_Synchronization_Train_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.52 Receive Synchronization Train Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        ('bd_addr', Address.parse_address),
+        ('c_192', 16),
+        ('r_192', 16),
+        ('c_256', 16),
+        ('r_256', 16),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('bd_addr', Address.parse_address),
+    ],
+)
+class HCI_Remote_OOB_Extended_Data_Request_Reply_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.1.53 Remote OOB Extended Data Request Reply Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    [
         ('connection_handle', 2),
         ('sniff_max_interval', 2),
         ('sniff_min_interval', 2),
@@ -2685,6 +2976,20 @@
 
 # -----------------------------------------------------------------------------
 @HCI_Command.command(
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('c', 16),
+        ('r', 16),
+    ]
+)
+class HCI_Read_Local_OOB_Data_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.3.60 Read Local OOB Data Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
     return_parameters_fields=[('status', STATUS_SPEC), ('tx_power', -1)]
 )
 class HCI_Read_Inquiry_Response_Transmit_Power_Level_Command(HCI_Command):
@@ -2747,6 +3052,22 @@
 @HCI_Command.command(
     return_parameters_fields=[
         ('status', STATUS_SPEC),
+        ('c_192', 16),
+        ('r_192', 16),
+        ('c_256', 16),
+        ('r_256', 16),
+    ]
+)
+class HCI_Read_Local_OOB_Extended_Data_Command(HCI_Command):
+    '''
+    See Bluetooth spec @ 7.3.95 Read Local OOB Extended Data Command
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
         ('hci_version', 1),
         ('hci_subversion', 2),
         ('lmp_version', 1),
@@ -3529,9 +3850,7 @@
             'advertising_data',
             {
                 'parser': HCI_Object.parse_length_prefixed_bytes,
-                'serializer': functools.partial(
-                    HCI_Object.serialize_length_prefixed_bytes
-                ),
+                'serializer': HCI_Object.serialize_length_prefixed_bytes,
             },
         ),
     ]
@@ -3579,9 +3898,7 @@
             'scan_response_data',
             {
                 'parser': HCI_Object.parse_length_prefixed_bytes,
-                'serializer': functools.partial(
-                    HCI_Object.serialize_length_prefixed_bytes
-                ),
+                'serializer': HCI_Object.serialize_length_prefixed_bytes,
             },
         ),
     ]
@@ -3609,73 +3926,21 @@
 
 
 # -----------------------------------------------------------------------------
-@HCI_Command.command(fields=None)
+@HCI_Command.command(
+    [
+        ('enable', 1),
+        [
+            ('advertising_handles', 1),
+            ('durations', 2),
+            ('max_extended_advertising_events', 1),
+        ],
+    ]
+)
 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, advertising_handle in enumerate(self.advertising_handles):
-            fields.append(
-                (f'advertising_handle[{i}]:             ', advertising_handle)
-            )
-            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(
@@ -3826,7 +4091,10 @@
             color(self.name, 'green')
             + ':\n'
             + '\n'.join(
-                [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
+                [
+                    color('  ' + field[0], 'cyan') + ' ' + str(field[1])
+                    for field in fields
+                ]
             )
         )
 
@@ -4002,7 +4270,10 @@
             color(self.name, 'green')
             + ':\n'
             + '\n'.join(
-                [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
+                [
+                    color('  ' + field[0], 'cyan') + ' ' + str(field[1])
+                    for field in fields
+                ]
             )
         )
 
@@ -4059,8 +4330,8 @@
     '''
 
     hci_packet_type = HCI_EVENT_PACKET
+    event_names: Dict[int, str] = {}
     event_classes: Dict[int, Type[HCI_Event]] = {}
-    meta_event_classes: Dict[int, Type[HCI_LE_Meta_Event]] = {}
 
     @staticmethod
     def event(fields=()):
@@ -4070,9 +4341,9 @@
 
         def inner(cls):
             cls.name = cls.__name__.upper()
-            cls.event_code = key_with_value(HCI_EVENT_NAMES, cls.name)
+            cls.event_code = key_with_value(cls.event_names, cls.name)
             if cls.event_code is None:
-                raise KeyError('event not found in HCI_EVENT_NAMES')
+                raise KeyError(f'event {cls.name} not found in event_names')
             cls.fields = fields
 
             # Patch the __init__ method to fix the event_code
@@ -4089,11 +4360,29 @@
         return inner
 
     @staticmethod
+    def event_map(symbols: Dict[str, Any]) -> Dict[int, str]:
+        return {
+            event_code: event_name
+            for (event_name, event_code) in symbols.items()
+            if event_name.startswith('HCI_')
+            and not event_name.startswith('HCI_LE_')
+            and event_name.endswith('_EVENT')
+        }
+
+    @staticmethod
+    def event_name(event_code):
+        return name_or_number(HCI_Event.event_names, event_code)
+
+    @staticmethod
+    def register_events(symbols: Dict[str, Any]) -> None:
+        HCI_Event.event_names.update(HCI_Event.event_map(symbols))
+
+    @staticmethod
     def registered(event_class):
         event_class.name = event_class.__name__.upper()
-        event_class.event_code = key_with_value(HCI_EVENT_NAMES, event_class.name)
+        event_class.event_code = key_with_value(HCI_Event.event_names, event_class.name)
         if event_class.event_code is None:
-            raise KeyError('event not found in HCI_EVENT_NAMES')
+            raise KeyError(f'event {event_class.name} not found in event_names')
 
         # Register a factory for this class
         HCI_Event.event_classes[event_class.event_code] = event_class
@@ -4101,22 +4390,28 @@
         return event_class
 
     @staticmethod
-    def from_bytes(packet):
+    def from_bytes(packet: bytes) -> HCI_Event:
         event_code = packet[1]
         length = packet[2]
         parameters = packet[3:]
         if len(parameters) != length:
             raise ValueError('invalid packet length')
 
+        cls: Any
         if event_code == HCI_LE_META_EVENT:
             # We do this dispatch here and not in the subclass in order to avoid call
             # loops
             subevent_code = parameters[0]
-            cls = HCI_Event.meta_event_classes.get(subevent_code)
+            cls = HCI_LE_Meta_Event.subevent_classes.get(subevent_code)
             if cls is None:
                 # No class registered, just use a generic class instance
                 return HCI_LE_Meta_Event(subevent_code, parameters)
-
+        elif event_code == HCI_VENDOR_EVENT:
+            subevent_code = parameters[0]
+            cls = HCI_Vendor_Event.subevent_classes.get(subevent_code)
+            if cls is None:
+                # No class registered, just use a generic class instance
+                return HCI_Vendor_Event(subevent_code, parameters)
         else:
             cls = HCI_Event.event_classes.get(event_code)
             if cls is None:
@@ -4124,7 +4419,7 @@
                 return HCI_Event(event_code, parameters)
 
         # Invoke the factory to create a new instance
-        return cls.from_parameters(parameters)
+        return cls.from_parameters(parameters)  # type: ignore
 
     @classmethod
     def from_parameters(cls, parameters):
@@ -4134,10 +4429,6 @@
             HCI_Object.init_from_bytes(self, parameters, 0, fields)
         return self
 
-    @staticmethod
-    def event_name(event_code):
-        return name_or_number(HCI_EVENT_NAMES, event_code)
-
     def __init__(self, event_code, parameters=None, **kwargs):
         super().__init__(HCI_Event.event_name(event_code))
         if (fields := getattr(self, 'fields', None)) and kwargs:
@@ -4164,71 +4455,111 @@
         return result
 
 
+HCI_Event.register_events(globals())
+
+
 # -----------------------------------------------------------------------------
-class HCI_LE_Meta_Event(HCI_Event):
+class HCI_Extended_Event(HCI_Event):
     '''
-    See Bluetooth spec @ 7.7.65 LE Meta Event
+    HCI_Event subclass for events that has a subevent code.
     '''
 
-    @staticmethod
-    def event(fields=()):
+    subevent_names: Dict[int, str] = {}
+    subevent_classes: Dict[int, Type[HCI_Extended_Event]]
+
+    @classmethod
+    def event(cls, fields=()):
         '''
         Decorator used to declare and register subclasses
         '''
 
         def inner(cls):
             cls.name = cls.__name__.upper()
-            cls.subevent_code = key_with_value(HCI_SUBEVENT_NAMES, cls.name)
+            cls.subevent_code = key_with_value(cls.subevent_names, cls.name)
             if cls.subevent_code is None:
-                raise KeyError('subevent not found in HCI_SUBEVENT_NAMES')
+                raise KeyError(f'subevent {cls.name} not found in subevent_names')
             cls.fields = fields
 
             # Patch the __init__ method to fix the subevent_code
+            original_init = cls.__init__
+
             def init(self, parameters=None, **kwargs):
-                return HCI_LE_Meta_Event.__init__(
-                    self, cls.subevent_code, parameters, **kwargs
-                )
+                return original_init(self, cls.subevent_code, parameters, **kwargs)
 
             cls.__init__ = init
 
             # Register a factory for this class
-            HCI_Event.meta_event_classes[cls.subevent_code] = cls
+            cls.subevent_classes[cls.subevent_code] = cls
 
             return cls
 
         return inner
 
     @classmethod
+    def subevent_name(cls, subevent_code):
+        subevent_name = cls.subevent_names.get(subevent_code)
+        if subevent_name is not None:
+            return subevent_name
+
+        return f'{cls.__name__.upper()}[0x{subevent_code:02X}]'
+
+    @staticmethod
+    def subevent_map(symbols: Dict[str, Any]) -> Dict[int, str]:
+        return {
+            subevent_code: subevent_name
+            for (subevent_name, subevent_code) in symbols.items()
+            if subevent_name.startswith('HCI_') and subevent_name.endswith('_EVENT')
+        }
+
+    @classmethod
+    def register_subevents(cls, symbols: Dict[str, Any]) -> None:
+        cls.subevent_names.update(cls.subevent_map(symbols))
+
+    @classmethod
     def from_parameters(cls, parameters):
         self = cls.__new__(cls)
-        HCI_LE_Meta_Event.__init__(self, self.subevent_code, parameters)
+        HCI_Extended_Event.__init__(self, self.subevent_code, parameters)
         if fields := getattr(self, 'fields', None):
             HCI_Object.init_from_bytes(self, parameters, 1, fields)
         return self
 
-    @staticmethod
-    def subevent_name(subevent_code):
-        return name_or_number(HCI_SUBEVENT_NAMES, subevent_code)
-
     def __init__(self, subevent_code, parameters, **kwargs):
         self.subevent_code = subevent_code
         if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
             parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
                 kwargs, fields
             )
-        super().__init__(HCI_LE_META_EVENT, parameters, **kwargs)
+        super().__init__(self.event_code, parameters, **kwargs)
 
         # Override the name in order to adopt the subevent name instead
         self.name = self.subevent_name(subevent_code)
 
-    def __str__(self):
-        result = color(self.subevent_name(self.subevent_code), 'magenta')
-        if fields := getattr(self, 'fields', None):
-            result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, '  ')
-        else:
-            if self.parameters:
-                result += f': {self.parameters.hex()}'
-        return result
+
+# -----------------------------------------------------------------------------
+class HCI_LE_Meta_Event(HCI_Extended_Event):
+    '''
+    See Bluetooth spec @ 7.7.65 LE Meta Event
+    '''
+
+    event_code: int = HCI_LE_META_EVENT
+    subevent_classes = {}
+
+    @staticmethod
+    def subevent_map(symbols: Dict[str, Any]) -> Dict[int, str]:
+        return {
+            subevent_code: subevent_name
+            for (subevent_name, subevent_code) in symbols.items()
+            if subevent_name.startswith('HCI_LE_') and subevent_name.endswith('_EVENT')
+        }
+
+
+HCI_LE_Meta_Event.register_subevents(globals())
+
+
+# -----------------------------------------------------------------------------
+class HCI_Vendor_Event(HCI_Extended_Event):
+    event_code: int = HCI_VENDOR_EVENT
+    subevent_classes = {}
 
 
 # -----------------------------------------------------------------------------
@@ -4342,7 +4673,7 @@
         return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
 
 
-HCI_Event.meta_event_classes[
+HCI_LE_Meta_Event.subevent_classes[
     HCI_LE_ADVERTISING_REPORT_EVENT
 ] = HCI_LE_Advertising_Report_Event
 
@@ -4596,7 +4927,7 @@
         return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
 
 
-HCI_Event.meta_event_classes[
+HCI_LE_Meta_Event.subevent_classes[
     HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT
 ] = HCI_LE_Extended_Advertising_Report_Event
 
@@ -4837,6 +5168,7 @@
     '''
 
     return_parameters = b''
+    command_opcode: int
 
     def map_return_parameters(self, return_parameters):
         '''Map simple 'status' return parameters to their named constant form'''
@@ -4869,11 +5201,11 @@
             self.return_parameters = self.return_parameters[0]
         else:
             cls = HCI_Command.command_classes.get(self.command_opcode)
-            if cls and cls.return_parameters_fields:
-                self.return_parameters = HCI_Object.from_bytes(
-                    self.return_parameters, 0, cls.return_parameters_fields
-                )
-                self.return_parameters.fields = cls.return_parameters_fields
+            if cls:
+                # Try to parse the return parameters bytes into an object.
+                return_parameters = cls.parse_return_parameters(self.return_parameters)
+                if return_parameters is not None:
+                    self.return_parameters = return_parameters
 
         return self
 
@@ -4965,7 +5297,7 @@
     def __str__(self):
         lines = [
             color(self.name, 'magenta') + ':',
-            color('  number_of_handles:         ', 'cyan')
+            color('  number_of_handles:        ', 'cyan')
             + f'{len(self.connection_handles)}',
         ]
         for i, connection_handle in enumerate(self.connection_handles):
@@ -5300,6 +5632,14 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Event.event([('bd_addr', Address.parse_address)])
+class HCI_Remote_OOB_Data_Request_Event(HCI_Event):
+    '''
+    See Bluetooth spec @ 7.7.44 Remote OOB Data Request Event
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Event.event([('status', STATUS_SPEC), ('bd_addr', Address.parse_address)])
 class HCI_Simple_Pairing_Complete_Event(HCI_Event):
     '''
@@ -5316,6 +5656,14 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Event.event([('handle', 2)])
+class HCI_Enhanced_Flush_Complete_Event(HCI_Event):
+    '''
+    See Bluetooth spec @ 7.7.47 Enhanced Flush Complete Event
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Event.event([('bd_addr', Address.parse_address), ('passkey', 4)])
 class HCI_User_Passkey_Notification_Event(HCI_Event):
     '''
@@ -5324,6 +5672,14 @@
 
 
 # -----------------------------------------------------------------------------
+@HCI_Event.event([('bd_addr', Address.parse_address), ('notification_type', 1)])
+class HCI_Keypress_Notification_Event(HCI_Event):
+    '''
+    See Bluetooth spec @ 7.7.49 Keypress Notification Event
+    '''
+
+
+# -----------------------------------------------------------------------------
 @HCI_Event.event([('bd_addr', Address.parse_address), ('host_supported_features', 8)])
 class HCI_Remote_Host_Supported_Features_Notification_Event(HCI_Event):
     '''
@@ -5332,7 +5688,7 @@
 
 
 # -----------------------------------------------------------------------------
-class HCI_AclDataPacket:
+class HCI_AclDataPacket(HCI_Packet):
     '''
     See Bluetooth spec @ 5.4.2 HCI ACL Data Packets
     '''
@@ -5340,7 +5696,7 @@
     hci_packet_type = HCI_ACL_DATA_PACKET
 
     @staticmethod
-    def from_bytes(packet):
+    def from_bytes(packet: bytes) -> HCI_AclDataPacket:
         # Read the header
         h, data_total_length = struct.unpack_from('<HH', packet, 1)
         connection_handle = h & 0xFFF
@@ -5373,7 +5729,7 @@
     def __str__(self):
         return (
             f'{color("ACL", "blue")}: '
-            f'handle=0x{self.connection_handle:04x}'
+            f'handle=0x{self.connection_handle:04x}, '
             f'pb={self.pb_flag}, bc={self.bc_flag}, '
             f'data_total_length={self.data_total_length}, '
             f'data={self.data.hex()}'
@@ -5382,12 +5738,14 @@
 
 # -----------------------------------------------------------------------------
 class HCI_AclDataPacketAssembler:
-    def __init__(self, callback):
+    current_data: Optional[bytes]
+
+    def __init__(self, callback: Callable[[bytes], Any]) -> None:
         self.callback = callback
         self.current_data = None
         self.l2cap_pdu_length = 0
 
-    def feed_packet(self, packet):
+    def feed_packet(self, packet: HCI_AclDataPacket) -> None:
         if packet.pb_flag in (
             HCI_ACL_PB_FIRST_NON_FLUSHABLE,
             HCI_ACL_PB_FIRST_FLUSHABLE,
@@ -5401,6 +5759,7 @@
                 return
             self.current_data += packet.data
 
+        assert self.current_data is not None
         if len(self.current_data) == self.l2cap_pdu_length + 4:
             # The packet is complete, invoke the callback
             logger.debug(f'<<< ACL PDU: {self.current_data.hex()}')
diff --git a/bumble/hfp.py b/bumble/hfp.py
index 7bb9f08..bb00920 100644
--- a/bumble/hfp.py
+++ b/bumble/hfp.py
@@ -1,4 +1,4 @@
-# Copyright 2021-2022 Google LLC
+# Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -15,11 +15,35 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+import collections.abc
 import logging
 import asyncio
-import collections
+import dataclasses
+import enum
+import traceback
+import warnings
+from typing import Dict, List, Union, Set, TYPE_CHECKING
 
-from .colors import color
+from . import at
+from . import rfcomm
+
+from bumble.colors import color
+from bumble.core import (
+    ProtocolError,
+    BT_GENERIC_AUDIO_SERVICE,
+    BT_HANDSFREE_SERVICE,
+    BT_L2CAP_PROTOCOL_ID,
+    BT_RFCOMM_PROTOCOL_ID,
+)
+from bumble.sdp import (
+    DataElement,
+    ServiceAttribute,
+    SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+    SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+    SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+    SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+    SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+)
 
 
 # -----------------------------------------------------------------------------
@@ -27,6 +51,15 @@
 # -----------------------------------------------------------------------------
 logger = logging.getLogger(__name__)
 
+# -----------------------------------------------------------------------------
+# Error
+# -----------------------------------------------------------------------------
+
+
+class HfpProtocolError(ProtocolError):
+    def __init__(self, error_name: str = '', details: str = ''):
+        super().__init__(None, 'hfp', error_name, details)
+
 
 # -----------------------------------------------------------------------------
 # Protocol Support
@@ -34,7 +67,13 @@
 
 # -----------------------------------------------------------------------------
 class HfpProtocol:
-    def __init__(self, dlc):
+    dlc: rfcomm.DLC
+    buffer: str
+    lines: collections.deque
+    lines_available: asyncio.Event
+
+    def __init__(self, dlc: rfcomm.DLC) -> None:
+        warnings.warn("See HfProtocol", DeprecationWarning)
         self.dlc = dlc
         self.buffer = ''
         self.lines = collections.deque()
@@ -42,7 +81,7 @@
 
         dlc.sink = self.feed
 
-    def feed(self, data):
+    def feed(self, data: Union[bytes, str]) -> None:
         # Convert the data to a string if needed
         if isinstance(data, bytes):
             data = data.decode('utf-8')
@@ -57,19 +96,19 @@
             if len(line) > 0:
                 self.on_line(line)
 
-    def on_line(self, line):
+    def on_line(self, line: str) -> None:
         self.lines.append(line)
         self.lines_available.set()
 
-    def send_command_line(self, line):
+    def send_command_line(self, line: str) -> None:
         logger.debug(color(f'>>> {line}', 'yellow'))
         self.dlc.write(line + '\r')
 
-    def send_response_line(self, line):
+    def send_response_line(self, line: str) -> None:
         logger.debug(color(f'>>> {line}', 'yellow'))
         self.dlc.write('\r\n' + line + '\r\n')
 
-    async def next_line(self):
+    async def next_line(self) -> str:
         await self.lines_available.wait()
         line = self.lines.popleft()
         if not self.lines:
@@ -77,19 +116,706 @@
         logger.debug(color(f'<<< {line}', 'green'))
         return line
 
-    async def initialize_service(self):
-        # Perform Service Level Connection Initialization
-        self.send_command_line('AT+BRSF=2072')  # Retrieve Supported Features
-        await (self.next_line())
-        await (self.next_line())
 
-        self.send_command_line('AT+CIND=?')
-        await (self.next_line())
-        await (self.next_line())
+# -----------------------------------------------------------------------------
+# Normative protocol definitions
+# -----------------------------------------------------------------------------
 
-        self.send_command_line('AT+CIND?')
-        await (self.next_line())
-        await (self.next_line())
 
-        self.send_command_line('AT+CMER=3,0,0,1')
-        await (self.next_line())
+# HF supported features (AT+BRSF=) (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class HfFeature(enum.IntFlag):
+    EC_NR = 0x001  # Echo Cancel & Noise reduction
+    THREE_WAY_CALLING = 0x002
+    CLI_PRESENTATION_CAPABILITY = 0x004
+    VOICE_RECOGNITION_ACTIVATION = 0x008
+    REMOTE_VOLUME_CONTROL = 0x010
+    ENHANCED_CALL_STATUS = 0x020
+    ENHANCED_CALL_CONTROL = 0x040
+    CODEC_NEGOTIATION = 0x080
+    HF_INDICATORS = 0x100
+    ESCO_S4_SETTINGS_SUPPORTED = 0x200
+    ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
+    VOICE_RECOGNITION_TEST = 0x800
+
+
+# AG supported features (+BRSF:) (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class AgFeature(enum.IntFlag):
+    THREE_WAY_CALLING = 0x001
+    EC_NR = 0x002  # Echo Cancel & Noise reduction
+    VOICE_RECOGNITION_FUNCTION = 0x004
+    IN_BAND_RING_TONE_CAPABILITY = 0x008
+    VOICE_TAG = 0x010  # Attach a number to voice tag
+    REJECT_CALL = 0x020  # Ability to reject a call
+    ENHANCED_CALL_STATUS = 0x040
+    ENHANCED_CALL_CONTROL = 0x080
+    EXTENDED_ERROR_RESULT_CODES = 0x100
+    CODEC_NEGOTIATION = 0x200
+    HF_INDICATORS = 0x400
+    ESCO_S4_SETTINGS_SUPPORTED = 0x800
+    ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
+    VOICE_RECOGNITION_TEST = 0x2000
+
+
+# Audio Codec IDs (normative).
+# Hands-Free Profile v1.8, 10 Appendix B
+class AudioCodec(enum.IntEnum):
+    CVSD = 0x01  # Support for CVSD audio codec
+    MSBC = 0x02  # Support for mSBC audio codec
+
+
+# HF Indicators (normative).
+# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
+class HfIndicator(enum.IntEnum):
+    ENHANCED_SAFETY = 0x01  # Enhanced safety feature
+    BATTERY_LEVEL = 0x02  # Battery level feature
+
+
+# Call Hold supported operations (normative).
+# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
+class CallHoldOperation(enum.IntEnum):
+    RELEASE_ALL_HELD_CALLS = 0  # Release all held calls
+    RELEASE_ALL_ACTIVE_CALLS = 1  # Release all active calls, accept other
+    HOLD_ALL_ACTIVE_CALLS = 2  # Place all active calls on hold, accept other
+    ADD_HELD_CALL = 3  # Adds a held call to conversation
+
+
+# Response Hold status (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class ResponseHoldStatus(enum.IntEnum):
+    INC_CALL_HELD = 0  # Put incoming call on hold
+    HELD_CALL_ACC = 1  # Accept a held incoming call
+    HELD_CALL_REJ = 2  # Reject a held incoming call
+
+
+# Values for the Call Setup AG indicator (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class CallSetupAgIndicator(enum.IntEnum):
+    NOT_IN_CALL_SETUP = 0
+    INCOMING_CALL_PROCESS = 1
+    OUTGOING_CALL_SETUP = 2
+    REMOTE_ALERTED = 3  # Remote party alerted in an outgoing call
+
+
+# Values for the Call Held AG indicator (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class CallHeldAgIndicator(enum.IntEnum):
+    NO_CALLS_HELD = 0
+    # Call is placed on hold or active/held calls swapped
+    # (The AG has both an active AND a held call)
+    CALL_ON_HOLD_AND_ACTIVE_CALL = 1
+    CALL_ON_HOLD_NO_ACTIVE_CALL = 2  # Call on hold, no active call
+
+
+# Call Info direction (normative).
+# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
+class CallInfoDirection(enum.IntEnum):
+    MOBILE_ORIGINATED_CALL = 0
+    MOBILE_TERMINATED_CALL = 1
+
+
+# Call Info status (normative).
+# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
+class CallInfoStatus(enum.IntEnum):
+    ACTIVE = 0
+    HELD = 1
+    DIALING = 2
+    ALERTING = 3
+    INCOMING = 4
+    WAITING = 5
+
+
+# Call Info mode (normative).
+# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
+class CallInfoMode(enum.IntEnum):
+    VOICE = 0
+    DATA = 1
+    FAX = 2
+    UNKNOWN = 9
+
+
+# -----------------------------------------------------------------------------
+# Hands-Free Control Interoperability Requirements
+# -----------------------------------------------------------------------------
+
+# Response codes.
+RESPONSE_CODES = [
+    "+APLSIRI",
+    "+BAC",
+    "+BCC",
+    "+BCS",
+    "+BIA",
+    "+BIEV",
+    "+BIND",
+    "+BINP",
+    "+BLDN",
+    "+BRSF",
+    "+BTRH",
+    "+BVRA",
+    "+CCWA",
+    "+CHLD",
+    "+CHUP",
+    "+CIND",
+    "+CLCC",
+    "+CLIP",
+    "+CMEE",
+    "+CMER",
+    "+CNUM",
+    "+COPS",
+    "+IPHONEACCEV",
+    "+NREC",
+    "+VGM",
+    "+VGS",
+    "+VTS",
+    "+XAPL",
+    "A",
+    "D",
+]
+
+# Unsolicited responses and statuses.
+UNSOLICITED_CODES = [
+    "+APLSIRI",
+    "+BCS",
+    "+BIND",
+    "+BSIR",
+    "+BTRH",
+    "+BVRA",
+    "+CCWA",
+    "+CIEV",
+    "+CLIP",
+    "+VGM",
+    "+VGS",
+    "BLACKLISTED",
+    "BUSY",
+    "DELAYED",
+    "NO ANSWER",
+    "NO CARRIER",
+    "RING",
+]
+
+# Status codes
+STATUS_CODES = [
+    "+CME ERROR",
+    "BLACKLISTED",
+    "BUSY",
+    "DELAYED",
+    "ERROR",
+    "NO ANSWER",
+    "NO CARRIER",
+    "OK",
+]
+
+
+@dataclasses.dataclass
+class Configuration:
+    supported_hf_features: List[HfFeature]
+    supported_hf_indicators: List[HfIndicator]
+    supported_audio_codecs: List[AudioCodec]
+
+
+class AtResponseType(enum.Enum):
+    """Indicate if a response is expected from an AT command, and if multiple
+    responses are accepted."""
+
+    NONE = 0
+    SINGLE = 1
+    MULTIPLE = 2
+
+
+class AtResponse:
+    code: str
+    parameters: list
+
+    def __init__(self, response: bytearray):
+        code_and_parameters = response.split(b':')
+        parameters = (
+            code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray()
+        )
+        self.code = code_and_parameters[0].decode()
+        self.parameters = at.parse_parameters(parameters)
+
+
+@dataclasses.dataclass
+class AgIndicatorState:
+    description: str
+    index: int
+    supported_values: Set[int]
+    current_status: int
+
+
+@dataclasses.dataclass
+class HfIndicatorState:
+    supported: bool = False
+    enabled: bool = False
+
+
+class HfProtocol:
+    """Implementation for the Hands-Free side of the Hands-Free profile.
+    Reference specification Hands-Free Profile v1.8"""
+
+    supported_hf_features: int
+    supported_audio_codecs: List[AudioCodec]
+
+    supported_ag_features: int
+    supported_ag_call_hold_operations: List[CallHoldOperation]
+
+    ag_indicators: List[AgIndicatorState]
+    hf_indicators: Dict[HfIndicator, HfIndicatorState]
+
+    dlc: rfcomm.DLC
+    command_lock: asyncio.Lock
+    if TYPE_CHECKING:
+        response_queue: asyncio.Queue[AtResponse]
+        unsolicited_queue: asyncio.Queue[AtResponse]
+    else:
+        response_queue: asyncio.Queue
+        unsolicited_queue: asyncio.Queue
+    read_buffer: bytearray
+
+    def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
+        # Configure internal state.
+        self.dlc = dlc
+        self.command_lock = asyncio.Lock()
+        self.response_queue = asyncio.Queue()
+        self.unsolicited_queue = asyncio.Queue()
+        self.read_buffer = bytearray()
+
+        # Build local features.
+        self.supported_hf_features = sum(configuration.supported_hf_features)
+        self.supported_audio_codecs = configuration.supported_audio_codecs
+
+        self.hf_indicators = {
+            indicator: HfIndicatorState()
+            for indicator in configuration.supported_hf_indicators
+        }
+
+        # Clear remote features.
+        self.supported_ag_features = 0
+        self.supported_ag_call_hold_operations = []
+        self.ag_indicators = []
+
+        # Bind the AT reader to the RFCOMM channel.
+        self.dlc.sink = self._read_at
+
+    def supports_hf_feature(self, feature: HfFeature) -> bool:
+        return (self.supported_hf_features & feature) != 0
+
+    def supports_ag_feature(self, feature: AgFeature) -> bool:
+        return (self.supported_ag_features & feature) != 0
+
+    # Read AT messages from the RFCOMM channel.
+    # Enqueue AT commands, responses, unsolicited responses to their
+    # respective queues, and set the corresponding event.
+    def _read_at(self, data: bytes):
+        # Append to the read buffer.
+        self.read_buffer.extend(data)
+
+        # Locate header and trailer.
+        header = self.read_buffer.find(b'\r\n')
+        trailer = self.read_buffer.find(b'\r\n', header + 2)
+        if header == -1 or trailer == -1:
+            return
+
+        # Isolate the AT response code and parameters.
+        raw_response = self.read_buffer[header + 2 : trailer]
+        response = AtResponse(raw_response)
+        logger.debug(f"<<< {raw_response.decode()}")
+
+        # Consume the response bytes.
+        self.read_buffer = self.read_buffer[trailer + 2 :]
+
+        # Forward the received code to the correct queue.
+        if self.command_lock.locked() and (
+            response.code in STATUS_CODES or response.code in RESPONSE_CODES
+        ):
+            self.response_queue.put_nowait(response)
+        elif response.code in UNSOLICITED_CODES:
+            self.unsolicited_queue.put_nowait(response)
+        else:
+            logger.warning(f"dropping unexpected response with code '{response.code}'")
+
+    # Send an AT command and wait for the peer response.
+    # Wait for the AT responses sent by the peer, to the status code.
+    # Raises asyncio.TimeoutError if the status is not received
+    # after a timeout (default 1 second).
+    # Raises ProtocolError if the status is not OK.
+    async def execute_command(
+        self,
+        cmd: str,
+        timeout: float = 1.0,
+        response_type: AtResponseType = AtResponseType.NONE,
+    ) -> Union[None, AtResponse, List[AtResponse]]:
+        async with self.command_lock:
+            logger.debug(f">>> {cmd}")
+            self.dlc.write(cmd + '\r')
+            responses: List[AtResponse] = []
+
+            while True:
+                result = await asyncio.wait_for(
+                    self.response_queue.get(), timeout=timeout
+                )
+                if result.code == 'OK':
+                    if response_type == AtResponseType.SINGLE and len(responses) != 1:
+                        raise HfpProtocolError("NO ANSWER")
+
+                    if response_type == AtResponseType.MULTIPLE:
+                        return responses
+                    if response_type == AtResponseType.SINGLE:
+                        return responses[0]
+                    return None
+                if result.code in STATUS_CODES:
+                    raise HfpProtocolError(result.code)
+                responses.append(result)
+
+    # 4.2.1 Service Level Connection Initialization.
+    async def initiate_slc(self):
+        # 4.2.1.1 Supported features exchange
+        # First, in the initialization procedure, the HF shall send the
+        # AT+BRSF=<HF supported features> command to the AG to both notify
+        # the AG of the supported features in the HF, as well as to retrieve the
+        # supported features in the AG using the +BRSF result code.
+        response = await self.execute_command(
+            f"AT+BRSF={self.supported_hf_features}", response_type=AtResponseType.SINGLE
+        )
+
+        self.supported_ag_features = int(response.parameters[0])
+        logger.info(f"supported AG features: {self.supported_ag_features}")
+        for feature in AgFeature:
+            if self.supports_ag_feature(feature):
+                logger.info(f"  - {feature.name}")
+
+        # 4.2.1.2 Codec Negotiation
+        # Secondly, in the initialization procedure, if the HF supports the
+        # Codec Negotiation feature, it shall check if the AT+BRSF command
+        # response from the AG has indicated that it supports the Codec
+        # Negotiation feature.
+        if self.supports_hf_feature(
+            HfFeature.CODEC_NEGOTIATION
+        ) and self.supports_ag_feature(AgFeature.CODEC_NEGOTIATION):
+            # If both the HF and AG do support the Codec Negotiation feature
+            # then the HF shall send the AT+BAC=<HF available codecs> command to
+            # the AG to notify the AG of the available codecs in the HF.
+            codecs = [str(c) for c in self.supported_audio_codecs]
+            await self.execute_command(f"AT+BAC={','.join(codecs)}")
+
+        # 4.2.1.3 AG Indicators
+        # After having retrieved the supported features in the AG, the HF shall
+        # determine which indicators are supported by the AG, as well as the
+        # ordering of the supported indicators. This is because, according to
+        # the 3GPP 27.007 specification [2], the AG may support additional
+        # indicators not provided for by the Hands-Free Profile, and because the
+        # ordering of the indicators is implementation specific. The HF uses
+        # the AT+CIND=? Test command to retrieve information about the supported
+        # indicators and their ordering.
+        response = await self.execute_command(
+            "AT+CIND=?", response_type=AtResponseType.SINGLE
+        )
+
+        self.ag_indicators = []
+        for index, indicator in enumerate(response.parameters):
+            description = indicator[0].decode()
+            supported_values = []
+            for value in indicator[1]:
+                value = value.split(b'-')
+                value = [int(v) for v in value]
+                value_min = value[0]
+                value_max = value[1] if len(value) > 1 else value[0]
+                supported_values.extend([v for v in range(value_min, value_max + 1)])
+
+            self.ag_indicators.append(
+                AgIndicatorState(description, index, set(supported_values), 0)
+            )
+
+        # Once the HF has the necessary supported indicator and ordering
+        # information, it shall retrieve the current status of the indicators
+        # in the AG using the AT+CIND? Read command.
+        response = await self.execute_command(
+            "AT+CIND?", response_type=AtResponseType.SINGLE
+        )
+
+        for index, indicator in enumerate(response.parameters):
+            self.ag_indicators[index].current_status = int(indicator)
+
+        # After having retrieved the status of the indicators in the AG, the HF
+        # shall then enable the "Indicators status update" function in the AG by
+        # issuing the AT+CMER command, to which the AG shall respond with OK.
+        await self.execute_command("AT+CMER=3,,,1")
+
+        if self.supports_hf_feature(
+            HfFeature.THREE_WAY_CALLING
+        ) and self.supports_ag_feature(HfFeature.THREE_WAY_CALLING):
+            # After the HF has enabled the “Indicators status update” function in
+            # the AG, and if the “Call waiting and 3-way calling” bit was set in the
+            # supported features bitmap by both the HF and the AG, the HF shall
+            # issue the AT+CHLD=? test command to retrieve the information about how
+            # the call hold and multiparty services are supported in the AG. The HF
+            # shall not issue the AT+CHLD=? test command in case either the HF or
+            # the AG does not support the "Three-way calling" feature.
+            response = await self.execute_command(
+                "AT+CHLD=?", response_type=AtResponseType.SINGLE
+            )
+
+            self.supported_ag_call_hold_operations = [
+                CallHoldOperation(int(operation))
+                for operation in response.parameters[0]
+                if not b'x' in operation
+            ]
+
+        # 4.2.1.4 HF Indicators
+        # If the HF supports the HF indicator feature, it shall check the +BRSF
+        # response to see if the AG also supports the HF Indicator feature.
+        if self.supports_hf_feature(
+            HfFeature.HF_INDICATORS
+        ) and self.supports_ag_feature(AgFeature.HF_INDICATORS):
+            # If both the HF and AG support the HF Indicator feature, then the HF
+            # shall send the AT+BIND=<HF supported HF indicators> command to the AG
+            # to notify the AG of the supported indicators’ assigned numbers in the
+            # HF. The AG shall respond with OK
+            indicators = [str(i) for i in self.hf_indicators.keys()]
+            await self.execute_command(f"AT+BIND={','.join(indicators)}")
+
+            # After having provided the AG with the HF indicators it supports,
+            # the HF shall send the AT+BIND=? to request HF indicators supported
+            # by the AG. The AG shall reply with the +BIND response listing all
+            # HF indicators that it supports followed by an OK.
+            response = await self.execute_command(
+                "AT+BIND=?", response_type=AtResponseType.SINGLE
+            )
+
+            logger.info("supported HF indicators:")
+            for indicator in response.parameters[0]:
+                indicator = HfIndicator(int(indicator))
+                logger.info(f"  - {indicator.name}")
+                if indicator in self.hf_indicators:
+                    self.hf_indicators[indicator].supported = True
+
+            # Once the HF receives the supported HF indicators list from the AG,
+            # the HF shall send the AT+BIND? command to determine which HF
+            # indicators are enabled. The AG shall respond with one or more
+            # +BIND responses. The AG shall terminate the list with OK.
+            # (See Section 4.36.1.3).
+            responses = await self.execute_command(
+                "AT+BIND?", response_type=AtResponseType.MULTIPLE
+            )
+
+            logger.info("enabled HF indicators:")
+            for response in responses:
+                indicator = HfIndicator(int(response.parameters[0]))
+                enabled = int(response.parameters[1]) != 0
+                logger.info(f"  - {indicator.name}: {enabled}")
+                if indicator in self.hf_indicators:
+                    self.hf_indicators[indicator].enabled = True
+
+        logger.info("SLC setup completed")
+
+    # 4.11.2 Audio Connection Setup by HF
+    async def setup_audio_connection(self):
+        # When the HF triggers the establishment of the Codec Connection it
+        # shall send the AT command AT+BCC to the AG. The AG shall respond with
+        # OK if it will start the Codec Connection procedure, and with ERROR
+        # if it cannot start the Codec Connection procedure.
+        await self.execute_command("AT+BCC")
+
+    # 4.11.3 Codec Connection Setup
+    async def setup_codec_connection(self, codec_id: int):
+        # The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
+        # The HF shall then respond to the incoming unsolicited response with
+        # the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
+        # unsolicited response code as long as the ID is supported.
+        # If the received ID is not available, the HF shall respond with
+        # AT+BAC with its available codecs.
+        if codec_id not in self.supported_audio_codecs:
+            codecs = [str(c) for c in self.supported_audio_codecs]
+            await self.execute_command(f"AT+BAC={','.join(codecs)}")
+            return
+
+        await self.execute_command(f"AT+BCS={codec_id}")
+
+        # After sending the OK response, the AG shall open the
+        # Synchronous Connection with the settings that are determined by the
+        # ID. The HF shall be ready to accept the synchronous connection
+        # establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
+
+        logger.info("codec connection setup completed")
+
+    # 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
+    async def answer_incoming_call(self):
+        # The user accepts the incoming voice call by using the proper means
+        # provided by the HF. The HF shall then send the ATA command
+        # (see Section 4.34) to the AG. The AG shall then begin the procedure for
+        # accepting the incoming call.
+        await self.execute_command("ATA")
+
+    # 4.14.1 Reject an Incoming Call from the HF
+    async def reject_incoming_call(self):
+        # The user rejects the incoming call by using the User Interface on the
+        # Hands-Free unit. The HF shall then send the AT+CHUP command
+        # (see Section 4.34) to the AG. This may happen at any time during the
+        # procedures described in Sections 4.13.1 and 4.13.2.
+        await self.execute_command("AT+CHUP")
+
+    # 4.15.1 Terminate a Call Process from the HF
+    async def terminate_call(self):
+        # The user may abort the ongoing call process using whatever means
+        # provided by the Hands-Free unit. The HF shall send AT+CHUP command
+        # (see Section 4.34) to the AG, and the AG shall then start the
+        # procedure to terminate or interrupt the current call procedure.
+        # The AG shall then send the OK indication followed by the +CIEV result
+        # code, with the value indicating (call=0).
+        await self.execute_command("AT+CHUP")
+
+    async def update_ag_indicator(self, index: int, value: int):
+        self.ag_indicators[index].current_status = value
+        logger.info(
+            f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
+        )
+
+    async def handle_unsolicited(self):
+        """Handle unsolicited result codes sent by the audio gateway."""
+        result = await self.unsolicited_queue.get()
+        if result.code == "+BCS":
+            await self.setup_codec_connection(int(result.parameters[0]))
+        elif result.code == "+CIEV":
+            await self.update_ag_indicator(
+                int(result.parameters[0]), int(result.parameters[1])
+            )
+        else:
+            logging.info(f"unhandled unsolicited response {result.code}")
+
+    async def run(self):
+        """Main rountine for the Hands-Free side of the HFP protocol.
+        Initiates the service level connection then loops handling
+        unsolicited AG responses."""
+
+        try:
+            await self.initiate_slc()
+            while True:
+                await self.handle_unsolicited()
+        except Exception:
+            logger.error("HFP-HF protocol failed with the following error:")
+            logger.error(traceback.format_exc())
+
+
+# -----------------------------------------------------------------------------
+# Normative SDP definitions
+# -----------------------------------------------------------------------------
+
+
+# Profile version (normative).
+# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
+class ProfileVersion(enum.IntEnum):
+    V1_5 = 0x0105
+    V1_6 = 0x0106
+    V1_7 = 0x0107
+    V1_8 = 0x0108
+    V1_9 = 0x0109
+
+
+# HF supported features (normative).
+# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
+class HfSdpFeature(enum.IntFlag):
+    EC_NR = 0x01  # Echo Cancel & Noise reduction
+    THREE_WAY_CALLING = 0x02
+    CLI_PRESENTATION_CAPABILITY = 0x04
+    VOICE_RECOGNITION_ACTIVATION = 0x08
+    REMOTE_VOLUME_CONTROL = 0x10
+    WIDE_BAND = 0x20  # Wide band speech
+    ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
+    VOICE_RECOGNITION_TEST = 0x80
+
+
+# AG supported features (normative).
+# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
+class AgSdpFeature(enum.IntFlag):
+    THREE_WAY_CALLING = 0x01
+    EC_NR = 0x02  # Echo Cancel & Noise reduction
+    VOICE_RECOGNITION_FUNCTION = 0x04
+    IN_BAND_RING_TONE_CAPABILITY = 0x08
+    VOICE_TAG = 0x10  # Attach a number to voice tag
+    WIDE_BAND = 0x20  # Wide band speech
+    ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
+    VOICE_RECOGNITION_TEST = 0x80
+
+
+def sdp_records(
+    service_record_handle: int, rfcomm_channel: int, configuration: Configuration
+) -> List[ServiceAttribute]:
+    """Generate the SDP record for HFP Hands-Free support.
+    The record exposes the features supported in the input configuration,
+    and the allocated RFCOMM channel."""
+
+    hf_supported_features = 0
+
+    if HfFeature.EC_NR in configuration.supported_hf_features:
+        hf_supported_features |= HfSdpFeature.EC_NR
+    if HfFeature.THREE_WAY_CALLING in configuration.supported_hf_features:
+        hf_supported_features |= HfSdpFeature.THREE_WAY_CALLING
+    if HfFeature.CLI_PRESENTATION_CAPABILITY in configuration.supported_hf_features:
+        hf_supported_features |= HfSdpFeature.CLI_PRESENTATION_CAPABILITY
+    if HfFeature.VOICE_RECOGNITION_ACTIVATION in configuration.supported_hf_features:
+        hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_ACTIVATION
+    if HfFeature.REMOTE_VOLUME_CONTROL in configuration.supported_hf_features:
+        hf_supported_features |= HfSdpFeature.REMOTE_VOLUME_CONTROL
+    if (
+        HfFeature.ENHANCED_VOICE_RECOGNITION_STATUS
+        in configuration.supported_hf_features
+    ):
+        hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
+    if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
+        hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
+
+    if AudioCodec.MSBC in configuration.supported_audio_codecs:
+        hf_supported_features |= HfSdpFeature.WIDE_BAND
+
+    return [
+        ServiceAttribute(
+            SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+            DataElement.unsigned_integer_32(service_record_handle),
+        ),
+        ServiceAttribute(
+            SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.uuid(BT_HANDSFREE_SERVICE),
+                    DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
+                            DataElement.unsigned_integer_8(rfcomm_channel),
+                        ]
+                    ),
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_HANDSFREE_SERVICE),
+                            DataElement.unsigned_integer_16(ProfileVersion.V1_8),
+                        ]
+                    )
+                ]
+            ),
+        ),
+        ServiceAttribute(
+            SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+            DataElement.unsigned_integer_16(hf_supported_features),
+        ),
+    ]
diff --git a/bumble/host.py b/bumble/host.py
index afde2ee..02caa46 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -15,22 +15,24 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
 import asyncio
 import collections
 import logging
 import struct
 
+from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
+
 from bumble.colors import color
 from bumble.l2cap import L2CAP_PDU
 from bumble.snoop import Snooper
-
-from typing import Optional
+from bumble import drivers
 
 from .hci import (
     Address,
     HCI_ACL_DATA_PACKET,
-    HCI_COMMAND_COMPLETE_EVENT,
     HCI_COMMAND_PACKET,
+    HCI_COMMAND_COMPLETE_EVENT,
     HCI_EVENT_PACKET,
     HCI_LE_READ_BUFFER_SIZE_COMMAND,
     HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
@@ -44,8 +46,11 @@
     HCI_VERSION_BLUETOOTH_CORE_4_0,
     HCI_AclDataPacket,
     HCI_AclDataPacketAssembler,
+    HCI_Command,
+    HCI_Command_Complete_Event,
     HCI_Constant,
     HCI_Error,
+    HCI_Event,
     HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
     HCI_LE_Long_Term_Key_Request_Reply_Command,
     HCI_LE_Read_Buffer_Size_Command,
@@ -65,12 +70,16 @@
 )
 from .core import (
     BT_BR_EDR_TRANSPORT,
-    BT_CENTRAL_ROLE,
     BT_LE_TRANSPORT,
     ConnectionPHY,
     ConnectionParameters,
+    InvalidStateError,
 )
 from .utils import AbortableEventEmitter
+from .transport.common import TransportLostError
+
+if TYPE_CHECKING:
+    from .transport.common import TransportSink, TransportSource
 
 
 # -----------------------------------------------------------------------------
@@ -94,27 +103,39 @@
 
 # -----------------------------------------------------------------------------
 class Connection:
-    def __init__(self, host, handle, peer_address, transport):
+    def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
         self.host = host
         self.handle = handle
         self.peer_address = peer_address
         self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
         self.transport = transport
 
-    def on_hci_acl_data_packet(self, packet):
+    def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
         self.assembler.feed_packet(packet)
 
-    def on_acl_pdu(self, pdu):
+    def on_acl_pdu(self, pdu: bytes) -> None:
         l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
         self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
 
 
 # -----------------------------------------------------------------------------
 class Host(AbortableEventEmitter):
-    def __init__(self, controller_source=None, controller_sink=None):
+    connections: Dict[int, Connection]
+    acl_packet_queue: collections.deque[HCI_AclDataPacket]
+    hci_sink: TransportSink
+    long_term_key_provider: Optional[
+        Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
+    ]
+    link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
+
+    def __init__(
+        self,
+        controller_source: Optional[TransportSource] = None,
+        controller_sink: Optional[TransportSink] = None,
+    ) -> None:
         super().__init__()
 
-        self.hci_sink = None
+        self.hci_metadata = None
         self.ready = False  # True when we can accept incoming packets
         self.reset_done = False
         self.connections = {}  # Connections, by connection handle
@@ -140,6 +161,9 @@
         # Connect to the source and sink if specified
         if controller_source:
             controller_source.set_packet_sink(self)
+            self.hci_metadata = getattr(
+                controller_source, 'metadata', self.hci_metadata
+            )
         if controller_sink:
             self.set_packet_sink(controller_sink)
 
@@ -169,7 +193,7 @@
         self.emit('flush')
         self.command_semaphore.release()
 
-    async def reset(self):
+    async def reset(self, driver_factory=drivers.get_driver_for_host):
         if self.ready:
             self.ready = False
             await self.flush()
@@ -177,6 +201,15 @@
         await self.send_command(HCI_Reset_Command(), check_result=True)
         self.ready = True
 
+        # Instantiate and init a driver for the host if needed.
+        # NOTE: we don't keep a reference to the driver here, because we don't
+        # currently have a need for the driver later on. But if the driver interface
+        # evolves, it may be required, then, to store a reference to the driver in
+        # an object property.
+        if driver_factory is not None:
+            if driver := await driver_factory(self):
+                await driver.init_controller()
+
         response = await self.send_command(
             HCI_Read_Local_Supported_Commands_Command(), check_result=True
         )
@@ -281,7 +314,7 @@
         self.reset_done = True
 
     @property
-    def controller(self):
+    def controller(self) -> TransportSink:
         return self.hci_sink
 
     @controller.setter
@@ -290,14 +323,13 @@
         if controller:
             controller.set_packet_sink(self)
 
-    def set_packet_sink(self, sink):
+    def set_packet_sink(self, sink: TransportSink) -> None:
         self.hci_sink = sink
 
-    def send_hci_packet(self, packet):
+    def send_hci_packet(self, packet: HCI_Packet) -> None:
         if self.snooper:
             self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
-
-        self.hci_sink.on_packet(packet.to_bytes())
+        self.hci_sink.on_packet(bytes(packet))
 
     async def send_command(self, command, check_result=False):
         logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
@@ -334,7 +366,7 @@
                 return response
             except Exception as error:
                 logger.warning(
-                    f'{color("!!! Exception while sending HCI packet:", "red")} {error}'
+                    f'{color("!!! Exception while sending command:", "red")} {error}'
                 )
                 raise error
             finally:
@@ -342,14 +374,14 @@
                 self.pending_response = None
 
     # Use this method to send a command from a task
-    def send_command_sync(self, command):
-        async def send_command(command):
+    def send_command_sync(self, command: HCI_Command) -> None:
+        async def send_command(command: HCI_Command) -> None:
             await self.send_command(command)
 
         asyncio.create_task(send_command(command))
 
-    def send_l2cap_pdu(self, connection_handle, cid, pdu):
-        l2cap_pdu = L2CAP_PDU(cid, pdu).to_bytes()
+    def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
+        l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
 
         # Send the data to the controller via ACL packets
         bytes_remaining = len(l2cap_pdu)
@@ -373,7 +405,7 @@
             offset += data_total_length
             bytes_remaining -= data_total_length
 
-    def queue_acl_packet(self, acl_packet):
+    def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
         self.acl_packet_queue.appendleft(acl_packet)
         self.check_acl_packet_queue()
 
@@ -383,7 +415,7 @@
                 f'{len(self.acl_packet_queue)} in queue'
             )
 
-    def check_acl_packet_queue(self):
+    def check_acl_packet_queue(self) -> None:
         # Send all we can (TODO: support different LE/Classic limits)
         while (
             len(self.acl_packet_queue) > 0
@@ -429,47 +461,53 @@
         ]
 
     # Packet Sink protocol (packets coming from the controller via HCI)
-    def on_packet(self, packet):
+    def on_packet(self, packet: bytes) -> None:
         hci_packet = HCI_Packet.from_bytes(packet)
         if self.ready or (
-            hci_packet.hci_packet_type == HCI_EVENT_PACKET
-            and hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT
+            isinstance(hci_packet, HCI_Command_Complete_Event)
             and hci_packet.command_opcode == HCI_RESET_COMMAND
         ):
             self.on_hci_packet(hci_packet)
         else:
             logger.debug('reset not done, ignoring packet from controller')
 
-    def on_hci_packet(self, packet):
+    def on_transport_lost(self):
+        # Called by the source when the transport has been lost.
+        if self.pending_response:
+            self.pending_response.set_exception(TransportLostError('transport lost'))
+
+        self.emit('flush')
+
+    def on_hci_packet(self, packet: HCI_Packet) -> None:
         logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
 
         if self.snooper:
             self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
 
         # If the packet is a command, invoke the handler for this packet
-        if packet.hci_packet_type == HCI_COMMAND_PACKET:
+        if isinstance(packet, HCI_Command):
             self.on_hci_command_packet(packet)
-        elif packet.hci_packet_type == HCI_EVENT_PACKET:
+        elif isinstance(packet, HCI_Event):
             self.on_hci_event_packet(packet)
-        elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
+        elif isinstance(packet, HCI_AclDataPacket):
             self.on_hci_acl_data_packet(packet)
         else:
             logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
 
-    def on_hci_command_packet(self, command):
+    def on_hci_command_packet(self, command: HCI_Command) -> None:
         logger.warning(f'!!! unexpected command packet: {command}')
 
-    def on_hci_event_packet(self, event):
+    def on_hci_event_packet(self, event: HCI_Event) -> None:
         handler_name = f'on_{event.name.lower()}'
         handler = getattr(self, handler_name, self.on_hci_event)
         handler(event)
 
-    def on_hci_acl_data_packet(self, packet):
+    def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
         # Look for the connection to which this data belongs
         if connection := self.connections.get(packet.connection_handle):
             connection.on_hci_acl_data_packet(packet)
 
-    def on_l2cap_pdu(self, connection, cid, pdu):
+    def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
         self.emit('l2cap_pdu', connection.handle, cid, pdu)
 
     def on_command_processed(self, event):
@@ -807,6 +845,10 @@
             f'simple pairing complete for {event.bd_addr}: '
             f'status={HCI_Constant.status_name(event.status)}'
         )
+        if event.status == HCI_SUCCESS:
+            self.emit('classic_pairing', event.bd_addr)
+        else:
+            self.emit('classic_pairing_failure', event.bd_addr, event.status)
 
     def on_hci_pin_code_request_event(self, event):
         self.emit('pin_code_request', event.bd_addr)
@@ -887,7 +929,12 @@
         if event.status != HCI_SUCCESS:
             self.emit('remote_name_failure', event.bd_addr, event.status)
         else:
-            self.emit('remote_name', event.bd_addr, event.remote_name)
+            utf8_name = event.remote_name
+            terminator = utf8_name.find(0)
+            if terminator >= 0:
+                utf8_name = utf8_name[0:terminator]
+
+            self.emit('remote_name', event.bd_addr, utf8_name)
 
     def on_hci_remote_host_supported_features_notification_event(self, event):
         self.emit(
diff --git a/bumble/keys.py b/bumble/keys.py
index a30e753..198d5c4 100644
--- a/bumble/keys.py
+++ b/bumble/keys.py
@@ -190,10 +190,44 @@
 
 # -----------------------------------------------------------------------------
 class JsonKeyStore(KeyStore):
+    """
+    KeyStore implementation that is backed by a JSON file.
+
+    This implementation supports storing a hierarchy of key sets in a single file.
+    A key set is a representation of a PairingKeys object. Each key set is stored
+    in a map, with the address of paired peer as the key. Maps are themselves grouped
+    into namespaces, grouping pairing keys by controller addresses.
+    The JSON object model looks like:
+    {
+        "<namespace>": {
+            "peer-address": {
+                "address_type": <n>,
+                "irk" : {
+                    "authenticated": <true/false>,
+                    "value": "hex-encoded-key"
+                },
+                ... other keys ...
+            },
+            ... other peers ...
+        }
+        ... other namespaces ...
+    }
+
+    A namespace is typically the BD_ADDR of a controller, since that is a convenient
+    unique identifier, but it may be something else.
+    A special namespace, called the "default" namespace, is used when instantiating this
+    class without a namespace. With the default namespace, reading from a file will
+    load an existing namespace if there is only one, which may be convenient for reading
+    from a file with a single key set and for which the namespace isn't known. If the
+    file does not include any existing key set, or if there are more than one and none
+    has the default name, a new one will be created with the name "__DEFAULT__".
+    """
+
     APP_NAME = 'Bumble'
     APP_AUTHOR = 'Google'
     KEYS_DIR = 'Pairing'
     DEFAULT_NAMESPACE = '__DEFAULT__'
+    DEFAULT_BASE_NAME = "keys"
 
     def __init__(self, namespace, filename=None):
         self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
@@ -208,8 +242,9 @@
             self.directory_name = os.path.join(
                 appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
             )
+            base_name = self.DEFAULT_BASE_NAME if namespace is None else self.namespace
             json_filename = (
-                f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p')
+                f'{base_name}.json'.lower().replace(':', '-').replace('/p', '-p')
             )
             self.filename = os.path.join(self.directory_name, json_filename)
         else:
@@ -219,11 +254,13 @@
         logger.debug(f'JSON keystore: {self.filename}')
 
     @staticmethod
-    def from_device(device: Device) -> Optional[JsonKeyStore]:
-        if not device.config.keystore:
-            return None
-
-        params = device.config.keystore.split(':', 1)[1:]
+    def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]:
+        if not filename:
+            # Extract the filename from the config if there is one
+            if device.config.keystore is not None:
+                params = device.config.keystore.split(':', 1)[1:]
+                if params:
+                    filename = params[0]
 
         # Use a namespace based on the device address
         if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
@@ -232,19 +269,31 @@
             namespace = str(device.random_address)
         else:
             namespace = JsonKeyStore.DEFAULT_NAMESPACE
-        if params:
-            filename = params[0]
-        else:
-            filename = None
 
         return JsonKeyStore(namespace, filename)
 
     async def load(self):
+        # Try to open the file, without failing. If the file does not exist, it
+        # will be created upon saving.
         try:
             with open(self.filename, 'r', encoding='utf-8') as json_file:
-                return json.load(json_file)
+                db = json.load(json_file)
         except FileNotFoundError:
-            return {}
+            db = {}
+
+        # First, look for a namespace match
+        if self.namespace in db:
+            return (db, db[self.namespace])
+
+        # Then, if the namespace is the default namespace, and there's
+        # only one entry in the db, use that
+        if self.namespace == self.DEFAULT_NAMESPACE and len(db) == 1:
+            return next(iter(db.items()))
+
+        # Finally, just create an empty key map for the namespace
+        key_map = {}
+        db[self.namespace] = key_map
+        return (db, key_map)
 
     async def save(self, db):
         # Create the directory if it doesn't exist
@@ -260,53 +309,30 @@
         os.replace(temp_filename, self.filename)
 
     async def delete(self, name: str) -> None:
-        db = await self.load()
-
-        namespace = db.get(self.namespace)
-        if namespace is None:
-            raise KeyError(name)
-
-        del namespace[name]
+        db, key_map = await self.load()
+        del key_map[name]
         await self.save(db)
 
     async def update(self, name, keys):
-        db = await self.load()
-
-        namespace = db.setdefault(self.namespace, {})
-        namespace.setdefault(name, {}).update(keys.to_dict())
-
+        db, key_map = await self.load()
+        key_map.setdefault(name, {}).update(keys.to_dict())
         await self.save(db)
 
     async def get_all(self):
-        db = await self.load()
-
-        namespace = db.get(self.namespace)
-        if namespace is None:
-            return []
-
-        return [
-            (name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items()
-        ]
+        _, key_map = await self.load()
+        return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
 
     async def delete_all(self):
-        db = await self.load()
-
-        db.pop(self.namespace, None)
-
+        db, key_map = await self.load()
+        key_map.clear()
         await self.save(db)
 
     async def get(self, name: str) -> Optional[PairingKeys]:
-        db = await self.load()
-
-        namespace = db.get(self.namespace)
-        if namespace is None:
+        _, key_map = await self.load()
+        if name not in key_map:
             return None
 
-        keys = namespace.get(name)
-        if keys is None:
-            return None
-
-        return PairingKeys.from_dict(keys)
+        return PairingKeys.from_dict(key_map[name])
 
 
 # -----------------------------------------------------------------------------
diff --git a/bumble/l2cap.py b/bumble/l2cap.py
index ef7fdab..cccb172 100644
--- a/bumble/l2cap.py
+++ b/bumble/l2cap.py
@@ -17,12 +17,26 @@
 # -----------------------------------------------------------------------------
 from __future__ import annotations
 import asyncio
+import enum
 import logging
 import struct
 
 from collections import deque
 from pyee import EventEmitter
-from typing import Dict, Type
+from typing import (
+    Dict,
+    Type,
+    List,
+    Optional,
+    Tuple,
+    Callable,
+    Any,
+    Union,
+    Deque,
+    Iterable,
+    SupportsBytes,
+    TYPE_CHECKING,
+)
 
 from .colors import color
 from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
@@ -33,6 +47,10 @@
     name_or_number,
 )
 
+if TYPE_CHECKING:
+    from bumble.device import Connection
+    from bumble.host import Host
+
 # -----------------------------------------------------------------------------
 # Logging
 # -----------------------------------------------------------------------------
@@ -155,7 +173,7 @@
     '''
 
     @staticmethod
-    def from_bytes(data):
+    def from_bytes(data: bytes) -> L2CAP_PDU:
         # Sanity check
         if len(data) < 4:
             raise ValueError('not enough data for L2CAP header')
@@ -165,18 +183,18 @@
 
         return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
 
-    def to_bytes(self):
+    def to_bytes(self) -> bytes:
         header = struct.pack('<HH', len(self.payload), self.cid)
         return header + self.payload
 
-    def __init__(self, cid, payload):
+    def __init__(self, cid: int, payload: bytes) -> None:
         self.cid = cid
         self.payload = payload
 
-    def __bytes__(self):
+    def __bytes__(self) -> bytes:
         return self.to_bytes()
 
-    def __str__(self):
+    def __str__(self) -> str:
         return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
 
 
@@ -188,10 +206,10 @@
 
     classes: Dict[int, Type[L2CAP_Control_Frame]] = {}
     code = 0
-    name = None
+    name: str
 
     @staticmethod
-    def from_bytes(pdu):
+    def from_bytes(pdu: bytes) -> L2CAP_Control_Frame:
         code = pdu[0]
 
         cls = L2CAP_Control_Frame.classes.get(code)
@@ -216,11 +234,11 @@
         return self
 
     @staticmethod
-    def code_name(code):
+    def code_name(code: int) -> str:
         return name_or_number(L2CAP_CONTROL_FRAME_NAMES, code)
 
     @staticmethod
-    def decode_configuration_options(data):
+    def decode_configuration_options(data: bytes) -> List[Tuple[int, bytes]]:
         options = []
         while len(data) >= 2:
             value_type = data[0]
@@ -232,7 +250,7 @@
         return options
 
     @staticmethod
-    def encode_configuration_options(options):
+    def encode_configuration_options(options: List[Tuple[int, bytes]]) -> bytes:
         return b''.join(
             [bytes([option[0], len(option[1])]) + option[1] for option in options]
         )
@@ -256,29 +274,30 @@
 
         return inner
 
-    def __init__(self, pdu=None, **kwargs):
+    def __init__(self, pdu=None, **kwargs) -> None:
         self.identifier = kwargs.get('identifier', 0)
-        if hasattr(self, 'fields') and kwargs:
-            HCI_Object.init_from_fields(self, self.fields, kwargs)
-        if pdu is None:
-            data = HCI_Object.dict_to_bytes(kwargs, self.fields)
-            pdu = (
-                bytes([self.code, self.identifier])
-                + struct.pack('<H', len(data))
-                + data
-            )
+        if hasattr(self, 'fields'):
+            if kwargs:
+                HCI_Object.init_from_fields(self, self.fields, kwargs)
+            if pdu is None:
+                data = HCI_Object.dict_to_bytes(kwargs, self.fields)
+                pdu = (
+                    bytes([self.code, self.identifier])
+                    + struct.pack('<H', len(data))
+                    + data
+                )
         self.pdu = pdu
 
     def init_from_bytes(self, pdu, offset):
         return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
 
-    def to_bytes(self):
+    def to_bytes(self) -> bytes:
         return self.pdu
 
-    def __bytes__(self):
+    def __bytes__(self) -> bytes:
         return self.to_bytes()
 
-    def __str__(self):
+    def __str__(self) -> str:
         result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
         if fields := getattr(self, 'fields', None):
             result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, '  ')
@@ -315,7 +334,7 @@
     }
 
     @staticmethod
-    def reason_name(reason):
+    def reason_name(reason: int) -> str:
         return name_or_number(L2CAP_Command_Reject.REASON_NAMES, reason)
 
 
@@ -343,7 +362,7 @@
     '''
 
     @staticmethod
-    def parse_psm(data, offset=0):
+    def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]:
         psm_length = 2
         psm = data[offset] | data[offset + 1] << 8
 
@@ -355,7 +374,7 @@
         return offset + psm_length, psm
 
     @staticmethod
-    def serialize_psm(psm):
+    def serialize_psm(psm: int) -> bytes:
         serialized = struct.pack('<H', psm & 0xFFFF)
         psm >>= 16
         while psm:
@@ -405,7 +424,7 @@
     }
 
     @staticmethod
-    def result_name(result):
+    def result_name(result: int) -> str:
         return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
 
 
@@ -452,7 +471,7 @@
     }
 
     @staticmethod
-    def result_name(result):
+    def result_name(result: int) -> str:
         return name_or_number(L2CAP_Configure_Response.RESULT_NAMES, result)
 
 
@@ -529,7 +548,7 @@
     }
 
     @staticmethod
-    def info_type_name(info_type):
+    def info_type_name(info_type: int) -> str:
         return name_or_number(L2CAP_Information_Request.INFO_TYPE_NAMES, info_type)
 
 
@@ -556,7 +575,7 @@
     RESULT_NAMES = {SUCCESS: 'SUCCESS', NOT_SUPPORTED: 'NOT_SUPPORTED'}
 
     @staticmethod
-    def result_name(result):
+    def result_name(result: int) -> str:
         return name_or_number(L2CAP_Information_Response.RESULT_NAMES, result)
 
 
@@ -588,6 +607,8 @@
     (CODE 0x14)
     '''
 
+    source_cid: int
+
 
 # -----------------------------------------------------------------------------
 @L2CAP_Control_Frame.subclass(
@@ -640,7 +661,7 @@
     }
 
     @staticmethod
-    def result_name(result):
+    def result_name(result: int) -> str:
         return name_or_number(
             L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result
         )
@@ -656,57 +677,51 @@
 
 # -----------------------------------------------------------------------------
 class Channel(EventEmitter):
-    # States
-    CLOSED = 0x00
-    WAIT_CONNECT = 0x01
-    WAIT_CONNECT_RSP = 0x02
-    OPEN = 0x03
-    WAIT_DISCONNECT = 0x04
-    WAIT_CREATE = 0x05
-    WAIT_CREATE_RSP = 0x06
-    WAIT_MOVE = 0x07
-    WAIT_MOVE_RSP = 0x08
-    WAIT_MOVE_CONFIRM = 0x09
-    WAIT_CONFIRM_RSP = 0x0A
+    class State(enum.IntEnum):
+        # States
+        CLOSED = 0x00
+        WAIT_CONNECT = 0x01
+        WAIT_CONNECT_RSP = 0x02
+        OPEN = 0x03
+        WAIT_DISCONNECT = 0x04
+        WAIT_CREATE = 0x05
+        WAIT_CREATE_RSP = 0x06
+        WAIT_MOVE = 0x07
+        WAIT_MOVE_RSP = 0x08
+        WAIT_MOVE_CONFIRM = 0x09
+        WAIT_CONFIRM_RSP = 0x0A
 
-    # CONFIG substates
-    WAIT_CONFIG = 0x10
-    WAIT_SEND_CONFIG = 0x11
-    WAIT_CONFIG_REQ_RSP = 0x12
-    WAIT_CONFIG_RSP = 0x13
-    WAIT_CONFIG_REQ = 0x14
-    WAIT_IND_FINAL_RSP = 0x15
-    WAIT_FINAL_RSP = 0x16
-    WAIT_CONTROL_IND = 0x17
+        # CONFIG substates
+        WAIT_CONFIG = 0x10
+        WAIT_SEND_CONFIG = 0x11
+        WAIT_CONFIG_REQ_RSP = 0x12
+        WAIT_CONFIG_RSP = 0x13
+        WAIT_CONFIG_REQ = 0x14
+        WAIT_IND_FINAL_RSP = 0x15
+        WAIT_FINAL_RSP = 0x16
+        WAIT_CONTROL_IND = 0x17
 
-    STATE_NAMES = {
-        CLOSED: 'CLOSED',
-        WAIT_CONNECT: 'WAIT_CONNECT',
-        WAIT_CONNECT_RSP: 'WAIT_CONNECT_RSP',
-        OPEN: 'OPEN',
-        WAIT_DISCONNECT: 'WAIT_DISCONNECT',
-        WAIT_CREATE: 'WAIT_CREATE',
-        WAIT_CREATE_RSP: 'WAIT_CREATE_RSP',
-        WAIT_MOVE: 'WAIT_MOVE',
-        WAIT_MOVE_RSP: 'WAIT_MOVE_RSP',
-        WAIT_MOVE_CONFIRM: 'WAIT_MOVE_CONFIRM',
-        WAIT_CONFIRM_RSP: 'WAIT_CONFIRM_RSP',
-        WAIT_CONFIG: 'WAIT_CONFIG',
-        WAIT_SEND_CONFIG: 'WAIT_SEND_CONFIG',
-        WAIT_CONFIG_REQ_RSP: 'WAIT_CONFIG_REQ_RSP',
-        WAIT_CONFIG_RSP: 'WAIT_CONFIG_RSP',
-        WAIT_CONFIG_REQ: 'WAIT_CONFIG_REQ',
-        WAIT_IND_FINAL_RSP: 'WAIT_IND_FINAL_RSP',
-        WAIT_FINAL_RSP: 'WAIT_FINAL_RSP',
-        WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
-    }
+    connection_result: Optional[asyncio.Future[None]]
+    disconnection_result: Optional[asyncio.Future[None]]
+    response: Optional[asyncio.Future[bytes]]
+    sink: Optional[Callable[[bytes], Any]]
+    state: State
+    connection: Connection
 
-    def __init__(self, manager, connection, signaling_cid, psm, source_cid, mtu):
+    def __init__(
+        self,
+        manager: ChannelManager,
+        connection: Connection,
+        signaling_cid: int,
+        psm: int,
+        source_cid: int,
+        mtu: int,
+    ) -> None:
         super().__init__()
         self.manager = manager
         self.connection = connection
         self.signaling_cid = signaling_cid
-        self.state = Channel.CLOSED
+        self.state = self.State.CLOSED
         self.mtu = mtu
         self.psm = psm
         self.source_cid = source_cid
@@ -716,30 +731,28 @@
         self.disconnection_result = None
         self.sink = None
 
-    def change_state(self, new_state):
-        logger.debug(
-            f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
-        )
+    def _change_state(self, new_state: State) -> None:
+        logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
         self.state = new_state
 
-    def send_pdu(self, pdu):
+    def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
         self.manager.send_pdu(self.connection, self.destination_cid, pdu)
 
-    def send_control_frame(self, frame):
+    def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
         self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
 
-    async def send_request(self, request):
+    async def send_request(self, request: SupportsBytes) -> bytes:
         # Check that there isn't already a request pending
         if self.response:
             raise InvalidStateError('request already pending')
-        if self.state != Channel.OPEN:
+        if self.state != self.State.OPEN:
             raise InvalidStateError('channel not open')
 
         self.response = asyncio.get_running_loop().create_future()
         self.send_pdu(request)
         return await self.response
 
-    def on_pdu(self, pdu):
+    def on_pdu(self, pdu: bytes) -> None:
         if self.response:
             self.response.set_result(pdu)
             self.response = None
@@ -751,15 +764,15 @@
                 color('received pdu without a pending request or sink', 'red')
             )
 
-    async def connect(self):
-        if self.state != Channel.CLOSED:
+    async def connect(self) -> None:
+        if self.state != self.State.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._change_state(self.State.WAIT_CONNECT_RSP)
         self.send_control_frame(
             L2CAP_Connection_Request(
                 identifier=self.manager.next_identifier(self.connection),
@@ -778,11 +791,11 @@
         finally:
             self.connection_result = None
 
-    async def disconnect(self):
-        if self.state != Channel.OPEN:
+    async def disconnect(self) -> None:
+        if self.state != self.State.OPEN:
             raise InvalidStateError('invalid state')
 
-        self.change_state(Channel.WAIT_DISCONNECT)
+        self._change_state(self.State.WAIT_DISCONNECT)
         self.send_control_frame(
             L2CAP_Disconnection_Request(
                 identifier=self.manager.next_identifier(self.connection),
@@ -796,12 +809,12 @@
         self.disconnection_result = asyncio.get_running_loop().create_future()
         return await self.disconnection_result
 
-    def abort(self):
-        if self.state == self.OPEN:
-            self.change_state(self.CLOSED)
+    def abort(self) -> None:
+        if self.state == self.State.OPEN:
+            self._change_state(self.State.CLOSED)
             self.emit('close')
 
-    def send_configure_request(self):
+    def send_configure_request(self) -> None:
         options = L2CAP_Control_Frame.encode_configuration_options(
             [
                 (
@@ -819,9 +832,9 @@
             )
         )
 
-    def on_connection_request(self, request):
+    def on_connection_request(self, request) -> None:
         self.destination_cid = request.source_cid
-        self.change_state(Channel.WAIT_CONNECT)
+        self._change_state(self.State.WAIT_CONNECT)
         self.send_control_frame(
             L2CAP_Connection_Response(
                 identifier=request.identifier,
@@ -831,24 +844,24 @@
                 status=0x0000,
             )
         )
-        self.change_state(Channel.WAIT_CONFIG)
+        self._change_state(self.State.WAIT_CONFIG)
         self.send_configure_request()
-        self.change_state(Channel.WAIT_CONFIG_REQ_RSP)
+        self._change_state(self.State.WAIT_CONFIG_REQ_RSP)
 
     def on_connection_response(self, response):
-        if self.state != Channel.WAIT_CONNECT_RSP:
+        if self.state != self.State.WAIT_CONNECT_RSP:
             logger.warning(color('invalid state', 'red'))
             return
 
         if response.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL:
             self.destination_cid = response.destination_cid
-            self.change_state(Channel.WAIT_CONFIG)
+            self._change_state(self.State.WAIT_CONFIG)
             self.send_configure_request()
-            self.change_state(Channel.WAIT_CONFIG_REQ_RSP)
+            self._change_state(self.State.WAIT_CONFIG_REQ_RSP)
         elif response.result == L2CAP_Connection_Response.CONNECTION_PENDING:
             pass
         else:
-            self.change_state(Channel.CLOSED)
+            self._change_state(self.State.CLOSED)
             self.connection_result.set_exception(
                 ProtocolError(
                     response.result,
@@ -858,11 +871,11 @@
             )
             self.connection_result = None
 
-    def on_configure_request(self, request):
+    def on_configure_request(self, request) -> None:
         if self.state not in (
-            Channel.WAIT_CONFIG,
-            Channel.WAIT_CONFIG_REQ,
-            Channel.WAIT_CONFIG_REQ_RSP,
+            self.State.WAIT_CONFIG,
+            self.State.WAIT_CONFIG_REQ,
+            self.State.WAIT_CONFIG_REQ_RSP,
         ):
             logger.warning(color('invalid state', 'red'))
             return
@@ -883,25 +896,28 @@
                 options=request.options,  # TODO: don't accept everything blindly
             )
         )
-        if self.state == Channel.WAIT_CONFIG:
-            self.change_state(Channel.WAIT_SEND_CONFIG)
+        if self.state == self.State.WAIT_CONFIG:
+            self._change_state(self.State.WAIT_SEND_CONFIG)
             self.send_configure_request()
-            self.change_state(Channel.WAIT_CONFIG_RSP)
-        elif self.state == Channel.WAIT_CONFIG_REQ:
-            self.change_state(Channel.OPEN)
+            self._change_state(self.State.WAIT_CONFIG_RSP)
+        elif self.state == self.State.WAIT_CONFIG_REQ:
+            self._change_state(self.State.OPEN)
             if self.connection_result:
                 self.connection_result.set_result(None)
                 self.connection_result = None
             self.emit('open')
-        elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
-            self.change_state(Channel.WAIT_CONFIG_RSP)
+        elif self.state == self.State.WAIT_CONFIG_REQ_RSP:
+            self._change_state(self.State.WAIT_CONFIG_RSP)
 
-    def on_configure_response(self, response):
+    def on_configure_response(self, response) -> None:
         if response.result == L2CAP_Configure_Response.SUCCESS:
-            if self.state == Channel.WAIT_CONFIG_REQ_RSP:
-                self.change_state(Channel.WAIT_CONFIG_REQ)
-            elif self.state in (Channel.WAIT_CONFIG_RSP, Channel.WAIT_CONTROL_IND):
-                self.change_state(Channel.OPEN)
+            if self.state == self.State.WAIT_CONFIG_REQ_RSP:
+                self._change_state(self.State.WAIT_CONFIG_REQ)
+            elif self.state in (
+                self.State.WAIT_CONFIG_RSP,
+                self.State.WAIT_CONTROL_IND,
+            ):
+                self._change_state(self.State.OPEN)
                 if self.connection_result:
                     self.connection_result.set_result(None)
                     self.connection_result = None
@@ -930,8 +946,8 @@
             )
             # TODO: decide how to fail gracefully
 
-    def on_disconnection_request(self, request):
-        if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
+    def on_disconnection_request(self, request) -> None:
+        if self.state in (self.State.OPEN, self.State.WAIT_DISCONNECT):
             self.send_control_frame(
                 L2CAP_Disconnection_Response(
                     identifier=request.identifier,
@@ -939,14 +955,14 @@
                     source_cid=request.source_cid,
                 )
             )
-            self.change_state(Channel.CLOSED)
+            self._change_state(self.State.CLOSED)
             self.emit('close')
             self.manager.on_channel_closed(self)
         else:
             logger.warning(color('invalid state', 'red'))
 
-    def on_disconnection_response(self, response):
-        if self.state != Channel.WAIT_DISCONNECT:
+    def on_disconnection_response(self, response) -> None:
+        if self.state != self.State.WAIT_DISCONNECT:
             logger.warning(color('invalid state', 'red'))
             return
 
@@ -957,19 +973,19 @@
             logger.warning('unexpected source or destination CID')
             return
 
-        self.change_state(Channel.CLOSED)
+        self._change_state(self.State.CLOSED)
         if self.disconnection_result:
             self.disconnection_result.set_result(None)
             self.disconnection_result = None
         self.emit('close')
         self.manager.on_channel_closed(self)
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'Channel({self.source_cid}->{self.destination_cid}, '
             f'PSM={self.psm}, '
             f'MTU={self.mtu}, '
-            f'state={Channel.STATE_NAMES[self.state]})'
+            f'state={self.state.name})'
         )
 
 
@@ -979,41 +995,36 @@
     LE Credit-based Connection Oriented Channel
     """
 
-    INIT = 0
-    CONNECTED = 1
-    CONNECTING = 2
-    DISCONNECTING = 3
-    DISCONNECTED = 4
-    CONNECTION_ERROR = 5
+    class State(enum.IntEnum):
+        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)
+    out_queue: Deque[bytes]
+    connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
+    disconnection_result: Optional[asyncio.Future[None]]
+    out_sdu: Optional[bytes]
+    state: State
+    connection: Connection
 
     def __init__(
         self,
-        manager,
-        connection,
-        le_psm,
-        source_cid,
-        destination_cid,
-        mtu,
-        mps,
-        credits,  # pylint: disable=redefined-builtin
-        peer_mtu,
-        peer_mps,
-        peer_credits,
-        connected,
-    ):
+        manager: ChannelManager,
+        connection: Connection,
+        le_psm: int,
+        source_cid: int,
+        destination_cid: int,
+        mtu: int,
+        mps: int,
+        credits: int,  # pylint: disable=redefined-builtin
+        peer_mtu: int,
+        peer_mps: int,
+        peer_credits: int,
+        connected: bool,
+    ) -> None:
         super().__init__()
         self.manager = manager
         self.connection = connection
@@ -1041,30 +1052,28 @@
         self.drained.set()
 
         if connected:
-            self.state = LeConnectionOrientedChannel.CONNECTED
+            self.state = self.State.CONNECTED
         else:
-            self.state = LeConnectionOrientedChannel.INIT
+            self.state = self.State.INIT
 
-    def change_state(self, new_state):
-        logger.debug(
-            f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
-        )
+    def _change_state(self, new_state: State) -> None:
+        logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
         self.state = new_state
 
-        if new_state == self.CONNECTED:
+        if new_state == self.State.CONNECTED:
             self.emit('open')
-        elif new_state == self.DISCONNECTED:
+        elif new_state == self.State.DISCONNECTED:
             self.emit('close')
 
-    def send_pdu(self, pdu):
+    def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
         self.manager.send_pdu(self.connection, self.destination_cid, pdu)
 
-    def send_control_frame(self, frame):
+    def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
         self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
 
-    async def connect(self):
+    async def connect(self) -> LeConnectionOrientedChannel:
         # Check that we're in the right state
-        if self.state != self.INIT:
+        if self.state != self.State.INIT:
             raise InvalidStateError('not in a connectable state')
 
         # Check that we can start a new connection
@@ -1072,7 +1081,7 @@
         if identifier in self.manager.le_coc_requests:
             raise RuntimeError('too many concurrent connection requests')
 
-        self.change_state(self.CONNECTING)
+        self._change_state(self.State.CONNECTING)
         request = L2CAP_LE_Credit_Based_Connection_Request(
             identifier=identifier,
             le_psm=self.le_psm,
@@ -1090,12 +1099,12 @@
         # Wait for the connection to succeed or fail
         return await self.connection_result
 
-    async def disconnect(self):
+    async def disconnect(self) -> None:
         # Check that we're connected
-        if self.state != self.CONNECTED:
+        if self.state != self.State.CONNECTED:
             raise InvalidStateError('not connected')
 
-        self.change_state(self.DISCONNECTING)
+        self._change_state(self.State.DISCONNECTING)
         self.flush_output()
         self.send_control_frame(
             L2CAP_Disconnection_Request(
@@ -1110,16 +1119,16 @@
         self.disconnection_result = asyncio.get_running_loop().create_future()
         return await self.disconnection_result
 
-    def abort(self):
-        if self.state == self.CONNECTED:
-            self.change_state(self.DISCONNECTED)
+    def abort(self) -> None:
+        if self.state == self.State.CONNECTED:
+            self._change_state(self.State.DISCONNECTED)
 
-    def on_pdu(self, pdu):
+    def on_pdu(self, pdu: bytes) -> None:
         if self.sink is None:
             logger.warning('received pdu without a sink')
             return
 
-        if self.state != self.CONNECTED:
+        if self.state != self.State.CONNECTED:
             logger.warning('received PDU while not connected, dropping')
 
         # Manage the peer credits
@@ -1180,7 +1189,7 @@
         self.in_sdu = None
         self.in_sdu_length = 0
 
-    def on_connection_response(self, response):
+    def on_connection_response(self, response) -> None:
         # Look for a matching pending response result
         if self.connection_result is None:
             logger.warning(
@@ -1198,7 +1207,7 @@
             self.credits = response.initial_credits
             self.connected = True
             self.connection_result.set_result(self)
-            self.change_state(self.CONNECTED)
+            self._change_state(self.State.CONNECTED)
         else:
             self.connection_result.set_exception(
                 ProtocolError(
@@ -1209,19 +1218,19 @@
                     ),
                 )
             )
-            self.change_state(self.CONNECTION_ERROR)
+            self._change_state(self.State.CONNECTION_ERROR)
 
         # Cleanup
         self.connection_result = None
 
-    def on_credits(self, credits):  # pylint: disable=redefined-builtin
+    def on_credits(self, credits: int) -> None:  # pylint: disable=redefined-builtin
         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):
+    def on_disconnection_request(self, request) -> None:
         self.send_control_frame(
             L2CAP_Disconnection_Response(
                 identifier=request.identifier,
@@ -1229,11 +1238,11 @@
                 source_cid=request.source_cid,
             )
         )
-        self.change_state(self.DISCONNECTED)
+        self._change_state(self.State.DISCONNECTED)
         self.flush_output()
 
-    def on_disconnection_response(self, response):
-        if self.state != self.DISCONNECTING:
+    def on_disconnection_response(self, response) -> None:
+        if self.state != self.State.DISCONNECTING:
             logger.warning(color('invalid state', 'red'))
             return
 
@@ -1244,16 +1253,16 @@
             logger.warning('unexpected source or destination CID')
             return
 
-        self.change_state(self.DISCONNECTED)
+        self._change_state(self.State.DISCONNECTED)
         if self.disconnection_result:
             self.disconnection_result.set_result(None)
             self.disconnection_result = None
 
-    def flush_output(self):
+    def flush_output(self) -> None:
         self.out_queue.clear()
         self.out_sdu = None
 
-    def process_output(self):
+    def process_output(self) -> None:
         while self.credits > 0:
             if self.out_sdu is not None:
                 # Finish the current SDU
@@ -1296,8 +1305,8 @@
                 self.drained.set()
                 return
 
-    def write(self, data):
-        if self.state != self.CONNECTED:
+    def write(self, data: bytes) -> None:
+        if self.state != self.State.CONNECTED:
             logger.warning('not connected, dropping data')
             return
 
@@ -1311,21 +1320,21 @@
         # Send what we can
         self.process_output()
 
-    async def drain(self):
+    async def drain(self) -> None:
         await self.drained.wait()
 
-    def pause_reading(self):
+    def pause_reading(self) -> None:
         # TODO: not implemented yet
         pass
 
-    def resume_reading(self):
+    def resume_reading(self) -> None:
         # TODO: not implemented yet
         pass
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'CoC({self.source_cid}->{self.destination_cid}, '
-            f'State={self.state_name(self.state)}, '
+            f'State={self.state.name}, '
             f'PSM={self.le_psm}, '
             f'MTU={self.mtu}/{self.peer_mtu}, '
             f'MPS={self.mps}/{self.peer_mps}, '
@@ -1335,9 +1344,23 @@
 
 # -----------------------------------------------------------------------------
 class ChannelManager:
+    identifiers: Dict[int, int]
+    channels: Dict[int, Dict[int, Union[Channel, LeConnectionOrientedChannel]]]
+    servers: Dict[int, Callable[[Channel], Any]]
+    le_coc_channels: Dict[int, Dict[int, LeConnectionOrientedChannel]]
+    le_coc_servers: Dict[
+        int, Tuple[Callable[[LeConnectionOrientedChannel], Any], int, int, int]
+    ]
+    le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
+    fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
+    _host: Optional[Host]
+    connection_parameters_update_response: Optional[asyncio.Future[int]]
+
     def __init__(
-        self, extended_features=(), connectionless_mtu=L2CAP_DEFAULT_CONNECTIONLESS_MTU
-    ):
+        self,
+        extended_features: Iterable[int] = (),
+        connectionless_mtu: int = L2CAP_DEFAULT_CONNECTIONLESS_MTU,
+    ) -> None:
         self._host = None
         self.identifiers = {}  # Incrementing identifier values by connection
         self.channels = {}  # All channels, mapped by connection and source cid
@@ -1353,33 +1376,35 @@
         self.le_coc_requests = {}  # LE CoC connection requests, by identifier
         self.extended_features = extended_features
         self.connectionless_mtu = connectionless_mtu
+        self.connection_parameters_update_response = None
 
     @property
-    def host(self):
+    def host(self) -> Host:
+        assert self._host
         return self._host
 
     @host.setter
-    def host(self, host):
+    def host(self, host: Host) -> None:
         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):
+    def find_channel(self, connection_handle: int, cid: int):
         if connection_channels := self.channels.get(connection_handle):
             return connection_channels.get(cid)
 
         return None
 
-    def find_le_coc_channel(self, connection_handle, cid):
+    def find_le_coc_channel(self, connection_handle: int, cid: int):
         if connection_channels := self.le_coc_channels.get(connection_handle):
             return connection_channels.get(cid)
 
         return None
 
     @staticmethod
-    def find_free_br_edr_cid(channels):
+    def find_free_br_edr_cid(channels: Iterable[int]) -> int:
         # 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)
@@ -1392,7 +1417,7 @@
         raise RuntimeError('no free CID available')
 
     @staticmethod
-    def find_free_le_cid(channels):
+    def find_free_le_cid(channels: Iterable[int]) -> int:
         # 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)
@@ -1405,7 +1430,7 @@
         raise RuntimeError('no free CID')
 
     @staticmethod
-    def check_le_coc_parameters(max_credits, mtu, mps):
+    def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
         if (
             max_credits < 1
             or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
@@ -1419,19 +1444,21 @@
         ):
             raise ValueError('MPS out of range')
 
-    def next_identifier(self, connection):
+    def next_identifier(self, connection: Connection) -> int:
         identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
         self.identifiers[connection.handle] = identifier
         return identifier
 
-    def register_fixed_channel(self, cid, handler):
+    def register_fixed_channel(
+        self, cid: int, handler: Callable[[int, bytes], Any]
+    ) -> None:
         self.fixed_channels[cid] = handler
 
-    def deregister_fixed_channel(self, cid):
+    def deregister_fixed_channel(self, cid: int) -> None:
         if cid in self.fixed_channels:
             del self.fixed_channels[cid]
 
-    def register_server(self, psm, server):
+    def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int:
         if psm == 0:
             # Find a free PSM
             for candidate in range(
@@ -1465,12 +1492,12 @@
 
     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,
-    ):
+        psm: int,
+        server: Callable[[LeConnectionOrientedChannel], Any],
+        max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
+        mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
+        mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
+    ) -> int:
         self.check_le_coc_parameters(max_credits, mtu, mps)
 
         if psm == 0:
@@ -1498,7 +1525,7 @@
 
         return psm
 
-    def on_disconnection(self, connection_handle, _reason):
+    def on_disconnection(self, connection_handle: int, _reason: int) -> None:
         logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
         if connection_handle in self.channels:
             for _, channel in self.channels[connection_handle].items():
@@ -1511,7 +1538,7 @@
         if connection_handle in self.identifiers:
             del self.identifiers[connection_handle]
 
-    def send_pdu(self, connection, cid, pdu):
+    def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
         pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
         logger.debug(
             f'{color(">>> Sending L2CAP PDU", "blue")} '
@@ -1520,14 +1547,16 @@
         )
         self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
 
-    def on_pdu(self, connection, cid, pdu):
+    def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
         if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
             # Parse the L2CAP payload into a Control Frame object
             control_frame = L2CAP_Control_Frame.from_bytes(pdu)
 
             self.on_control_frame(connection, cid, control_frame)
         elif cid in self.fixed_channels:
-            self.fixed_channels[cid](connection.handle, pdu)
+            handler = self.fixed_channels[cid]
+            assert handler is not None
+            handler(connection.handle, pdu)
         else:
             if (channel := self.find_channel(connection.handle, cid)) is None:
                 logger.warning(
@@ -1539,7 +1568,9 @@
 
             channel.on_pdu(pdu)
 
-    def send_control_frame(self, connection, cid, control_frame):
+    def send_control_frame(
+        self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
+    ) -> None:
         logger.debug(
             f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
             f'on connection [0x{connection.handle:04X}] (CID={cid}) '
@@ -1547,7 +1578,9 @@
         )
         self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
 
-    def on_control_frame(self, connection, cid, control_frame):
+    def on_control_frame(
+        self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
+    ) -> None:
         logger.debug(
             f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
             f'on connection [0x{connection.handle:04X}] (CID={cid}) '
@@ -1584,10 +1617,14 @@
                 ),
             )
 
-    def on_l2cap_command_reject(self, _connection, _cid, packet):
+    def on_l2cap_command_reject(
+        self, _connection: Connection, _cid: int, packet
+    ) -> None:
         logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
 
-    def on_l2cap_connection_request(self, connection, cid, request):
+    def on_l2cap_connection_request(
+        self, connection: Connection, cid: int, request
+    ) -> None:
         # Check if there's a server for this PSM
         server = self.servers.get(request.psm)
         if server:
@@ -1639,7 +1676,9 @@
                 ),
             )
 
-    def on_l2cap_connection_response(self, connection, cid, response):
+    def on_l2cap_connection_response(
+        self, connection: Connection, cid: int, response
+    ) -> None:
         if (
             channel := self.find_channel(connection.handle, response.source_cid)
         ) is None:
@@ -1654,7 +1693,9 @@
 
         channel.on_connection_response(response)
 
-    def on_l2cap_configure_request(self, connection, cid, request):
+    def on_l2cap_configure_request(
+        self, connection: Connection, cid: int, request
+    ) -> None:
         if (
             channel := self.find_channel(connection.handle, request.destination_cid)
         ) is None:
@@ -1669,7 +1710,9 @@
 
         channel.on_configure_request(request)
 
-    def on_l2cap_configure_response(self, connection, cid, response):
+    def on_l2cap_configure_response(
+        self, connection: Connection, cid: int, response
+    ) -> None:
         if (
             channel := self.find_channel(connection.handle, response.source_cid)
         ) is None:
@@ -1684,7 +1727,9 @@
 
         channel.on_configure_response(response)
 
-    def on_l2cap_disconnection_request(self, connection, cid, request):
+    def on_l2cap_disconnection_request(
+        self, connection: Connection, cid: int, request
+    ) -> None:
         if (
             channel := self.find_channel(connection.handle, request.destination_cid)
         ) is None:
@@ -1699,7 +1744,9 @@
 
         channel.on_disconnection_request(request)
 
-    def on_l2cap_disconnection_response(self, connection, cid, response):
+    def on_l2cap_disconnection_response(
+        self, connection: Connection, cid: int, response
+    ) -> None:
         if (
             channel := self.find_channel(connection.handle, response.source_cid)
         ) is None:
@@ -1714,7 +1761,7 @@
 
         channel.on_disconnection_response(response)
 
-    def on_l2cap_echo_request(self, connection, cid, request):
+    def on_l2cap_echo_request(self, connection: Connection, cid: int, request) -> None:
         logger.debug(f'<<< Echo request: data={request.data.hex()}')
         self.send_control_frame(
             connection,
@@ -1722,11 +1769,15 @@
             L2CAP_Echo_Response(identifier=request.identifier, data=request.data),
         )
 
-    def on_l2cap_echo_response(self, _connection, _cid, response):
+    def on_l2cap_echo_response(
+        self, _connection: Connection, _cid: int, response
+    ) -> None:
         logger.debug(f'<<< Echo response: data={response.data.hex()}')
         # TODO notify listeners
 
-    def on_l2cap_information_request(self, connection, cid, request):
+    def on_l2cap_information_request(
+        self, connection: Connection, cid: int, request
+    ) -> None:
         if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
             result = L2CAP_Information_Response.SUCCESS
             data = self.connectionless_mtu.to_bytes(2, 'little')
@@ -1750,7 +1801,9 @@
             ),
         )
 
-    def on_l2cap_connection_parameter_update_request(self, connection, cid, request):
+    def on_l2cap_connection_parameter_update_request(
+        self, connection: Connection, cid: int, request
+    ):
         if connection.role == BT_CENTRAL_ROLE:
             self.send_control_frame(
                 connection,
@@ -1769,7 +1822,7 @@
                     supervision_timeout=request.timeout,
                     min_ce_length=0,
                     max_ce_length=0,
-                )
+                )  # type: ignore[call-arg]
             )
         else:
             self.send_control_frame(
@@ -1781,11 +1834,49 @@
                 ),
             )
 
-    def on_l2cap_connection_parameter_update_response(self, connection, cid, response):
-        # TODO: check response
-        pass
+    async def update_connection_parameters(
+        self,
+        connection: Connection,
+        interval_min: int,
+        interval_max: int,
+        latency: int,
+        timeout: int,
+    ) -> int:
+        # Check that there isn't already a request pending
+        if self.connection_parameters_update_response:
+            raise InvalidStateError('request already pending')
+        self.connection_parameters_update_response = (
+            asyncio.get_running_loop().create_future()
+        )
+        self.send_control_frame(
+            connection,
+            L2CAP_LE_SIGNALING_CID,
+            L2CAP_Connection_Parameter_Update_Request(
+                interval_min=interval_min,
+                interval_max=interval_max,
+                latency=latency,
+                timeout=timeout,
+            ),
+        )
+        return await self.connection_parameters_update_response
 
-    def on_l2cap_le_credit_based_connection_request(self, connection, cid, request):
+    def on_l2cap_connection_parameter_update_response(
+        self, connection: Connection, cid: int, response
+    ) -> None:
+        if self.connection_parameters_update_response:
+            self.connection_parameters_update_response.set_result(response.result)
+            self.connection_parameters_update_response = None
+        else:
+            logger.warning(
+                color(
+                    'received l2cap_connection_parameter_update_response without a pending request',
+                    'red',
+                )
+            )
+
+    def on_l2cap_le_credit_based_connection_request(
+        self, connection: Connection, cid: int, request
+    ) -> None:
         if request.le_psm in self.le_coc_servers:
             (server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
 
@@ -1887,7 +1978,9 @@
                 ),
             )
 
-    def on_l2cap_le_credit_based_connection_response(self, connection, _cid, response):
+    def on_l2cap_le_credit_based_connection_response(
+        self, connection: Connection, _cid: int, response
+    ) -> None:
         # Find the pending request by identifier
         request = self.le_coc_requests.get(response.identifier)
         if request is None:
@@ -1910,7 +2003,9 @@
         # Process the response
         channel.on_connection_response(response)
 
-    def on_l2cap_le_flow_control_credit(self, connection, _cid, credit):
+    def on_l2cap_le_flow_control_credit(
+        self, connection: Connection, _cid: int, credit
+    ) -> None:
         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}')
@@ -1918,13 +2013,15 @@
 
         channel.on_credits(credit.credits)
 
-    def on_channel_closed(self, channel):
+    def on_channel_closed(self, channel: Channel) -> None:
         connection_channels = self.channels.get(channel.connection.handle)
         if connection_channels:
             if channel.source_cid in connection_channels:
                 del connection_channels[channel.source_cid]
 
-    async def open_le_coc(self, connection, psm, max_credits, mtu, mps):
+    async def open_le_coc(
+        self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
+    ) -> LeConnectionOrientedChannel:
         self.check_le_coc_parameters(max_credits, mtu, mps)
 
         # Find a free CID for the new channel
@@ -1965,7 +2062,7 @@
 
         return channel
 
-    async def connect(self, connection, psm):
+    async def connect(self, connection: Connection, psm: int) -> Channel:
         # NOTE: this implementation hard-codes BR/EDR
 
         # Find a free CID for a new channel
@@ -1984,7 +2081,8 @@
         # Connect
         try:
             await channel.connect()
-        except Exception:
+        except Exception as e:
             del connection_channels[source_cid]
+            raise e
 
         return channel
diff --git a/bumble/pairing.py b/bumble/pairing.py
index ab356ee..877b739 100644
--- a/bumble/pairing.py
+++ b/bumble/pairing.py
@@ -19,6 +19,7 @@
 from typing import Optional, Tuple
 
 from .hci import (
+    Address,
     HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
     HCI_DISPLAY_ONLY_IO_CAPABILITY,
     HCI_DISPLAY_YES_NO_IO_CAPABILITY,
@@ -168,21 +169,28 @@
 class PairingConfig:
     """Configuration for the Pairing protocol."""
 
+    class AddressType(enum.IntEnum):
+        PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
+        RANDOM = Address.RANDOM_DEVICE_ADDRESS
+
     def __init__(
         self,
         sc: bool = True,
         mitm: bool = True,
         bonding: bool = True,
         delegate: Optional[PairingDelegate] = None,
+        identity_address_type: Optional[AddressType] = None,
     ) -> None:
         self.sc = sc
         self.mitm = mitm
         self.bonding = bonding
         self.delegate = delegate or PairingDelegate()
+        self.identity_address_type = identity_address_type
 
     def __str__(self) -> str:
         return (
             f'PairingConfig(sc={self.sc}, '
             f'mitm={self.mitm}, bonding={self.bonding}, '
+            f'identity_address_type={self.identity_address_type}, '
             f'delegate[{self.delegate.io_capability}])'
         )
diff --git a/bumble/pandora/config.py b/bumble/pandora/config.py
index 5edba55..fa448b8 100644
--- a/bumble/pandora/config.py
+++ b/bumble/pandora/config.py
@@ -12,7 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from bumble.pairing import PairingDelegate
+from bumble.pairing import PairingConfig, PairingDelegate
 from dataclasses import dataclass
 from typing import Any, Dict
 
@@ -20,6 +20,7 @@
 @dataclass
 class Config:
     io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
+    identity_address_type: PairingConfig.AddressType = PairingConfig.AddressType.RANDOM
     pairing_sc_enable: bool = True
     pairing_mitm_enable: bool = True
     pairing_bonding_enable: bool = True
@@ -35,6 +36,12 @@
             'io_capability', 'no_output_no_input'
         ).upper()
         self.io_capability = getattr(PairingDelegate, io_capability_name)
+        identity_address_type_name: str = config.get(
+            'identity_address_type', 'random'
+        ).upper()
+        self.identity_address_type = getattr(
+            PairingConfig.AddressType, identity_address_type_name
+        )
         self.pairing_sc_enable = config.get('pairing_sc_enable', True)
         self.pairing_mitm_enable = config.get('pairing_mitm_enable', True)
         self.pairing_bonding_enable = config.get('pairing_bonding_enable', True)
diff --git a/bumble/pandora/device.py b/bumble/pandora/device.py
index a4403b6..9173900 100644
--- a/bumble/pandora/device.py
+++ b/bumble/pandora/device.py
@@ -34,6 +34,10 @@
 from typing import Any, Dict, List, Optional
 
 
+# Default rootcanal HCI TCP address
+ROOTCANAL_HCI_ADDRESS = "localhost:6402"
+
+
 class PandoraDevice:
     """
     Small wrapper around a Bumble device and it's HCI transport.
@@ -53,7 +57,9 @@
     def __init__(self, config: Dict[str, Any]) -> None:
         self.config = config
         self.device = _make_device(config)
-        self._hci_name = config.get('transport', '')
+        self._hci_name = config.get(
+            'transport', f"tcp-client:{config.get('tcp', ROOTCANAL_HCI_ADDRESS)}"
+        )
         self._hci = None
 
     @property
diff --git a/bumble/pandora/host.py b/bumble/pandora/host.py
index 63b295d..9e6e4b5 100644
--- a/bumble/pandora/host.py
+++ b/bumble/pandora/host.py
@@ -43,7 +43,8 @@
     HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
     Address,
 )
-from google.protobuf import any_pb2, empty_pb2  # pytype: disable=pyi-error
+from google.protobuf import any_pb2  # pytype: disable=pyi-error
+from google.protobuf import empty_pb2  # pytype: disable=pyi-error
 from pandora.host_grpc_aio import HostServicer
 from pandora.host_pb2 import (
     NOT_CONNECTABLE,
@@ -111,7 +112,7 @@
     async def FactoryReset(
         self, request: empty_pb2.Empty, context: grpc.ServicerContext
     ) -> empty_pb2.Empty:
-        self.log.info('FactoryReset')
+        self.log.debug('FactoryReset')
 
         # delete all bonds
         if self.device.keystore is not None:
@@ -125,7 +126,7 @@
     async def Reset(
         self, request: empty_pb2.Empty, context: grpc.ServicerContext
     ) -> empty_pb2.Empty:
-        self.log.info('Reset')
+        self.log.debug('Reset')
 
         # clear service.
         self.waited_connections.clear()
@@ -138,7 +139,7 @@
     async def ReadLocalAddress(
         self, request: empty_pb2.Empty, context: grpc.ServicerContext
     ) -> ReadLocalAddressResponse:
-        self.log.info('ReadLocalAddress')
+        self.log.debug('ReadLocalAddress')
         return ReadLocalAddressResponse(
             address=bytes(reversed(bytes(self.device.public_address)))
         )
@@ -151,7 +152,7 @@
         address = Address(
             bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
         )
-        self.log.info(f"Connect to {address}")
+        self.log.debug(f"Connect to {address}")
 
         try:
             connection = await self.device.connect(
@@ -166,7 +167,7 @@
                 return ConnectResponse(connection_already_exists=empty_pb2.Empty())
             raise e
 
-        self.log.info(f"Connect to {address} done (handle={connection.handle})")
+        self.log.debug(f"Connect to {address} done (handle={connection.handle})")
 
         cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
         return ConnectResponse(connection=Connection(cookie=cookie))
@@ -185,7 +186,7 @@
         if address in (Address.NIL, Address.ANY):
             raise ValueError('Invalid address')
 
-        self.log.info(f"WaitConnection from {address}...")
+        self.log.debug(f"WaitConnection from {address}...")
 
         connection = self.device.find_connection_by_bd_addr(
             address, transport=BT_BR_EDR_TRANSPORT
@@ -200,7 +201,7 @@
         # save connection has waited and respond.
         self.waited_connections.add(id(connection))
 
-        self.log.info(
+        self.log.debug(
             f"WaitConnection from {address} done (handle={connection.handle})"
         )
 
@@ -215,7 +216,7 @@
         if address in (Address.NIL, Address.ANY):
             raise ValueError('Invalid address')
 
-        self.log.info(f"ConnectLE to {address}...")
+        self.log.debug(f"ConnectLE to {address}...")
 
         try:
             connection = await self.device.connect(
@@ -232,7 +233,7 @@
                 return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
             raise e
 
-        self.log.info(f"ConnectLE to {address} done (handle={connection.handle})")
+        self.log.debug(f"ConnectLE to {address} done (handle={connection.handle})")
 
         cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
         return ConnectLEResponse(connection=Connection(cookie=cookie))
@@ -242,12 +243,12 @@
         self, request: DisconnectRequest, context: grpc.ServicerContext
     ) -> empty_pb2.Empty:
         connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
-        self.log.info(f"Disconnect: {connection_handle}")
+        self.log.debug(f"Disconnect: {connection_handle}")
 
-        self.log.info("Disconnecting...")
+        self.log.debug("Disconnecting...")
         if connection := self.device.lookup_connection(connection_handle):
             await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
-        self.log.info("Disconnected")
+        self.log.debug("Disconnected")
 
         return empty_pb2.Empty()
 
@@ -256,7 +257,7 @@
         self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
     ) -> empty_pb2.Empty:
         connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
-        self.log.info(f"WaitDisconnection: {connection_handle}")
+        self.log.debug(f"WaitDisconnection: {connection_handle}")
 
         if connection := self.device.lookup_connection(connection_handle):
             disconnection_future: asyncio.Future[
@@ -269,7 +270,7 @@
             connection.on('disconnection', on_disconnection)
             try:
                 await disconnection_future
-                self.log.info("Disconnected")
+                self.log.debug("Disconnected")
             finally:
                 connection.remove_listener('disconnection', on_disconnection)  # type: ignore
 
@@ -377,7 +378,7 @@
         try:
             while True:
                 if not self.device.is_advertising:
-                    self.log.info('Advertise')
+                    self.log.debug('Advertise')
                     await self.device.start_advertising(
                         target=target,
                         advertising_type=advertising_type,
@@ -392,10 +393,10 @@
                     bumble.device.Connection
                 ] = asyncio.get_running_loop().create_future()
 
-                self.log.info('Wait for LE connection...')
+                self.log.debug('Wait for LE connection...')
                 connection = await pending_connection
 
-                self.log.info(
+                self.log.debug(
                     f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
                 )
 
@@ -409,7 +410,7 @@
                 self.device.remove_listener('connection', on_connection)  # type: ignore
 
             try:
-                self.log.info('Stop advertising')
+                self.log.debug('Stop advertising')
                 await self.device.abort_on('flush', self.device.stop_advertising())
             except:
                 pass
@@ -422,7 +423,7 @@
         if request.phys:
             raise NotImplementedError("TODO: add support for `request.phys`")
 
-        self.log.info('Scan')
+        self.log.debug('Scan')
 
         scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
         handler = self.device.on('advertisement', scan_queue.put_nowait)
@@ -469,7 +470,7 @@
         finally:
             self.device.remove_listener('advertisement', handler)  # type: ignore
             try:
-                self.log.info('Stop scanning')
+                self.log.debug('Stop scanning')
                 await self.device.abort_on('flush', self.device.stop_scanning())
             except:
                 pass
@@ -478,7 +479,7 @@
     async def Inquiry(
         self, request: empty_pb2.Empty, context: grpc.ServicerContext
     ) -> AsyncGenerator[InquiryResponse, None]:
-        self.log.info('Inquiry')
+        self.log.debug('Inquiry')
 
         inquiry_queue: asyncio.Queue[
             Optional[Tuple[Address, int, AdvertisingData, int]]
@@ -509,7 +510,7 @@
             self.device.remove_listener('inquiry_complete', complete_handler)  # type: ignore
             self.device.remove_listener('inquiry_result', result_handler)  # type: ignore
             try:
-                self.log.info('Stop inquiry')
+                self.log.debug('Stop inquiry')
                 await self.device.abort_on('flush', self.device.stop_discovery())
             except:
                 pass
@@ -518,7 +519,7 @@
     async def SetDiscoverabilityMode(
         self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
     ) -> empty_pb2.Empty:
-        self.log.info("SetDiscoverabilityMode")
+        self.log.debug("SetDiscoverabilityMode")
         await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
         return empty_pb2.Empty()
 
@@ -526,7 +527,7 @@
     async def SetConnectabilityMode(
         self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
     ) -> empty_pb2.Empty:
-        self.log.info("SetConnectabilityMode")
+        self.log.debug("SetConnectabilityMode")
         await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
         return empty_pb2.Empty()
 
diff --git a/bumble/pandora/security.py b/bumble/pandora/security.py
index fee1b7a..0f31512 100644
--- a/bumble/pandora/security.py
+++ b/bumble/pandora/security.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import asyncio
+import contextlib
 import grpc
 import logging
 
@@ -27,14 +28,11 @@
 )
 from bumble.device import Connection as BumbleConnection, Device
 from bumble.hci import HCI_Error
+from bumble.utils import EventWatcher
 from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
-from contextlib import suppress
-from google.protobuf import (
-    any_pb2,
-    empty_pb2,
-    wrappers_pb2,
-)  # pytype: disable=pyi-error
-from google.protobuf.wrappers_pb2 import BoolValue  # pytype: disable=pyi-error
+from google.protobuf import any_pb2  # pytype: disable=pyi-error
+from google.protobuf import empty_pb2  # pytype: disable=pyi-error
+from google.protobuf import wrappers_pb2  # pytype: disable=pyi-error
 from pandora.host_pb2 import Connection
 from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
 from pandora.security_pb2 import (
@@ -102,7 +100,7 @@
         return ev
 
     async def confirm(self, auto: bool = False) -> bool:
-        self.log.info(
+        self.log.debug(
             f"Pairing event: `just_works` (io_capability: {self.io_capability})"
         )
 
@@ -117,7 +115,7 @@
         return answer.confirm
 
     async def compare_numbers(self, number: int, digits: int = 6) -> bool:
-        self.log.info(
+        self.log.debug(
             f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
         )
 
@@ -132,7 +130,7 @@
         return answer.confirm
 
     async def get_number(self) -> Optional[int]:
-        self.log.info(
+        self.log.debug(
             f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
         )
 
@@ -149,7 +147,7 @@
         return answer.passkey
 
     async def get_string(self, max_length: int) -> Optional[str]:
-        self.log.info(
+        self.log.debug(
             f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
         )
 
@@ -180,7 +178,7 @@
         ):
             return
 
-        self.log.info(
+        self.log.debug(
             f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
         )
 
@@ -235,6 +233,11 @@
                 sc=config.pairing_sc_enable,
                 mitm=config.pairing_mitm_enable,
                 bonding=config.pairing_bonding_enable,
+                identity_address_type=(
+                    PairingConfig.AddressType.PUBLIC
+                    if connection.self_address.is_public
+                    else config.identity_address_type
+                ),
                 delegate=PairingDelegate(
                     connection,
                     self,
@@ -250,7 +253,7 @@
     async def OnPairing(
         self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
     ) -> AsyncGenerator[PairingEvent, None]:
-        self.log.info('OnPairing')
+        self.log.debug('OnPairing')
 
         if self.event_queue is not None:
             raise RuntimeError('already streaming pairing events')
@@ -276,7 +279,7 @@
         self, request: SecureRequest, context: grpc.ServicerContext
     ) -> SecureResponse:
         connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
-        self.log.info(f"Secure: {connection_handle}")
+        self.log.debug(f"Secure: {connection_handle}")
 
         connection = self.device.lookup_connection(connection_handle)
         assert connection
@@ -294,25 +297,37 @@
         # trigger pairing if needed
         if self.need_pairing(connection, level):
             try:
-                self.log.info('Pair...')
+                self.log.debug('Pair...')
 
-                if (
-                    connection.transport == BT_LE_TRANSPORT
-                    and connection.role == BT_PERIPHERAL_ROLE
-                ):
-                    wait_for_security: asyncio.Future[
-                        bool
-                    ] = asyncio.get_running_loop().create_future()
-                    connection.on("pairing", lambda *_: wait_for_security.set_result(True))  # type: ignore
-                    connection.on("pairing_failure", wait_for_security.set_exception)
+                security_result = asyncio.get_running_loop().create_future()
 
-                    connection.request_pairing()
+                with contextlib.closing(EventWatcher()) as watcher:
 
-                    await wait_for_security
-                else:
-                    await connection.pair()
+                    @watcher.on(connection, 'pairing')
+                    def on_pairing(*_: Any) -> None:
+                        security_result.set_result('success')
 
-                self.log.info('Paired')
+                    @watcher.on(connection, 'pairing_failure')
+                    def on_pairing_failure(*_: Any) -> None:
+                        security_result.set_result('pairing_failure')
+
+                    @watcher.on(connection, 'disconnection')
+                    def on_disconnection(*_: Any) -> None:
+                        security_result.set_result('connection_died')
+
+                    if (
+                        connection.transport == BT_LE_TRANSPORT
+                        and connection.role == BT_PERIPHERAL_ROLE
+                    ):
+                        connection.request_pairing()
+                    else:
+                        await connection.pair()
+
+                    result = await security_result
+
+                self.log.debug(f'Pairing session complete, status={result}')
+                if result != 'success':
+                    return SecureResponse(**{result: empty_pb2.Empty()})
             except asyncio.CancelledError:
                 self.log.warning("Connection died during encryption")
                 return SecureResponse(connection_died=empty_pb2.Empty())
@@ -323,9 +338,9 @@
         # trigger authentication if needed
         if self.need_authentication(connection, level):
             try:
-                self.log.info('Authenticate...')
+                self.log.debug('Authenticate...')
                 await connection.authenticate()
-                self.log.info('Authenticated')
+                self.log.debug('Authenticated')
             except asyncio.CancelledError:
                 self.log.warning("Connection died during authentication")
                 return SecureResponse(connection_died=empty_pb2.Empty())
@@ -336,9 +351,9 @@
         # trigger encryption if needed
         if self.need_encryption(connection, level):
             try:
-                self.log.info('Encrypt...')
+                self.log.debug('Encrypt...')
                 await connection.encrypt()
-                self.log.info('Encrypted')
+                self.log.debug('Encrypted')
             except asyncio.CancelledError:
                 self.log.warning("Connection died during encryption")
                 return SecureResponse(connection_died=empty_pb2.Empty())
@@ -356,7 +371,7 @@
         self, request: WaitSecurityRequest, context: grpc.ServicerContext
     ) -> WaitSecurityResponse:
         connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
-        self.log.info(f"WaitSecurity: {connection_handle}")
+        self.log.debug(f"WaitSecurity: {connection_handle}")
 
         connection = self.device.lookup_connection(connection_handle)
         assert connection
@@ -371,6 +386,7 @@
             str
         ] = asyncio.get_running_loop().create_future()
         authenticate_task: Optional[asyncio.Future[None]] = None
+        pair_task: Optional[asyncio.Future[None]] = None
 
         async def authenticate() -> None:
             assert connection
@@ -393,7 +409,7 @@
 
         def set_failure(name: str) -> Callable[..., None]:
             def wrapper(*args: Any) -> None:
-                self.log.info(f'Wait for security: error `{name}`: {args}')
+                self.log.debug(f'Wait for security: error `{name}`: {args}')
                 wait_for_security.set_result(name)
 
             return wrapper
@@ -401,13 +417,13 @@
         def try_set_success(*_: Any) -> None:
             assert connection
             if self.reached_security_level(connection, level):
-                self.log.info('Wait for security: done')
+                self.log.debug('Wait for security: done')
                 wait_for_security.set_result('success')
 
         def on_encryption_change(*_: Any) -> None:
             assert connection
             if self.reached_security_level(connection, level):
-                self.log.info('Wait for security: done')
+                self.log.debug('Wait for security: done')
                 wait_for_security.set_result('success')
             elif (
                 connection.transport == BT_BR_EDR_TRANSPORT
@@ -417,6 +433,10 @@
                 if authenticate_task is None:
                     authenticate_task = asyncio.create_task(authenticate())
 
+        def pair(*_: Any) -> None:
+            if self.need_pairing(connection, level):
+                pair_task = asyncio.create_task(connection.pair())
+
         listeners: Dict[str, Callable[..., None]] = {
             'disconnection': set_failure('connection_died'),
             'pairing_failure': set_failure('pairing_failure'),
@@ -425,6 +445,9 @@
             'pairing': try_set_success,
             'connection_authentication': try_set_success,
             'connection_encryption_change': on_encryption_change,
+            'classic_pairing': try_set_success,
+            'classic_pairing_failure': set_failure('pairing_failure'),
+            'security_request': pair,
         }
 
         # register event handlers
@@ -435,7 +458,7 @@
         if self.reached_security_level(connection, level):
             return WaitSecurityResponse(success=empty_pb2.Empty())
 
-        self.log.info('Wait for security...')
+        self.log.debug('Wait for security...')
         kwargs = {}
         kwargs[await wait_for_security] = empty_pb2.Empty()
 
@@ -445,12 +468,21 @@
 
         # wait for `authenticate` to finish if any
         if authenticate_task is not None:
-            self.log.info('Wait for authentication...')
+            self.log.debug('Wait for authentication...')
             try:
                 await authenticate_task  # type: ignore
             except:
                 pass
-            self.log.info('Authenticated')
+            self.log.debug('Authenticated')
+
+        # wait for `pair` to finish if any
+        if pair_task is not None:
+            self.log.debug('Wait for authentication...')
+            try:
+                await pair_task  # type: ignore
+            except:
+                pass
+            self.log.debug('paired')
 
         return WaitSecurityResponse(**kwargs)
 
@@ -506,24 +538,24 @@
         self, request: IsBondedRequest, context: grpc.ServicerContext
     ) -> wrappers_pb2.BoolValue:
         address = utils.address_from_request(request, request.WhichOneof("address"))
-        self.log.info(f"IsBonded: {address}")
+        self.log.debug(f"IsBonded: {address}")
 
         if self.device.keystore is not None:
             is_bonded = await self.device.keystore.get(str(address)) is not None
         else:
             is_bonded = False
 
-        return BoolValue(value=is_bonded)
+        return wrappers_pb2.BoolValue(value=is_bonded)
 
     @utils.rpc
     async def DeleteBond(
         self, request: DeleteBondRequest, context: grpc.ServicerContext
     ) -> empty_pb2.Empty:
         address = utils.address_from_request(request, request.WhichOneof("address"))
-        self.log.info(f"DeleteBond: {address}")
+        self.log.debug(f"DeleteBond: {address}")
 
         if self.device.keystore is not None:
-            with suppress(KeyError):
+            with contextlib.suppress(KeyError):
                 await self.device.keystore.delete(str(address))
 
         return empty_pb2.Empty()
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index 71be8dc..02c18fa 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -15,14 +15,37 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
+
 import logging
 import asyncio
+import enum
+from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
 
 from pyee import EventEmitter
 
-from . import core
+from . import core, l2cap
 from .colors import color
-from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
+from .core import (
+    UUID,
+    BT_RFCOMM_PROTOCOL_ID,
+    BT_BR_EDR_TRANSPORT,
+    BT_L2CAP_PROTOCOL_ID,
+    InvalidStateError,
+    ProtocolError,
+)
+from .sdp import (
+    SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+    SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+    SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+    SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+    SDP_PUBLIC_BROWSE_ROOT,
+    DataElement,
+    ServiceAttribute,
+)
+
+if TYPE_CHECKING:
+    from bumble.device import Device, Connection
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -105,7 +128,51 @@
 
 
 # -----------------------------------------------------------------------------
-def compute_fcs(buffer):
+def make_service_sdp_records(
+    service_record_handle: int, channel: int, uuid: Optional[UUID] = None
+) -> List[ServiceAttribute]:
+    """
+    Create SDP records for an RFComm service given a channel number and an
+    optional UUID. A Service Class Attribute is included only if the UUID is not None.
+    """
+    records = [
+        ServiceAttribute(
+            SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+            DataElement.unsigned_integer_32(service_record_handle),
+        ),
+        ServiceAttribute(
+            SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+            DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+        ),
+        ServiceAttribute(
+            SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+            DataElement.sequence(
+                [
+                    DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
+                    DataElement.sequence(
+                        [
+                            DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
+                            DataElement.unsigned_integer_8(channel),
+                        ]
+                    ),
+                ]
+            ),
+        ),
+    ]
+
+    if uuid:
+        records.append(
+            ServiceAttribute(
+                SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+                DataElement.sequence([DataElement.uuid(uuid)]),
+            )
+        )
+
+    return records
+
+
+# -----------------------------------------------------------------------------
+def compute_fcs(buffer: bytes) -> int:
     result = 0xFF
     for byte in buffer:
         result = CRC_TABLE[result ^ byte]
@@ -114,7 +181,15 @@
 
 # -----------------------------------------------------------------------------
 class RFCOMM_Frame:
-    def __init__(self, frame_type, c_r, dlci, p_f, information=b'', with_credits=False):
+    def __init__(
+        self,
+        frame_type: int,
+        c_r: int,
+        dlci: int,
+        p_f: int,
+        information: bytes = b'',
+        with_credits: bool = False,
+    ) -> None:
         self.type = frame_type
         self.c_r = c_r
         self.dlci = dlci
@@ -136,13 +211,13 @@
         else:
             self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
 
-    def type_name(self):
+    def type_name(self) -> str:
         return RFCOMM_FRAME_TYPE_NAMES[self.type]
 
     @staticmethod
-    def parse_mcc(data):
+    def parse_mcc(data) -> Tuple[int, bool, bytes]:
         mcc_type = data[0] >> 2
-        c_r = (data[0] >> 1) & 1
+        c_r = bool((data[0] >> 1) & 1)
         length = data[1]
         if data[1] & 1:
             length >>= 1
@@ -154,36 +229,36 @@
         return (mcc_type, c_r, value)
 
     @staticmethod
-    def make_mcc(mcc_type, c_r, data):
+    def make_mcc(mcc_type: int, c_r: int, data: bytes) -> bytes:
         return (
             bytes([(mcc_type << 2 | c_r << 1 | 1) & 0xFF, (len(data) & 0x7F) << 1 | 1])
             + data
         )
 
     @staticmethod
-    def sabm(c_r, dlci):
+    def sabm(c_r: int, dlci: int):
         return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
 
     @staticmethod
-    def ua(c_r, dlci):
+    def ua(c_r: int, dlci: int):
         return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
 
     @staticmethod
-    def dm(c_r, dlci):
+    def dm(c_r: int, dlci: int):
         return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
 
     @staticmethod
-    def disc(c_r, dlci):
+    def disc(c_r: int, dlci: int):
         return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
 
     @staticmethod
-    def uih(c_r, dlci, information, p_f=0):
+    def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
         return RFCOMM_Frame(
             RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
         )
 
     @staticmethod
-    def from_bytes(data):
+    def from_bytes(data: bytes) -> RFCOMM_Frame:
         # Extract fields
         dlci = (data[0] >> 2) & 0x3F
         c_r = (data[0] >> 1) & 0x01
@@ -206,7 +281,7 @@
 
         return frame
 
-    def __bytes__(self):
+    def __bytes__(self) -> bytes:
         return (
             bytes([self.address, self.control])
             + self.length
@@ -214,7 +289,7 @@
             + bytes([self.fcs])
         )
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'{color(self.type_name(), "yellow")}'
             f'(c/r={self.c_r},'
@@ -227,16 +302,24 @@
 
 # -----------------------------------------------------------------------------
 class RFCOMM_MCC_PN:
+    dlci: int
+    cl: int
+    priority: int
+    ack_timer: int
+    max_frame_size: int
+    max_retransmissions: int
+    window_size: int
+
     def __init__(
         self,
-        dlci,
-        cl,
-        priority,
-        ack_timer,
-        max_frame_size,
-        max_retransmissions,
-        window_size,
-    ):
+        dlci: int,
+        cl: int,
+        priority: int,
+        ack_timer: int,
+        max_frame_size: int,
+        max_retransmissions: int,
+        window_size: int,
+    ) -> None:
         self.dlci = dlci
         self.cl = cl
         self.priority = priority
@@ -246,7 +329,7 @@
         self.window_size = window_size
 
     @staticmethod
-    def from_bytes(data):
+    def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
         return RFCOMM_MCC_PN(
             dlci=data[0],
             cl=data[1],
@@ -257,7 +340,7 @@
             window_size=data[7],
         )
 
-    def __bytes__(self):
+    def __bytes__(self) -> bytes:
         return bytes(
             [
                 self.dlci & 0xFF,
@@ -271,7 +354,7 @@
             ]
         )
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'PN(dlci={self.dlci},'
             f'cl={self.cl},'
@@ -285,7 +368,16 @@
 
 # -----------------------------------------------------------------------------
 class RFCOMM_MCC_MSC:
-    def __init__(self, dlci, fc, rtc, rtr, ic, dv):
+    dlci: int
+    fc: int
+    rtc: int
+    rtr: int
+    ic: int
+    dv: int
+
+    def __init__(
+        self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
+    ) -> None:
         self.dlci = dlci
         self.fc = fc
         self.rtc = rtc
@@ -294,7 +386,7 @@
         self.dv = dv
 
     @staticmethod
-    def from_bytes(data):
+    def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
         return RFCOMM_MCC_MSC(
             dlci=data[0] >> 2,
             fc=data[1] >> 1 & 1,
@@ -304,7 +396,7 @@
             dv=data[1] >> 7 & 1,
         )
 
-    def __bytes__(self):
+    def __bytes__(self) -> bytes:
         return bytes(
             [
                 (self.dlci << 2) | 3,
@@ -317,7 +409,7 @@
             ]
         )
 
-    def __str__(self):
+    def __str__(self) -> str:
         return (
             f'MSC(dlci={self.dlci},'
             f'fc={self.fc},'
@@ -330,24 +422,24 @@
 
 # -----------------------------------------------------------------------------
 class DLC(EventEmitter):
-    # States
-    INIT = 0x00
-    CONNECTING = 0x01
-    CONNECTED = 0x02
-    DISCONNECTING = 0x03
-    DISCONNECTED = 0x04
-    RESET = 0x05
+    class State(enum.IntEnum):
+        INIT = 0x00
+        CONNECTING = 0x01
+        CONNECTED = 0x02
+        DISCONNECTING = 0x03
+        DISCONNECTED = 0x04
+        RESET = 0x05
 
-    STATE_NAMES = {
-        INIT: 'INIT',
-        CONNECTING: 'CONNECTING',
-        CONNECTED: 'CONNECTED',
-        DISCONNECTING: 'DISCONNECTING',
-        DISCONNECTED: 'DISCONNECTED',
-        RESET: 'RESET',
-    }
+    connection_result: Optional[asyncio.Future]
+    sink: Optional[Callable[[bytes], None]]
 
-    def __init__(self, multiplexer, dlci, max_frame_size, initial_tx_credits):
+    def __init__(
+        self,
+        multiplexer: Multiplexer,
+        dlci: int,
+        max_frame_size: int,
+        initial_tx_credits: int,
+    ) -> None:
         super().__init__()
         self.multiplexer = multiplexer
         self.dlci = dlci
@@ -355,9 +447,9 @@
         self.rx_threshold = self.rx_credits // 2
         self.tx_credits = initial_tx_credits
         self.tx_buffer = b''
-        self.state = DLC.INIT
+        self.state = DLC.State.INIT
         self.role = multiplexer.role
-        self.c_r = 1 if self.role == Multiplexer.INITIATOR else 0
+        self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
         self.sink = None
         self.connection_result = None
 
@@ -367,25 +459,19 @@
             max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
         )
 
-    @staticmethod
-    def state_name(state):
-        return DLC.STATE_NAMES[state]
-
-    def change_state(self, new_state):
-        logger.debug(
-            f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
-        )
+    def change_state(self, new_state: State) -> None:
+        logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
         self.state = new_state
 
-    def send_frame(self, frame):
+    def send_frame(self, frame: RFCOMM_Frame) -> None:
         self.multiplexer.send_frame(frame)
 
-    def on_frame(self, frame):
+    def on_frame(self, frame: RFCOMM_Frame) -> None:
         handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
         handler(frame)
 
-    def on_sabm_frame(self, _frame):
-        if self.state != DLC.CONNECTING:
+    def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
+        if self.state != DLC.State.CONNECTING:
             logger.warning(
                 color('!!! received SABM when not in CONNECTING state', 'red')
             )
@@ -401,11 +487,11 @@
         logger.debug(f'>>> MCC MSC Command: {msc}')
         self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
 
-        self.change_state(DLC.CONNECTED)
+        self.change_state(DLC.State.CONNECTED)
         self.emit('open')
 
-    def on_ua_frame(self, _frame):
-        if self.state != DLC.CONNECTING:
+    def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
+        if self.state != DLC.State.CONNECTING:
             logger.warning(
                 color('!!! received SABM when not in CONNECTING state', 'red')
             )
@@ -419,18 +505,18 @@
         logger.debug(f'>>> MCC MSC Command: {msc}')
         self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
 
-        self.change_state(DLC.CONNECTED)
+        self.change_state(DLC.State.CONNECTED)
         self.multiplexer.on_dlc_open_complete(self)
 
-    def on_dm_frame(self, frame):
+    def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
         # TODO: handle all states
         pass
 
-    def on_disc_frame(self, _frame):
+    def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
         # TODO: handle all states
         self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
 
-    def on_uih_frame(self, frame):
+    def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
         data = frame.information
         if frame.p_f == 1:
             # With credits
@@ -460,10 +546,10 @@
         # Check if there's anything to send (including credits)
         self.process_tx()
 
-    def on_ui_frame(self, frame):
+    def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
         pass
 
-    def on_mcc_msc(self, c_r, msc):
+    def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
         if c_r:
             # Command
             logger.debug(f'<<< MCC MSC Command: {msc}')
@@ -477,16 +563,16 @@
             # Response
             logger.debug(f'<<< MCC MSC Response: {msc}')
 
-    def connect(self):
-        if self.state != DLC.INIT:
+    def connect(self) -> None:
+        if self.state != DLC.State.INIT:
             raise InvalidStateError('invalid state')
 
-        self.change_state(DLC.CONNECTING)
+        self.change_state(DLC.State.CONNECTING)
         self.connection_result = asyncio.get_running_loop().create_future()
         self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
 
-    def accept(self):
-        if self.state != DLC.INIT:
+    def accept(self) -> None:
+        if self.state != DLC.State.INIT:
             raise InvalidStateError('invalid state')
 
         pn = RFCOMM_MCC_PN(
@@ -501,15 +587,15 @@
         mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
         logger.debug(f'>>> PN Response: {pn}')
         self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
-        self.change_state(DLC.CONNECTING)
+        self.change_state(DLC.State.CONNECTING)
 
-    def rx_credits_needed(self):
+    def rx_credits_needed(self) -> int:
         if self.rx_credits <= self.rx_threshold:
             return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
 
         return 0
 
-    def process_tx(self):
+    def process_tx(self) -> None:
         # Send anything we can (or an empty frame if we need to send rx credits)
         rx_credits_needed = self.rx_credits_needed()
         while (self.tx_buffer and self.tx_credits > 0) or rx_credits_needed > 0:
@@ -547,7 +633,7 @@
             rx_credits_needed = 0
 
     # Stream protocol
-    def write(self, data):
+    def write(self, data: Union[bytes, str]) -> None:
         # We can only send bytes
         if not isinstance(data, bytes):
             if isinstance(data, str):
@@ -559,44 +645,40 @@
         self.tx_buffer += data
         self.process_tx()
 
-    def drain(self):
+    def drain(self) -> None:
         # TODO
         pass
 
-    def __str__(self):
-        return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
+    def __str__(self) -> str:
+        return f'DLC(dlci={self.dlci},state={self.state.name})'
 
 
 # -----------------------------------------------------------------------------
 class Multiplexer(EventEmitter):
-    # Roles
-    INITIATOR = 0x00
-    RESPONDER = 0x01
+    class Role(enum.IntEnum):
+        INITIATOR = 0x00
+        RESPONDER = 0x01
 
-    # States
-    INIT = 0x00
-    CONNECTING = 0x01
-    CONNECTED = 0x02
-    OPENING = 0x03
-    DISCONNECTING = 0x04
-    DISCONNECTED = 0x05
-    RESET = 0x06
+    class State(enum.IntEnum):
+        INIT = 0x00
+        CONNECTING = 0x01
+        CONNECTED = 0x02
+        OPENING = 0x03
+        DISCONNECTING = 0x04
+        DISCONNECTED = 0x05
+        RESET = 0x06
 
-    STATE_NAMES = {
-        INIT: 'INIT',
-        CONNECTING: 'CONNECTING',
-        CONNECTED: 'CONNECTED',
-        OPENING: 'OPENING',
-        DISCONNECTING: 'DISCONNECTING',
-        DISCONNECTED: 'DISCONNECTED',
-        RESET: 'RESET',
-    }
+    connection_result: Optional[asyncio.Future]
+    disconnection_result: Optional[asyncio.Future]
+    open_result: Optional[asyncio.Future]
+    acceptor: Optional[Callable[[int], bool]]
+    dlcs: Dict[int, DLC]
 
-    def __init__(self, l2cap_channel, role):
+    def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
         super().__init__()
         self.role = role
         self.l2cap_channel = l2cap_channel
-        self.state = Multiplexer.INIT
+        self.state = Multiplexer.State.INIT
         self.dlcs = {}  # DLCs, by DLCI
         self.connection_result = None
         self.disconnection_result = None
@@ -606,21 +688,15 @@
         # Become a sink for the L2CAP channel
         l2cap_channel.sink = self.on_pdu
 
-    @staticmethod
-    def state_name(state):
-        return Multiplexer.STATE_NAMES[state]
-
-    def change_state(self, new_state):
-        logger.debug(
-            f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
-        )
+    def change_state(self, new_state: State) -> None:
+        logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
         self.state = new_state
 
-    def send_frame(self, frame):
+    def send_frame(self, frame: RFCOMM_Frame) -> None:
         logger.debug(f'>>> Multiplexer sending {frame}')
         self.l2cap_channel.send_pdu(frame)
 
-    def on_pdu(self, pdu):
+    def on_pdu(self, pdu: bytes) -> None:
         frame = RFCOMM_Frame.from_bytes(pdu)
         logger.debug(f'<<< Multiplexer received {frame}')
 
@@ -640,32 +716,32 @@
                     return
                 dlc.on_frame(frame)
 
-    def on_frame(self, frame):
+    def on_frame(self, frame: RFCOMM_Frame) -> None:
         handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
         handler(frame)
 
-    def on_sabm_frame(self, _frame):
-        if self.state != Multiplexer.INIT:
+    def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
+        if self.state != Multiplexer.State.INIT:
             logger.debug('not in INIT state, ignoring SABM')
             return
-        self.change_state(Multiplexer.CONNECTED)
+        self.change_state(Multiplexer.State.CONNECTED)
         self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
 
-    def on_ua_frame(self, _frame):
-        if self.state == Multiplexer.CONNECTING:
-            self.change_state(Multiplexer.CONNECTED)
+    def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
+        if self.state == Multiplexer.State.CONNECTING:
+            self.change_state(Multiplexer.State.CONNECTED)
             if self.connection_result:
                 self.connection_result.set_result(0)
                 self.connection_result = None
-        elif self.state == Multiplexer.DISCONNECTING:
-            self.change_state(Multiplexer.DISCONNECTED)
+        elif self.state == Multiplexer.State.DISCONNECTING:
+            self.change_state(Multiplexer.State.DISCONNECTED)
             if self.disconnection_result:
                 self.disconnection_result.set_result(None)
                 self.disconnection_result = None
 
-    def on_dm_frame(self, _frame):
-        if self.state == Multiplexer.OPENING:
-            self.change_state(Multiplexer.CONNECTED)
+    def on_dm_frame(self, _frame: RFCOMM_Frame) -> None:
+        if self.state == Multiplexer.State.OPENING:
+            self.change_state(Multiplexer.State.CONNECTED)
             if self.open_result:
                 self.open_result.set_exception(
                     core.ConnectionError(
@@ -678,13 +754,15 @@
         else:
             logger.warning(f'unexpected state for DM: {self}')
 
-    def on_disc_frame(self, _frame):
-        self.change_state(Multiplexer.DISCONNECTED)
+    def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
+        self.change_state(Multiplexer.State.DISCONNECTED)
         self.send_frame(
-            RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.INITIATOR else 1, dlci=0)
+            RFCOMM_Frame.ua(
+                c_r=0 if self.role == Multiplexer.Role.INITIATOR else 1, dlci=0
+            )
         )
 
-    def on_uih_frame(self, frame):
+    def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
         (mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
 
         if mcc_type == RFCOMM_MCC_PN_TYPE:
@@ -694,11 +772,11 @@
             mcs = RFCOMM_MCC_MSC.from_bytes(value)
             self.on_mcc_msc(c_r, mcs)
 
-    def on_ui_frame(self, frame):
+    def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
         pass
 
-    def on_mcc_pn(self, c_r, pn):
-        if c_r == 1:
+    def on_mcc_pn(self, c_r: bool, pn: RFCOMM_MCC_PN) -> None:
+        if c_r:
             # Command
             logger.debug(f'<<< PN Command: {pn}')
 
@@ -729,45 +807,45 @@
         else:
             # Response
             logger.debug(f'>>> PN Response: {pn}')
-            if self.state == Multiplexer.OPENING:
+            if self.state == Multiplexer.State.OPENING:
                 dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
                 self.dlcs[pn.dlci] = dlc
                 dlc.connect()
             else:
                 logger.warning('ignoring PN response')
 
-    def on_mcc_msc(self, c_r, msc):
+    def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
         dlc = self.dlcs.get(msc.dlci)
         if dlc is None:
             logger.warning(f'no dlc for DLCI {msc.dlci}')
             return
         dlc.on_mcc_msc(c_r, msc)
 
-    async def connect(self):
-        if self.state != Multiplexer.INIT:
+    async def connect(self) -> None:
+        if self.state != Multiplexer.State.INIT:
             raise InvalidStateError('invalid state')
 
-        self.change_state(Multiplexer.CONNECTING)
+        self.change_state(Multiplexer.State.CONNECTING)
         self.connection_result = asyncio.get_running_loop().create_future()
         self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
         return await self.connection_result
 
-    async def disconnect(self):
-        if self.state != Multiplexer.CONNECTED:
+    async def disconnect(self) -> None:
+        if self.state != Multiplexer.State.CONNECTED:
             return
 
         self.disconnection_result = asyncio.get_running_loop().create_future()
-        self.change_state(Multiplexer.DISCONNECTING)
+        self.change_state(Multiplexer.State.DISCONNECTING)
         self.send_frame(
             RFCOMM_Frame.disc(
-                c_r=1 if self.role == Multiplexer.INITIATOR else 0, dlci=0
+                c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=0
             )
         )
         await self.disconnection_result
 
-    async def open_dlc(self, channel):
-        if self.state != Multiplexer.CONNECTED:
-            if self.state == Multiplexer.OPENING:
+    async def open_dlc(self, channel: int) -> DLC:
+        if self.state != Multiplexer.State.CONNECTED:
+            if self.state == Multiplexer.State.OPENING:
                 raise InvalidStateError('open already in progress')
 
             raise InvalidStateError('not connected')
@@ -784,10 +862,10 @@
         mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
         logger.debug(f'>>> Sending MCC: {pn}')
         self.open_result = asyncio.get_running_loop().create_future()
-        self.change_state(Multiplexer.OPENING)
+        self.change_state(Multiplexer.State.OPENING)
         self.send_frame(
             RFCOMM_Frame.uih(
-                c_r=1 if self.role == Multiplexer.INITIATOR else 0,
+                c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0,
                 dlci=0,
                 information=mcc,
             )
@@ -796,25 +874,28 @@
         self.open_result = None
         return result
 
-    def on_dlc_open_complete(self, dlc):
+    def on_dlc_open_complete(self, dlc: DLC) -> None:
         logger.debug(f'DLC [{dlc.dlci}] open complete')
-        self.change_state(Multiplexer.CONNECTED)
+        self.change_state(Multiplexer.State.CONNECTED)
         if self.open_result:
             self.open_result.set_result(dlc)
 
-    def __str__(self):
-        return f'Multiplexer(state={self.state_name(self.state)})'
+    def __str__(self) -> str:
+        return f'Multiplexer(state={self.state.name})'
 
 
 # -----------------------------------------------------------------------------
 class Client:
-    def __init__(self, device, connection):
+    multiplexer: Optional[Multiplexer]
+    l2cap_channel: Optional[l2cap.Channel]
+
+    def __init__(self, device: Device, connection: Connection) -> None:
         self.device = device
         self.connection = connection
         self.l2cap_channel = None
         self.multiplexer = None
 
-    async def start(self):
+    async def start(self) -> Multiplexer:
         # Create a new L2CAP connection
         try:
             self.l2cap_channel = await self.device.l2cap_channel_manager.connect(
@@ -824,15 +905,18 @@
             logger.warning(f'L2CAP connection failed: {error}')
             raise
 
+        assert self.l2cap_channel is not None
         # Create a mutliplexer to manage DLCs with the server
-        self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
+        self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
 
         # Connect the multiplexer
         await self.multiplexer.connect()
 
         return self.multiplexer
 
-    async def shutdown(self):
+    async def shutdown(self) -> None:
+        if self.multiplexer is None:
+            return
         # Disconnect the multiplexer
         await self.multiplexer.disconnect()
         self.multiplexer = None
@@ -843,7 +927,9 @@
 
 # -----------------------------------------------------------------------------
 class Server(EventEmitter):
-    def __init__(self, device):
+    acceptors: Dict[int, Callable[[DLC], None]]
+
+    def __init__(self, device: Device) -> None:
         super().__init__()
         self.device = device
         self.multiplexer = None
@@ -852,7 +938,7 @@
         # Register ourselves with the L2CAP channel manager
         device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
 
-    def listen(self, acceptor, channel=0):
+    def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
         if channel:
             if channel in self.acceptors:
                 # Busy
@@ -874,25 +960,25 @@
         self.acceptors[channel] = acceptor
         return channel
 
-    def on_connection(self, l2cap_channel):
+    def on_connection(self, l2cap_channel: l2cap.Channel) -> None:
         logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
         l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
 
-    def on_l2cap_channel_open(self, l2cap_channel):
+    def on_l2cap_channel_open(self, l2cap_channel: l2cap.Channel) -> None:
         logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
 
         # Create a new multiplexer for the channel
-        multiplexer = Multiplexer(l2cap_channel, Multiplexer.RESPONDER)
+        multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
         multiplexer.acceptor = self.accept_dlc
         multiplexer.on('dlc', self.on_dlc)
 
         # Notify
         self.emit('start', multiplexer)
 
-    def accept_dlc(self, channel_number):
+    def accept_dlc(self, channel_number: int) -> bool:
         return channel_number in self.acceptors
 
-    def on_dlc(self, dlc):
+    def on_dlc(self, dlc: DLC) -> None:
         logger.debug(f'@@@ new DLC connected: {dlc}')
 
         # Let the acceptor know
diff --git a/bumble/sdp.py b/bumble/sdp.py
index 019b8e6..6428187 100644
--- a/bumble/sdp.py
+++ b/bumble/sdp.py
@@ -18,13 +18,16 @@
 from __future__ import annotations
 import logging
 import struct
-from typing import Dict, List, Type
+from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
 
-from . import core
+from . import core, l2cap
 from .colors import color
 from .core import InvalidStateError
 from .hci import HCI_Object, name_or_number, key_with_value
 
+if TYPE_CHECKING:
+    from .device import Device, Connection
+
 # -----------------------------------------------------------------------------
 # Logging
 # -----------------------------------------------------------------------------
@@ -94,6 +97,10 @@
 SDP_ICON_URL_ATTRIBUTE_ID                            = 0X000C
 SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
 
+# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
+# used by AVRCP, HFP and A2DP
+SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
+
 SDP_ATTRIBUTE_ID_NAMES = {
     SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID:               'SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID',
     SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:               'SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID',
@@ -462,7 +469,7 @@
         self.value = value
 
     @staticmethod
-    def list_from_data_elements(elements):
+    def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
         attribute_list = []
         for i in range(0, len(elements) // 2):
             attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
@@ -474,7 +481,9 @@
         return attribute_list
 
     @staticmethod
-    def find_attribute_in_list(attribute_list, attribute_id):
+    def find_attribute_in_list(
+        attribute_list: List[ServiceAttribute], attribute_id: int
+    ) -> Optional[DataElement]:
         return next(
             (
                 attribute.value
@@ -489,7 +498,7 @@
         return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
 
     @staticmethod
-    def is_uuid_in_value(uuid, value):
+    def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
         # Find if a uuid matches a value, either directly or recursing into sequences
         if value.type == DataElement.UUID:
             return value.value == uuid
@@ -543,7 +552,9 @@
         return self
 
     @staticmethod
-    def parse_service_record_handle_list_preceded_by_count(data, offset):
+    def parse_service_record_handle_list_preceded_by_count(
+        data: bytes, offset: int
+    ) -> Tuple[int, List[int]]:
         count = struct.unpack_from('>H', data, offset - 2)[0]
         handle_list = [
             struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
@@ -641,6 +652,10 @@
     See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
     '''
 
+    service_search_pattern: DataElement
+    maximum_service_record_count: int
+    continuation_state: bytes
+
 
 # -----------------------------------------------------------------------------
 @SDP_PDU.subclass(
@@ -659,6 +674,11 @@
     See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
     '''
 
+    service_record_handle_list: List[int]
+    total_service_record_count: int
+    current_service_record_count: int
+    continuation_state: bytes
+
 
 # -----------------------------------------------------------------------------
 @SDP_PDU.subclass(
@@ -674,6 +694,11 @@
     See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
     '''
 
+    service_record_handle: int
+    maximum_attribute_byte_count: int
+    attribute_id_list: DataElement
+    continuation_state: bytes
+
 
 # -----------------------------------------------------------------------------
 @SDP_PDU.subclass(
@@ -688,6 +713,10 @@
     See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
     '''
 
+    attribute_list_byte_count: int
+    attribute_list: bytes
+    continuation_state: bytes
+
 
 # -----------------------------------------------------------------------------
 @SDP_PDU.subclass(
@@ -703,6 +732,11 @@
     See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
     '''
 
+    service_search_pattern: DataElement
+    maximum_attribute_byte_count: int
+    attribute_id_list: DataElement
+    continuation_state: bytes
+
 
 # -----------------------------------------------------------------------------
 @SDP_PDU.subclass(
@@ -717,26 +751,34 @@
     See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
     '''
 
+    attribute_list_byte_count: int
+    attribute_list: bytes
+    continuation_state: bytes
+
 
 # -----------------------------------------------------------------------------
 class Client:
-    def __init__(self, device):
+    channel: Optional[l2cap.Channel]
+
+    def __init__(self, device: Device) -> None:
         self.device = device
         self.pending_request = None
         self.channel = None
 
-    async def connect(self, connection):
+    async def connect(self, connection: Connection) -> None:
         result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
         self.channel = result
 
-    async def disconnect(self):
+    async def disconnect(self) -> None:
         if self.channel:
             await self.channel.disconnect()
             self.channel = None
 
-    async def search_services(self, uuids):
+    async def search_services(self, uuids: List[core.UUID]) -> List[int]:
         if self.pending_request is not None:
             raise InvalidStateError('request already pending')
+        if self.channel is None:
+            raise InvalidStateError('L2CAP not connected')
 
         service_search_pattern = DataElement.sequence(
             [DataElement.uuid(uuid) for uuid in uuids]
@@ -766,9 +808,13 @@
 
         return service_record_handle_list
 
-    async def search_attributes(self, uuids, attribute_ids):
+    async def search_attributes(
+        self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
+    ) -> List[List[ServiceAttribute]]:
         if self.pending_request is not None:
             raise InvalidStateError('request already pending')
+        if self.channel is None:
+            raise InvalidStateError('L2CAP not connected')
 
         service_search_pattern = DataElement.sequence(
             [DataElement.uuid(uuid) for uuid in uuids]
@@ -819,9 +865,15 @@
             if sequence.type == DataElement.SEQUENCE
         ]
 
-    async def get_attributes(self, service_record_handle, attribute_ids):
+    async def get_attributes(
+        self,
+        service_record_handle: int,
+        attribute_ids: List[Union[int, Tuple[int, int]]],
+    ) -> List[ServiceAttribute]:
         if self.pending_request is not None:
             raise InvalidStateError('request already pending')
+        if self.channel is None:
+            raise InvalidStateError('L2CAP not connected')
 
         attribute_id_list = DataElement.sequence(
             [
@@ -869,21 +921,25 @@
 # -----------------------------------------------------------------------------
 class Server:
     CONTINUATION_STATE = bytes([0x01, 0x43])
+    channel: Optional[l2cap.Channel]
+    Service = NewType('Service', List[ServiceAttribute])
+    service_records: Dict[int, Service]
+    current_response: Union[None, bytes, Tuple[int, List[int]]]
 
-    def __init__(self, device):
+    def __init__(self, device: Device) -> None:
         self.device = device
         self.service_records = {}  # Service records maps, by record handle
         self.channel = None
         self.current_response = None
 
-    def register(self, l2cap_channel_manager):
+    def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
         l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
 
     def send_response(self, response):
         logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
         self.channel.send_pdu(response)
 
-    def match_services(self, search_pattern):
+    def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
         # Find the services for which the attributes in the pattern is a subset of the
         # service's attribute values (NOTE: the value search recurses into sequences)
         matching_services = {}
@@ -953,7 +1009,9 @@
         return (payload, continuation_state)
 
     @staticmethod
-    def get_service_attributes(service, attribute_ids):
+    def get_service_attributes(
+        service: Service, attribute_ids: List[DataElement]
+    ) -> DataElement:
         attributes = []
         for attribute_id in attribute_ids:
             if attribute_id.value_size == 4:
@@ -978,10 +1036,10 @@
 
         return attribute_list
 
-    def on_sdp_service_search_request(self, request):
+    def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
         # Check if this is a continuation
         if len(request.continuation_state) > 1:
-            if not self.current_response:
+            if self.current_response is None:
                 self.send_response(
                     SDP_ErrorResponse(
                         transaction_id=request.transaction_id,
@@ -1010,6 +1068,7 @@
             )
 
         # Respond, keeping any unsent handles for later
+        assert isinstance(self.current_response, tuple)
         service_record_handles = self.current_response[1][
             : request.maximum_service_record_count
         ]
@@ -1033,10 +1092,12 @@
             )
         )
 
-    def on_sdp_service_attribute_request(self, request):
+    def on_sdp_service_attribute_request(
+        self, request: SDP_ServiceAttributeRequest
+    ) -> None:
         # Check if this is a continuation
         if len(request.continuation_state) > 1:
-            if not self.current_response:
+            if self.current_response is None:
                 self.send_response(
                     SDP_ErrorResponse(
                         transaction_id=request.transaction_id,
@@ -1069,22 +1130,24 @@
             self.current_response = bytes(attribute_list)
 
         # Respond, keeping any pending chunks for later
-        attribute_list, continuation_state = self.get_next_response_payload(
+        attribute_list_response, continuation_state = self.get_next_response_payload(
             request.maximum_attribute_byte_count
         )
         self.send_response(
             SDP_ServiceAttributeResponse(
                 transaction_id=request.transaction_id,
-                attribute_list_byte_count=len(attribute_list),
+                attribute_list_byte_count=len(attribute_list_response),
                 attribute_list=attribute_list,
                 continuation_state=continuation_state,
             )
         )
 
-    def on_sdp_service_search_attribute_request(self, request):
+    def on_sdp_service_search_attribute_request(
+        self, request: SDP_ServiceSearchAttributeRequest
+    ) -> None:
         # Check if this is a continuation
         if len(request.continuation_state) > 1:
-            if not self.current_response:
+            if self.current_response is None:
                 self.send_response(
                     SDP_ErrorResponse(
                         transaction_id=request.transaction_id,
@@ -1114,13 +1177,13 @@
             self.current_response = bytes(attribute_lists)
 
         # Respond, keeping any pending chunks for later
-        attribute_lists, continuation_state = self.get_next_response_payload(
+        attribute_lists_response, continuation_state = self.get_next_response_payload(
             request.maximum_attribute_byte_count
         )
         self.send_response(
             SDP_ServiceSearchAttributeResponse(
                 transaction_id=request.transaction_id,
-                attribute_lists_byte_count=len(attribute_lists),
+                attribute_lists_byte_count=len(attribute_lists_response),
                 attribute_lists=attribute_lists,
                 continuation_state=continuation_state,
             )
diff --git a/bumble/smp.py b/bumble/smp.py
index f3fbf27..f8bba40 100644
--- a/bumble/smp.py
+++ b/bumble/smp.py
@@ -25,6 +25,7 @@
 from __future__ import annotations
 import logging
 import asyncio
+import enum
 import secrets
 from typing import (
     TYPE_CHECKING,
@@ -36,6 +37,7 @@
     Optional,
     Tuple,
     Type,
+    cast,
 )
 
 from pyee import EventEmitter
@@ -553,20 +555,16 @@
 
 
 # -----------------------------------------------------------------------------
-class Session:
-    # Pairing methods
+class PairingMethod(enum.IntEnum):
     JUST_WORKS = 0
     NUMERIC_COMPARISON = 1
     PASSKEY = 2
     OOB = 3
+    CTKD_OVER_CLASSIC = 4
 
-    PAIRING_METHOD_NAMES = {
-        JUST_WORKS: 'JUST_WORKS',
-        NUMERIC_COMPARISON: 'NUMERIC_COMPARISON',
-        PASSKEY: 'PASSKEY',
-        OOB: 'OOB',
-    }
 
+# -----------------------------------------------------------------------------
+class Session:
     # I/O Capability to pairing method decision matrix
     #
     # See Bluetooth spec @ Vol 3, part H - Table 2.8: Mapping of IO Capabilities to Key
@@ -581,47 +579,50 @@
     # (False).
     PAIRING_METHODS = {
         SMP_DISPLAY_ONLY_IO_CAPABILITY: {
-            SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
-            SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
-            SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
-            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
-            SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, True, False),
+            SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
+            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
         },
         SMP_DISPLAY_YES_NO_IO_CAPABILITY: {
-            SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
-            SMP_DISPLAY_YES_NO_IO_CAPABILITY: (JUST_WORKS, NUMERIC_COMPARISON),
-            SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
-            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
+            SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
+                PairingMethod.JUST_WORKS,
+                PairingMethod.NUMERIC_COMPARISON,
+            ),
+            SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
+            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
             SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
-                (PASSKEY, True, False),
-                NUMERIC_COMPARISON,
+                (PairingMethod.PASSKEY, True, False),
+                PairingMethod.NUMERIC_COMPARISON,
             ),
         },
         SMP_KEYBOARD_ONLY_IO_CAPABILITY: {
-            SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
-            SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PASSKEY, False, True),
-            SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, False, False),
-            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
-            SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, False, True),
+            SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
+            SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
+            SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, False),
+            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
         },
         SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
-            SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
-            SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
-            SMP_KEYBOARD_ONLY_IO_CAPABILITY: JUST_WORKS,
-            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
-            SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: JUST_WORKS,
+            SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_KEYBOARD_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+            SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
         },
         SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: {
-            SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
+            SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
             SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
-                (PASSKEY, False, True),
-                NUMERIC_COMPARISON,
+                (PairingMethod.PASSKEY, False, True),
+                PairingMethod.NUMERIC_COMPARISON,
             ),
-            SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
-            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
+            SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
+            SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
             SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
-                (PASSKEY, True, False),
-                NUMERIC_COMPARISON,
+                (PairingMethod.PASSKEY, True, False),
+                PairingMethod.NUMERIC_COMPARISON,
             ),
         },
     }
@@ -664,7 +665,7 @@
         self.passkey_ready = asyncio.Event()
         self.passkey_step = 0
         self.passkey_display = False
-        self.pairing_method = 0
+        self.pairing_method: PairingMethod = PairingMethod.JUST_WORKS
         self.pairing_config = pairing_config
         self.wait_before_continuing: Optional[asyncio.Future[None]] = None
         self.completed = False
@@ -769,19 +770,23 @@
     def decide_pairing_method(
         self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
     ) -> None:
+        if self.connection.transport == BT_BR_EDR_TRANSPORT:
+            self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
+            return
         if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
-            self.pairing_method = self.JUST_WORKS
+            self.pairing_method = PairingMethod.JUST_WORKS
             return
 
         details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability]  # type: ignore[index]
         if isinstance(details, tuple) and len(details) == 2:
             # One entry for legacy pairing and one for secure connections
             details = details[1 if self.sc else 0]
-        if isinstance(details, int):
+        if isinstance(details, PairingMethod):
             # Just a method ID
             self.pairing_method = details
         else:
             # PASSKEY method, with a method ID and display/input flags
+            assert isinstance(details[0], PairingMethod)
             self.pairing_method = details[0]
             self.passkey_display = details[1 if self.is_initiator else 2]
 
@@ -858,10 +863,13 @@
             self.tk = self.passkey.to_bytes(16, byteorder='little')
             logger.debug(f'TK from passkey = {self.tk.hex()}')
 
-        self.connection.abort_on(
-            'disconnection',
-            self.pairing_config.delegate.display_number(self.passkey, digits=6),
-        )
+        try:
+            self.connection.abort_on(
+                'disconnection',
+                self.pairing_config.delegate.display_number(self.passkey, digits=6),
+            )
+        except Exception as error:
+            logger.warning(f'exception while displaying number: {error}')
 
     def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None:
         # Prompt the user for the passkey displayed on the peer
@@ -929,9 +937,12 @@
         if self.sc:
 
             async def next_steps() -> None:
-                if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+                if self.pairing_method in (
+                    PairingMethod.JUST_WORKS,
+                    PairingMethod.NUMERIC_COMPARISON,
+                ):
                     z = 0
-                elif self.pairing_method == self.PASSKEY:
+                elif self.pairing_method == PairingMethod.PASSKEY:
                     # We need a passkey
                     await self.passkey_ready.wait()
                     assert self.passkey
@@ -983,6 +994,19 @@
             )
         )
 
+    def send_identity_address_command(self) -> None:
+        identity_address = {
+            None: self.connection.self_address,
+            Address.PUBLIC_DEVICE_ADDRESS: self.manager.device.public_address,
+            Address.RANDOM_DEVICE_ADDRESS: self.manager.device.random_address,
+        }[self.pairing_config.identity_address_type]
+        self.send_command(
+            SMP_Identity_Address_Information_Command(
+                addr_type=identity_address.address_type,
+                bd_addr=identity_address,
+            )
+        )
+
     def start_encryption(self, key: bytes) -> None:
         # We can now encrypt the connection with the short term key, so that we can
         # distribute the long term and/or other keys over an encrypted connection
@@ -1006,6 +1030,7 @@
         self.ltk = crypto.h6(ilk, b'brle')
 
     def distribute_keys(self) -> None:
+
         # Distribute the keys as required
         if self.is_initiator:
             # CTKD: Derive LTK from LinkKey
@@ -1035,12 +1060,7 @@
                         identity_resolving_key=self.manager.device.irk
                     )
                 )
-                self.send_command(
-                    SMP_Identity_Address_Information_Command(
-                        addr_type=self.connection.self_address.address_type,
-                        bd_addr=self.connection.self_address,
-                    )
-                )
+                self.send_identity_address_command()
 
             # Distribute CSRK
             csrk = bytes(16)  # FIXME: testing
@@ -1084,12 +1104,7 @@
                         identity_resolving_key=self.manager.device.irk
                     )
                 )
-                self.send_command(
-                    SMP_Identity_Address_Information_Command(
-                        addr_type=self.connection.self_address.address_type,
-                        bd_addr=self.connection.self_address,
-                    )
-                )
+                self.send_identity_address_command()
 
             # Distribute CSRK
             csrk = bytes(16)  # FIXME: testing
@@ -1224,7 +1239,7 @@
         # Create an object to hold the keys
         keys = PairingKeys()
         keys.address_type = peer_address.address_type
-        authenticated = self.pairing_method != self.JUST_WORKS
+        authenticated = self.pairing_method != PairingMethod.JUST_WORKS
         if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
             keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
         else:
@@ -1258,7 +1273,7 @@
             keys.link_key = PairingKeys.Key(
                 value=self.link_key, authenticated=authenticated
             )
-        self.manager.on_pairing(self, peer_address, keys)
+        await self.manager.on_pairing(self, peer_address, keys)
 
     def on_pairing_failure(self, reason: int) -> None:
         logger.warning(f'pairing failure ({error_name(reason)})')
@@ -1300,7 +1315,11 @@
         self, command: SMP_Pairing_Request_Command
     ) -> None:
         # Check if the request should proceed
-        accepted = await self.pairing_config.delegate.accept()
+        try:
+            accepted = await self.pairing_config.delegate.accept()
+        except Exception as error:
+            logger.warning(f'exception while accepting: {error}')
+            accepted = False
         if not accepted:
             logger.debug('pairing rejected by delegate')
             self.send_pairing_failed(SMP_PAIRING_NOT_SUPPORTED_ERROR)
@@ -1323,9 +1342,7 @@
         self.decide_pairing_method(
             command.auth_req, command.io_capability, self.io_capability
         )
-        logger.debug(
-            f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
-        )
+        logger.debug(f'pairing method: {self.pairing_method.name}')
 
         # Key distribution
         (
@@ -1341,7 +1358,7 @@
 
         # Display a passkey if we need to
         if not self.sc:
-            if self.pairing_method == self.PASSKEY and self.passkey_display:
+            if self.pairing_method == PairingMethod.PASSKEY and self.passkey_display:
                 self.display_passkey()
 
         # Respond
@@ -1382,9 +1399,7 @@
         self.decide_pairing_method(
             command.auth_req, self.io_capability, command.io_capability
         )
-        logger.debug(
-            f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
-        )
+        logger.debug(f'pairing method: {self.pairing_method.name}')
 
         # Key distribution
         if (
@@ -1400,13 +1415,16 @@
         self.compute_peer_expected_distributions(self.responder_key_distribution)
 
         # Start phase 2
-        if self.sc:
-            if self.pairing_method == self.PASSKEY:
+        if self.pairing_method == PairingMethod.CTKD_OVER_CLASSIC:
+            # Authentication is already done in SMP, so remote shall start keys distribution immediately
+            return
+        elif self.sc:
+            if self.pairing_method == PairingMethod.PASSKEY:
                 self.display_or_input_passkey()
 
             self.send_public_key_command()
         else:
-            if self.pairing_method == self.PASSKEY:
+            if self.pairing_method == PairingMethod.PASSKEY:
                 self.display_or_input_passkey(self.send_pairing_confirm_command)
             else:
                 self.send_pairing_confirm_command()
@@ -1418,7 +1436,10 @@
             self.send_pairing_random_command()
         else:
             # If the method is PASSKEY, now is the time to input the code
-            if self.pairing_method == self.PASSKEY and not self.passkey_display:
+            if (
+                self.pairing_method == PairingMethod.PASSKEY
+                and not self.passkey_display
+            ):
                 self.input_passkey(self.send_pairing_confirm_command)
             else:
                 self.send_pairing_confirm_command()
@@ -1426,11 +1447,14 @@
     def on_smp_pairing_confirm_command_secure_connections(
         self, _: SMP_Pairing_Confirm_Command
     ) -> None:
-        if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+        if self.pairing_method in (
+            PairingMethod.JUST_WORKS,
+            PairingMethod.NUMERIC_COMPARISON,
+        ):
             if self.is_initiator:
                 self.r = crypto.r()
                 self.send_pairing_random_command()
-        elif self.pairing_method == self.PASSKEY:
+        elif self.pairing_method == PairingMethod.PASSKEY:
             if self.is_initiator:
                 self.send_pairing_random_command()
             else:
@@ -1486,13 +1510,16 @@
     def on_smp_pairing_random_command_secure_connections(
         self, command: SMP_Pairing_Random_Command
     ) -> None:
-        if self.pairing_method == self.PASSKEY and self.passkey is None:
+        if self.pairing_method == PairingMethod.PASSKEY and self.passkey is None:
             logger.warning('no passkey entered, ignoring command')
             return
 
         # pylint: disable=too-many-return-statements
         if self.is_initiator:
-            if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+            if self.pairing_method in (
+                PairingMethod.JUST_WORKS,
+                PairingMethod.NUMERIC_COMPARISON,
+            ):
                 assert self.confirm_value
                 # Check that the random value matches what was committed to earlier
                 confirm_verifier = crypto.f4(
@@ -1502,7 +1529,7 @@
                     self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
                 ):
                     return
-            elif self.pairing_method == self.PASSKEY:
+            elif self.pairing_method == PairingMethod.PASSKEY:
                 assert self.passkey and self.confirm_value
                 # Check that the random value matches what was committed to earlier
                 confirm_verifier = crypto.f4(
@@ -1525,9 +1552,12 @@
             else:
                 return
         else:
-            if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+            if self.pairing_method in (
+                PairingMethod.JUST_WORKS,
+                PairingMethod.NUMERIC_COMPARISON,
+            ):
                 self.send_pairing_random_command()
-            elif self.pairing_method == self.PASSKEY:
+            elif self.pairing_method == PairingMethod.PASSKEY:
                 assert self.passkey and self.confirm_value
                 # Check that the random value matches what was committed to earlier
                 confirm_verifier = crypto.f4(
@@ -1558,10 +1588,13 @@
         (mac_key, self.ltk) = crypto.f5(self.dh_key, self.na, self.nb, a, b)
 
         # Compute the DH Key checks
-        if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+        if self.pairing_method in (
+            PairingMethod.JUST_WORKS,
+            PairingMethod.NUMERIC_COMPARISON,
+        ):
             ra = bytes(16)
             rb = ra
-        elif self.pairing_method == self.PASSKEY:
+        elif self.pairing_method == PairingMethod.PASSKEY:
             assert self.passkey
             ra = self.passkey.to_bytes(16, byteorder='little')
             rb = ra
@@ -1585,13 +1618,16 @@
                     self.wait_before_continuing.set_result(None)
 
         # Prompt the user for confirmation if needed
-        if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+        if self.pairing_method in (
+            PairingMethod.JUST_WORKS,
+            PairingMethod.NUMERIC_COMPARISON,
+        ):
             # Compute the 6-digit code
             code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
 
             # Ask for user confirmation
             self.wait_before_continuing = asyncio.get_running_loop().create_future()
-            if self.pairing_method == self.JUST_WORKS:
+            if self.pairing_method == PairingMethod.JUST_WORKS:
                 self.prompt_user_for_confirmation(next_steps)
             else:
                 self.prompt_user_for_numeric_comparison(code, next_steps)
@@ -1628,13 +1664,16 @@
         if self.is_initiator:
             self.send_pairing_confirm_command()
         else:
-            if self.pairing_method == self.PASSKEY:
+            if self.pairing_method == PairingMethod.PASSKEY:
                 self.display_or_input_passkey()
 
             # Send our public key back to the initiator
             self.send_public_key_command()
 
-            if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+            if self.pairing_method in (
+                PairingMethod.JUST_WORKS,
+                PairingMethod.NUMERIC_COMPARISON,
+            ):
                 # We can now send the confirmation value
                 self.send_pairing_confirm_command()
 
@@ -1733,7 +1772,26 @@
         cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
         connection.send_l2cap_pdu(cid, command.to_bytes())
 
+    def on_smp_security_request_command(
+        self, connection: Connection, request: SMP_Security_Request_Command
+    ) -> None:
+        connection.emit('security_request', request.auth_req)
+
     def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None:
+        # Parse the L2CAP payload into an SMP Command object
+        command = SMP_Command.from_bytes(pdu)
+        logger.debug(
+            f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
+            f'{connection.peer_address}: {command}'
+        )
+
+        # Security request is more than just pairing, so let applications handle them
+        if command.code == SMP_SECURITY_REQUEST_COMMAND:
+            self.on_smp_security_request_command(
+                connection, cast(SMP_Security_Request_Command, command)
+            )
+            return
+
         # Look for a session with this connection, and create one if none exists
         if not (session := self.sessions.get(connection.handle)):
             if connection.role == BT_CENTRAL_ROLE:
@@ -1744,13 +1802,6 @@
             )
             self.sessions[connection.handle] = session
 
-        # Parse the L2CAP payload into an SMP Command object
-        command = SMP_Command.from_bytes(pdu)
-        logger.debug(
-            f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
-            f'{connection.peer_address}: {command}'
-        )
-
         # Delegate the handling of the command to the session
         session.on_smp_command(command)
 
@@ -1789,23 +1840,17 @@
     def on_session_start(self, session: Session) -> None:
         self.device.on_pairing_start(session.connection)
 
-    def on_pairing(
+    async def on_pairing(
         self, session: Session, identity_address: Optional[Address], keys: PairingKeys
     ) -> None:
         # Store the keys in the key store
         if self.device.keystore and identity_address is not None:
-
-            async def store_keys():
-                try:
-                    assert self.device.keystore
-                    await self.device.keystore.update(str(identity_address), keys)
-                except Exception as error:
-                    logger.warning(f'!!! error while storing keys: {error}')
-
-            self.device.abort_on('flush', store_keys())
+            self.device.abort_on(
+                'flush', self.device.update_keys(str(identity_address), keys)
+            )
 
         # Notify the device
-        self.device.on_pairing(session.connection, keys, session.sc)
+        self.device.on_pairing(session.connection, identity_address, keys, session.sc)
 
     def on_pairing_failure(self, session: Session, reason: int) -> None:
         self.device.on_pairing_failure(session.connection, reason)
diff --git a/bumble/transport/__init__.py b/bumble/transport/__init__.py
index 840b3e5..bc0766b 100644
--- a/bumble/transport/__init__.py
+++ b/bumble/transport/__init__.py
@@ -20,7 +20,6 @@
 import os
 
 from .common import Transport, AsyncPipeSink, SnoopingTransport
-from ..controller import Controller
 from ..snoop import create_snooper
 
 # -----------------------------------------------------------------------------
@@ -69,6 +68,7 @@
       * usb
       * pyusb
       * android-emulator
+      * android-netsim
     """
 
     return _wrap_transport(await _open_transport(name))
@@ -118,7 +118,8 @@
     if scheme == 'file':
         from .file import open_file_transport
 
-        return await open_file_transport(spec[0] if spec else None)
+        assert spec is not None
+        return await open_file_transport(spec[0])
 
     if scheme == 'vhci':
         from .vhci import open_vhci_transport
@@ -133,12 +134,14 @@
     if scheme == 'usb':
         from .usb import open_usb_transport
 
-        return await open_usb_transport(spec[0] if spec else None)
+        assert spec is not None
+        return await open_usb_transport(spec[0])
 
     if scheme == 'pyusb':
         from .pyusb import open_pyusb_transport
 
-        return await open_pyusb_transport(spec[0] if spec else None)
+        assert spec is not None
+        return await open_pyusb_transport(spec[0])
 
     if scheme == 'android-emulator':
         from .android_emulator import open_android_emulator_transport
@@ -167,6 +170,7 @@
 
     """
     if name.startswith('link-relay:'):
+        from ..controller import Controller
         from ..link import RemoteLink  # lazy import
 
         link = RemoteLink(name[11:])
diff --git a/bumble/transport/android_emulator.py b/bumble/transport/android_emulator.py
index b78e263..8d19a9e 100644
--- a/bumble/transport/android_emulator.py
+++ b/bumble/transport/android_emulator.py
@@ -18,7 +18,9 @@
 import logging
 import grpc.aio
 
-from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
+from typing import Optional, Union
+
+from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
 
 # pylint: disable=no-name-in-module
 from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
@@ -33,7 +35,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_android_emulator_transport(spec):
+async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
     '''
     Open a transport connection to an Android emulator via its gRPC interface.
     The parameter string has this syntax:
@@ -66,7 +68,7 @@
     # Parse the parameters
     mode = 'host'
     server_host = 'localhost'
-    server_port = 8554
+    server_port = '8554'
     if spec is not None:
         params = spec.split(',')
         for param in params:
@@ -82,6 +84,7 @@
     logger.debug(f'connecting to gRPC server at {server_address}')
     channel = grpc.aio.insecure_channel(server_address)
 
+    service: Union[EmulatedBluetoothServiceStub, VhciForwardingServiceStub]
     if mode == 'host':
         # Connect as a host
         service = EmulatedBluetoothServiceStub(channel)
@@ -94,10 +97,13 @@
         raise ValueError('invalid mode')
 
     # Create the transport object
-    transport = PumpedTransport(
-        PumpedPacketSource(hci_device.read),
-        PumpedPacketSink(hci_device.write),
-        channel.close,
+    class EmulatorTransport(PumpedTransport):
+        async def close(self):
+            await super().close()
+            await channel.close()
+
+    transport = EmulatorTransport(
+        PumpedPacketSource(hci_device.read), PumpedPacketSink(hci_device.write)
     )
     transport.start()
 
diff --git a/bumble/transport/android_netsim.py b/bumble/transport/android_netsim.py
index 99ebf87..e9d36cd 100644
--- a/bumble/transport/android_netsim.py
+++ b/bumble/transport/android_netsim.py
@@ -18,11 +18,12 @@
 import asyncio
 import atexit
 import logging
-import grpc.aio
 import os
 import pathlib
 import sys
-from typing import Optional
+from typing import Dict, Optional
+
+import grpc.aio
 
 from .common import (
     ParserSource,
@@ -33,8 +34,8 @@
 )
 
 # pylint: disable=no-name-in-module
-from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
 from .grpc_protobuf.packet_streamer_pb2_grpc import (
+    PacketStreamerStub,
     PacketStreamerServicer,
     add_PacketStreamerServicer_to_server,
 )
@@ -43,6 +44,7 @@
 from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
 from .grpc_protobuf.common_pb2 import ChipKind
 
+
 # -----------------------------------------------------------------------------
 # Logging
 # -----------------------------------------------------------------------------
@@ -74,14 +76,20 @@
 
 
 # -----------------------------------------------------------------------------
-def find_grpc_port() -> int:
+def ini_file_name(instance_number: int) -> str:
+    suffix = f'_{instance_number}' if instance_number > 0 else ''
+    return f'netsim{suffix}.ini'
+
+
+# -----------------------------------------------------------------------------
+def find_grpc_port(instance_number: int) -> int:
     if not (ini_dir := get_ini_dir()):
         logger.debug('no known directory for .ini file')
         return 0
 
-    ini_file = ini_dir / 'netsim.ini'
+    ini_file = ini_dir / ini_file_name(instance_number)
+    logger.debug(f'Looking for .ini file at {ini_file}')
     if ini_file.is_file():
-        logger.debug(f'Found .ini file at {ini_file}')
         with open(ini_file, 'r') as ini_file_data:
             for line in ini_file_data.readlines():
                 if '=' in line:
@@ -90,12 +98,14 @@
                         logger.debug(f'gRPC port = {value}')
                         return int(value)
 
+        logger.debug('no grpc.port property found in .ini file')
+
     # Not found
     return 0
 
 
 # -----------------------------------------------------------------------------
-def publish_grpc_port(grpc_port) -> bool:
+def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
     if not (ini_dir := get_ini_dir()):
         logger.debug('no known directory for .ini file')
         return False
@@ -104,7 +114,7 @@
         logger.debug('ini directory does not exist')
         return False
 
-    ini_file = ini_dir / 'netsim.ini'
+    ini_file = ini_dir / ini_file_name(instance_number)
     try:
         ini_file.write_text(f'grpc.port={grpc_port}\n')
         logger.debug(f"published gRPC port at {ini_file}")
@@ -121,13 +131,16 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_android_netsim_controller_transport(server_host, server_port):
+async def open_android_netsim_controller_transport(
+    server_host: Optional[str], server_port: int, options: Dict[str, str]
+) -> Transport:
     if not server_port:
         raise ValueError('invalid port')
     if server_host == '_' or not server_host:
         server_host = 'localhost'
 
-    if not publish_grpc_port(server_port):
+    instance_number = int(options.get('instance', "0"))
+    if not publish_grpc_port(server_port, instance_number):
         logger.warning("unable to publish gRPC port")
 
     class HciDevice:
@@ -184,15 +197,12 @@
                 logger.debug(f'<<< PACKET: {data.hex()}')
                 self.on_data_received(data)
 
-        def send_packet(self, data):
-            async def send():
-                await self.context.write(
-                    PacketResponse(
-                        hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
-                    )
+        async def send_packet(self, data):
+            return await self.context.write(
+                PacketResponse(
+                    hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
                 )
-
-            self.loop.create_task(send())
+            )
 
         def terminate(self):
             self.task.cancel()
@@ -226,17 +236,17 @@
                 logger.debug('gRPC server cancelled')
                 await self.grpc_server.stop(None)
 
-        def on_packet(self, packet):
+        async def send_packet(self, packet):
             if not self.device:
                 logger.debug('no device, dropping packet')
                 return
 
-            self.device.send_packet(packet)
+            return await self.device.send_packet(packet)
 
         async def StreamPackets(self, _request_iterator, context):
             logger.debug('StreamPackets request')
 
-            # Check that we won't already have a device
+            # Check that we don't already have a device
             if self.device:
                 logger.debug('busy, already serving a device')
                 return PacketResponse(error='Busy')
@@ -259,15 +269,42 @@
     await server.start()
     asyncio.get_running_loop().create_task(server.serve())
 
-    class GrpcServerTransport(Transport):
-        async def close(self):
-            await super().close()
-
-    return GrpcServerTransport(server, server)
+    sink = PumpedPacketSink(server.send_packet)
+    sink.start()
+    return Transport(server, sink)
 
 
 # -----------------------------------------------------------------------------
-async def open_android_netsim_host_transport(server_host, server_port, options):
+async def open_android_netsim_host_transport_with_address(
+    server_host: Optional[str],
+    server_port: int,
+    options: Optional[Dict[str, str]] = None,
+):
+    if server_host == '_' or not server_host:
+        server_host = 'localhost'
+
+    if not server_port:
+        # Look for the gRPC config in a .ini file
+        instance_number = 0 if options is None else int(options.get('instance', '0'))
+        server_port = find_grpc_port(instance_number)
+        if not server_port:
+            raise RuntimeError('gRPC server port not found')
+
+    # Connect to the gRPC server
+    server_address = f'{server_host}:{server_port}'
+    logger.debug(f'Connecting to gRPC server at {server_address}')
+    channel = grpc.aio.insecure_channel(server_address)
+
+    return await open_android_netsim_host_transport_with_channel(
+        channel,
+        options,
+    )
+
+
+# -----------------------------------------------------------------------------
+async def open_android_netsim_host_transport_with_channel(
+    channel, options: Optional[Dict[str, str]] = None
+):
     # Wrapper for I/O operations
     class HciDevice:
         def __init__(self, name, manufacturer, hci_device):
@@ -286,10 +323,12 @@
         async def read(self):
             response = await self.hci_device.read()
             response_type = response.WhichOneof('response_type')
+
             if response_type == 'error':
                 logger.warning(f'received error: {response.error}')
                 raise RuntimeError(response.error)
-            elif response_type == 'hci_packet':
+
+            if response_type == 'hci_packet':
                 return (
                     bytes([response.hci_packet.packet_type])
                     + response.hci_packet.packet
@@ -304,24 +343,9 @@
                 )
             )
 
-    name = options.get('name', DEFAULT_NAME)
+    name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
     manufacturer = DEFAULT_MANUFACTURER
 
-    if server_host == '_' or not server_host:
-        server_host = 'localhost'
-
-    if not server_port:
-        # Look for the gRPC config in a .ini file
-        server_host = 'localhost'
-        server_port = find_grpc_port()
-        if not server_port:
-            raise RuntimeError('gRPC server port not found')
-
-    # Connect to the gRPC server
-    server_address = f'{server_host}:{server_port}'
-    logger.debug(f'Connecting to gRPC server at {server_address}')
-    channel = grpc.aio.insecure_channel(server_address)
-
     # Connect as a host
     service = PacketStreamerStub(channel)
     hci_device = HciDevice(
@@ -332,10 +356,14 @@
     await hci_device.start()
 
     # Create the transport object
-    transport = PumpedTransport(
+    class GrpcTransport(PumpedTransport):
+        async def close(self):
+            await super().close()
+            await channel.close()
+
+    transport = GrpcTransport(
         PumpedPacketSource(hci_device.read),
         PumpedPacketSink(hci_device.write),
-        channel.close,
     )
     transport.start()
 
@@ -343,7 +371,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_android_netsim_transport(spec):
+async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
     '''
     Open a transport connection as a client or server, implementing Android's `netsim`
     simulator protocol over gRPC.
@@ -357,6 +385,11 @@
         to connect *to* a netsim server (netsim is the controller), or accept
         connections *as* a netsim-compatible server.
 
+      instance=<n>
+        Specifies an instance number, with <n> > 0. This is used to determine which
+        .init file to use. In `host` mode, it is ignored when the <host>:<port>
+        specifier is present, since in that case no .ini file is used.
+
     In `host` mode:
       The <host>:<port> part is optional. When not specified, the transport
       looks for a netsim .ini file, from which it will read the `grpc.backend.port`
@@ -385,14 +418,15 @@
     params = spec.split(',') if spec else []
     if params and ':' in params[0]:
         # Explicit <host>:<port>
-        host, port = params[0].split(':')
+        host, port_str = params[0].split(':')
+        port = int(port_str)
         params_offset = 1
     else:
         host = None
         port = 0
         params_offset = 0
 
-    options = {}
+    options: Dict[str, str] = {}
     for param in params[params_offset:]:
         if '=' not in param:
             raise ValueError('invalid parameter, expected <name>=<value>')
@@ -401,10 +435,12 @@
 
     mode = options.get('mode', 'host')
     if mode == 'host':
-        return await open_android_netsim_host_transport(host, port, options)
+        return await open_android_netsim_host_transport_with_address(
+            host, port, options
+        )
     if mode == 'controller':
         if host is None:
             raise ValueError('<host>:<port> missing')
-        return await open_android_netsim_controller_transport(host, port)
+        return await open_android_netsim_controller_transport(host, port, options)
 
     raise ValueError('invalid mode option')
diff --git a/bumble/transport/common.py b/bumble/transport/common.py
index 05a1fb5..2786a75 100644
--- a/bumble/transport/common.py
+++ b/bumble/transport/common.py
@@ -20,11 +20,12 @@
 import struct
 import asyncio
 import logging
-from typing import ContextManager
+import io
+from typing import ContextManager, Tuple, Optional, Protocol, Dict
 
-from .. import hci
-from ..colors import color
-from ..snoop import Snooper
+from bumble import hci
+from bumble.colors import color
+from bumble.snoop import Snooper
 
 
 # -----------------------------------------------------------------------------
@@ -36,7 +37,7 @@
 # Information needed to parse HCI packets with a generic parser:
 # For each packet type, the info represents:
 # (length-size, length-offset, unpack-type)
-HCI_PACKET_INFO = {
+HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
     hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
     hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
     hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
@@ -45,33 +46,54 @@
 
 
 # -----------------------------------------------------------------------------
-class PacketPump:
-    '''
-    Pump HCI packets from a reader to a sink
-    '''
+# Errors
+# -----------------------------------------------------------------------------
+class TransportLostError(Exception):
+    """
+    The Transport has been lost/disconnected.
+    """
 
-    def __init__(self, reader, sink):
+
+# -----------------------------------------------------------------------------
+# Typing Protocols
+# -----------------------------------------------------------------------------
+class TransportSink(Protocol):
+    def on_packet(self, packet: bytes) -> None:
+        ...
+
+
+class TransportSource(Protocol):
+    terminated: asyncio.Future[None]
+
+    def set_packet_sink(self, sink: TransportSink) -> None:
+        ...
+
+
+# -----------------------------------------------------------------------------
+class PacketPump:
+    """
+    Pump HCI packets from a reader to a sink.
+    """
+
+    def __init__(self, reader: AsyncPacketReader, sink: TransportSink) -> None:
         self.reader = reader
         self.sink = sink
 
-    async def run(self):
+    async def run(self) -> None:
         while True:
             try:
-                # Get a packet from the source
-                packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
-
                 # Deliver the packet to the sink
-                self.sink.on_packet(packet)
+                self.sink.on_packet(await self.reader.next_packet())
             except Exception as error:
                 logger.warning(f'!!! {error}')
 
 
 # -----------------------------------------------------------------------------
 class PacketParser:
-    '''
+    """
     In-line parser that accepts data and emits 'on_packet' when a full packet has been
-    parsed
-    '''
+    parsed.
+    """
 
     # pylint: disable=attribute-defined-outside-init
 
@@ -79,18 +101,22 @@
     NEED_LENGTH = 1
     NEED_BODY = 2
 
-    def __init__(self, sink=None):
+    sink: Optional[TransportSink]
+    extended_packet_info: Dict[int, Tuple[int, int, str]]
+    packet_info: Optional[Tuple[int, int, str]] = None
+
+    def __init__(self, sink: Optional[TransportSink] = None) -> None:
         self.sink = sink
         self.extended_packet_info = {}
         self.reset()
 
-    def reset(self):
+    def reset(self) -> None:
         self.state = PacketParser.NEED_TYPE
         self.bytes_needed = 1
         self.packet = bytearray()
         self.packet_info = None
 
-    def feed_data(self, data):
+    def feed_data(self, data: bytes) -> None:
         data_offset = 0
         data_left = len(data)
         while data_left and self.bytes_needed:
@@ -111,6 +137,7 @@
                     self.state = PacketParser.NEED_LENGTH
                     self.bytes_needed = self.packet_info[0] + self.packet_info[1]
                 elif self.state == PacketParser.NEED_LENGTH:
+                    assert self.packet_info is not None
                     body_length = struct.unpack_from(
                         self.packet_info[2], self.packet, 1 + self.packet_info[1]
                     )[0]
@@ -128,20 +155,20 @@
                             )
                     self.reset()
 
-    def set_packet_sink(self, sink):
+    def set_packet_sink(self, sink: TransportSink) -> None:
         self.sink = sink
 
 
 # -----------------------------------------------------------------------------
 class PacketReader:
-    '''
-    Reader that reads HCI packets from a sync source
-    '''
+    """
+    Reader that reads HCI packets from a sync source.
+    """
 
-    def __init__(self, source):
+    def __init__(self, source: io.BufferedReader) -> None:
         self.source = source
 
-    def next_packet(self):
+    def next_packet(self) -> Optional[bytes]:
         # Get the packet type
         packet_type = self.source.read(1)
         if len(packet_type) != 1:
@@ -150,7 +177,7 @@
         # Get the packet info based on its type
         packet_info = HCI_PACKET_INFO.get(packet_type[0])
         if packet_info is None:
-            raise ValueError(f'invalid packet type {packet_type} found')
+            raise ValueError(f'invalid packet type {packet_type[0]} found')
 
         # Read the header (that includes the length)
         header_size = packet_info[0] + packet_info[1]
@@ -169,21 +196,21 @@
 
 # -----------------------------------------------------------------------------
 class AsyncPacketReader:
-    '''
-    Reader that reads HCI packets from an async source
-    '''
+    """
+    Reader that reads HCI packets from an async source.
+    """
 
-    def __init__(self, source):
+    def __init__(self, source: asyncio.StreamReader) -> None:
         self.source = source
 
-    async def next_packet(self):
+    async def next_packet(self) -> bytes:
         # Get the packet type
         packet_type = await self.source.readexactly(1)
 
         # Get the packet info based on its type
         packet_info = HCI_PACKET_INFO.get(packet_type[0])
         if packet_info is None:
-            raise ValueError(f'invalid packet type {packet_type} found')
+            raise ValueError(f'invalid packet type {packet_type[0]} found')
 
         # Read the header (that includes the length)
         header_size = packet_info[0] + packet_info[1]
@@ -198,15 +225,15 @@
 
 # -----------------------------------------------------------------------------
 class AsyncPipeSink:
-    '''
-    Sink that forwards packets asynchronously to another sink
-    '''
+    """
+    Sink that forwards packets asynchronously to another sink.
+    """
 
-    def __init__(self, sink):
+    def __init__(self, sink: TransportSink) -> None:
         self.sink = sink
         self.loop = asyncio.get_running_loop()
 
-    def on_packet(self, packet):
+    def on_packet(self, packet: bytes) -> None:
         self.loop.call_soon(self.sink.on_packet, packet)
 
 
@@ -216,35 +243,48 @@
     Base class designed to be subclassed by transport-specific source classes
     """
 
-    def __init__(self):
+    terminated: asyncio.Future[None]
+    parser: PacketParser
+
+    def __init__(self) -> None:
         self.parser = PacketParser()
         self.terminated = asyncio.get_running_loop().create_future()
 
-    def set_packet_sink(self, sink):
+    def set_packet_sink(self, sink: TransportSink) -> None:
         self.parser.set_packet_sink(sink)
 
-    async def wait_for_termination(self):
+    def on_transport_lost(self) -> None:
+        self.terminated.set_result(None)
+        if self.parser.sink:
+            if hasattr(self.parser.sink, 'on_transport_lost'):
+                self.parser.sink.on_transport_lost()
+
+    async def wait_for_termination(self) -> None:
+        """
+        Convenience method for backward compatibility. Prefer using the `terminated`
+        attribute instead.
+        """
         return await self.terminated
 
-    def close(self):
+    def close(self) -> None:
         pass
 
 
 # -----------------------------------------------------------------------------
 class StreamPacketSource(asyncio.Protocol, ParserSource):
-    def data_received(self, data):
+    def data_received(self, data: bytes) -> None:
         self.parser.feed_data(data)
 
 
 # -----------------------------------------------------------------------------
 class StreamPacketSink:
-    def __init__(self, transport):
+    def __init__(self, transport: asyncio.WriteTransport) -> None:
         self.transport = transport
 
-    def on_packet(self, packet):
+    def on_packet(self, packet: bytes) -> None:
         self.transport.write(packet)
 
-    def close(self):
+    def close(self) -> None:
         self.transport.close()
 
 
@@ -264,7 +304,7 @@
         ...
     """
 
-    def __init__(self, source, sink):
+    def __init__(self, source: TransportSource, sink: TransportSink) -> None:
         self.source = source
         self.sink = sink
 
@@ -278,34 +318,39 @@
         return iter((self.source, self.sink))
 
     async def close(self) -> None:
-        self.source.close()
-        self.sink.close()
+        if hasattr(self.source, 'close'):
+            self.source.close()
+        if hasattr(self.sink, 'close'):
+            self.sink.close()
 
 
 # -----------------------------------------------------------------------------
 class PumpedPacketSource(ParserSource):
-    def __init__(self, receive):
+    pump_task: Optional[asyncio.Task[None]]
+
+    def __init__(self, receive) -> None:
         super().__init__()
         self.receive_function = receive
         self.pump_task = None
 
-    def start(self):
-        async def pump_packets():
+    def start(self) -> None:
+        async def pump_packets() -> None:
             while True:
                 try:
                     packet = await self.receive_function()
                     self.parser.feed_data(packet)
-                except asyncio.exceptions.CancelledError:
+                except asyncio.CancelledError:
                     logger.debug('source pump task done')
+                    self.terminated.set_result(None)
                     break
                 except Exception as error:
                     logger.warning(f'exception while waiting for packet: {error}')
-                    self.terminated.set_result(error)
+                    self.terminated.set_exception(error)
                     break
 
         self.pump_task = asyncio.create_task(pump_packets())
 
-    def close(self):
+    def close(self) -> None:
         if self.pump_task:
             self.pump_task.cancel()
 
@@ -317,7 +362,7 @@
         self.packet_queue = asyncio.Queue()
         self.pump_task = None
 
-    def on_packet(self, packet):
+    def on_packet(self, packet: bytes) -> None:
         self.packet_queue.put_nowait(packet)
 
     def start(self):
@@ -326,7 +371,7 @@
                 try:
                     packet = await self.packet_queue.get()
                     await self.send_function(packet)
-                except asyncio.exceptions.CancelledError:
+                except asyncio.CancelledError:
                     logger.debug('sink pump task done')
                     break
                 except Exception as error:
@@ -342,18 +387,20 @@
 
 # -----------------------------------------------------------------------------
 class PumpedTransport(Transport):
-    def __init__(self, source, sink, close_function):
-        super().__init__(source, sink)
-        self.close_function = close_function
+    source: PumpedPacketSource
+    sink: PumpedPacketSink
 
-    def start(self):
+    def __init__(
+        self,
+        source: PumpedPacketSource,
+        sink: PumpedPacketSink,
+    ) -> None:
+        super().__init__(source, sink)
+
+    def start(self) -> None:
         self.source.start()
         self.sink.start()
 
-    async def close(self):
-        await super().close()
-        await self.close_function()
-
 
 # -----------------------------------------------------------------------------
 class SnoopingTransport(Transport):
@@ -375,31 +422,38 @@
         raise RuntimeError('unexpected code path')  # Satisfy the type checker
 
     class Source:
-        def __init__(self, source, snooper):
+        sink: TransportSink
+
+        def __init__(self, source: TransportSource, snooper: Snooper):
             self.source = source
             self.snooper = snooper
-            self.sink = None
+            self.terminated = source.terminated
 
-        def set_packet_sink(self, sink):
+        def set_packet_sink(self, sink: TransportSink) -> None:
             self.sink = sink
             self.source.set_packet_sink(self)
 
-        def on_packet(self, packet):
+        def on_packet(self, packet: bytes) -> None:
             self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
             if self.sink:
                 self.sink.on_packet(packet)
 
     class Sink:
-        def __init__(self, sink, snooper):
+        def __init__(self, sink: TransportSink, snooper: Snooper) -> None:
             self.sink = sink
             self.snooper = snooper
 
-        def on_packet(self, packet):
+        def on_packet(self, packet: bytes) -> None:
             self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
             if self.sink:
                 self.sink.on_packet(packet)
 
-    def __init__(self, transport, snooper, close_snooper=None):
+    def __init__(
+        self,
+        transport: Transport,
+        snooper: Snooper,
+        close_snooper=None,
+    ) -> None:
         super().__init__(
             self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
         )
diff --git a/bumble/transport/file.py b/bumble/transport/file.py
index 9c073d2..dee1c23 100644
--- a/bumble/transport/file.py
+++ b/bumble/transport/file.py
@@ -28,7 +28,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_file_transport(spec):
+async def open_file_transport(spec: str) -> Transport:
     '''
     Open a File transport (typically not for a real file, but for a PTY or other unix
     virtual files).
diff --git a/bumble/transport/hci_socket.py b/bumble/transport/hci_socket.py
index 4e1ad99..df9e885 100644
--- a/bumble/transport/hci_socket.py
+++ b/bumble/transport/hci_socket.py
@@ -23,6 +23,8 @@
 import ctypes
 import collections
 
+from typing import Optional
+
 from .common import Transport, ParserSource
 
 
@@ -33,7 +35,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_hci_socket_transport(spec):
+async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
     '''
     Open an HCI Socket (only available on some platforms).
     The parameter string is either empty (to use the first/default Bluetooth adapter)
@@ -45,9 +47,9 @@
     # Create a raw HCI socket
     try:
         hci_socket = socket.socket(
-            socket.AF_BLUETOOTH,
-            socket.SOCK_RAW | socket.SOCK_NONBLOCK,
-            socket.BTPROTO_HCI,
+            socket.AF_BLUETOOTH,  # type: ignore[attr-defined]
+            socket.SOCK_RAW | socket.SOCK_NONBLOCK,  # type: ignore[attr-defined]
+            socket.BTPROTO_HCI,  # type: ignore[attr-defined]
         )
     except AttributeError as error:
         # Not supported on this platform
@@ -78,7 +80,7 @@
     bind_address = struct.pack(
         # pylint: disable=no-member
         '<HHH',
-        socket.AF_BLUETOOTH,
+        socket.AF_BLUETOOTH,  # type: ignore[attr-defined]
         adapter_index,
         HCI_CHANNEL_USER,
     )
diff --git a/bumble/transport/pty.py b/bumble/transport/pty.py
index e6e2ab5..2f46e75 100644
--- a/bumble/transport/pty.py
+++ b/bumble/transport/pty.py
@@ -23,6 +23,8 @@
 import os
 import logging
 
+from typing import Optional
+
 from .common import Transport, StreamPacketSource, StreamPacketSink
 
 # -----------------------------------------------------------------------------
@@ -32,7 +34,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_pty_transport(spec):
+async def open_pty_transport(spec: Optional[str]) -> Transport:
     '''
     Open a PTY transport.
     The parameter string may be empty, or a path name where a symbolic link
diff --git a/bumble/transport/pyusb.py b/bumble/transport/pyusb.py
index 8ad8598..5e686d1 100644
--- a/bumble/transport/pyusb.py
+++ b/bumble/transport/pyusb.py
@@ -35,7 +35,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_pyusb_transport(spec):
+async def open_pyusb_transport(spec: str) -> Transport:
     '''
     Open a USB transport. [Implementation based on PyUSB]
     The parameter string has this syntax:
diff --git a/bumble/transport/serial.py b/bumble/transport/serial.py
index c83b605..c48cdc6 100644
--- a/bumble/transport/serial.py
+++ b/bumble/transport/serial.py
@@ -28,7 +28,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_serial_transport(spec):
+async def open_serial_transport(spec: str) -> Transport:
     '''
     Open a serial port transport.
     The parameter string has this syntax:
diff --git a/bumble/transport/tcp_client.py b/bumble/transport/tcp_client.py
index 934a521..4fb268a 100644
--- a/bumble/transport/tcp_client.py
+++ b/bumble/transport/tcp_client.py
@@ -27,7 +27,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_tcp_client_transport(spec):
+async def open_tcp_client_transport(spec: str) -> Transport:
     '''
     Open a TCP client transport.
     The parameter string has this syntax:
@@ -39,7 +39,7 @@
     class TcpPacketSource(StreamPacketSource):
         def connection_lost(self, exc):
             logger.debug(f'connection lost: {exc}')
-            self.terminated.set_result(exc)
+            self.on_transport_lost()
 
     remote_host, remote_port = spec.split(':')
     tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
diff --git a/bumble/transport/tcp_server.py b/bumble/transport/tcp_server.py
index 11b0453..77d0304 100644
--- a/bumble/transport/tcp_server.py
+++ b/bumble/transport/tcp_server.py
@@ -15,6 +15,7 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
 import asyncio
 import logging
 
@@ -27,7 +28,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_tcp_server_transport(spec):
+async def open_tcp_server_transport(spec: str) -> Transport:
     '''
     Open a TCP server transport.
     The parameter string has this syntax:
@@ -42,7 +43,7 @@
         async def close(self):
             await super().close()
 
-    class TcpServerProtocol:
+    class TcpServerProtocol(asyncio.BaseProtocol):
         def __init__(self, packet_source, packet_sink):
             self.packet_source = packet_source
             self.packet_sink = packet_sink
diff --git a/bumble/transport/udp.py b/bumble/transport/udp.py
index e5e26fa..faa9bf0 100644
--- a/bumble/transport/udp.py
+++ b/bumble/transport/udp.py
@@ -27,7 +27,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_udp_transport(spec):
+async def open_udp_transport(spec: str) -> Transport:
     '''
     Open a UDP transport.
     The parameter string has this syntax:
diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py
index 68c5a6f..ccc82c1 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -60,7 +60,7 @@
             usb1.loadLibrary(libusb_dll)
 
 
-async def open_usb_transport(spec):
+async def open_usb_transport(spec: str) -> Transport:
     '''
     Open a USB transport.
     The moniker string has this syntax:
@@ -206,10 +206,11 @@
                     logger.debug('OUT transfer likely already completed')
 
     class UsbPacketSource(asyncio.Protocol, ParserSource):
-        def __init__(self, context, device, acl_in, events_in):
+        def __init__(self, context, device, metadata, acl_in, events_in):
             super().__init__()
             self.context = context
             self.device = device
+            self.metadata = metadata
             self.acl_in = acl_in
             self.events_in = events_in
             self.loop = asyncio.get_running_loop()
@@ -510,6 +511,10 @@
             f'events_in=0x{events_in:02X}, '
         )
 
+        device_metadata = {
+            'vendor_id': found.getVendorID(),
+            'product_id': found.getProductID(),
+        }
         device = found.open()
 
         # Auto-detach the kernel driver if supported
@@ -535,7 +540,7 @@
             except usb1.USBError:
                 logger.warning('failed to set configuration')
 
-        source = UsbPacketSource(context, device, acl_in, events_in)
+        source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
         sink = UsbPacketSink(device, acl_out)
         return UsbTransport(context, device, interface, setting, source, sink)
     except usb1.USBError as error:
diff --git a/bumble/transport/vhci.py b/bumble/transport/vhci.py
index ec61ab4..2b19085 100644
--- a/bumble/transport/vhci.py
+++ b/bumble/transport/vhci.py
@@ -17,6 +17,9 @@
 # -----------------------------------------------------------------------------
 import logging
 
+from typing import Optional
+
+from .common import Transport
 from .file import open_file_transport
 
 # -----------------------------------------------------------------------------
@@ -26,7 +29,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_vhci_transport(spec):
+async def open_vhci_transport(spec: Optional[str]) -> Transport:
     '''
     Open a VHCI transport (only available on some platforms).
     The parameter string is either empty (to use the default VHCI device
@@ -42,15 +45,15 @@
     # Override the source's `data_received` method so that we can
     # filter out the vendor packet that is received just after the
     # initial open
-    def vhci_data_received(data):
+    def vhci_data_received(data: bytes) -> None:
         if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
             if len(data) == 4:
                 hci_index = data[2] << 8 | data[3]
                 logger.info(f'HCI index {hci_index}')
         else:
-            transport.source.parser.feed_data(data)
+            transport.source.parser.feed_data(data)  # type: ignore
 
-    transport.source.data_received = vhci_data_received
+    transport.source.data_received = vhci_data_received  # type: ignore
 
     # Write the initial config
     transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
diff --git a/bumble/transport/ws_client.py b/bumble/transport/ws_client.py
index 85f6e88..902001e 100644
--- a/bumble/transport/ws_client.py
+++ b/bumble/transport/ws_client.py
@@ -16,9 +16,9 @@
 # Imports
 # -----------------------------------------------------------------------------
 import logging
-import websockets
+import websockets.client
 
-from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
+from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
 
 # -----------------------------------------------------------------------------
 # Logging
@@ -27,23 +27,25 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_ws_client_transport(spec):
+async def open_ws_client_transport(spec: str) -> Transport:
     '''
     Open a WebSocket client transport.
     The parameter string has this syntax:
-    <remote-host>:<remote-port>
+    <websocket-url>
 
-    Example: 127.0.0.1:9001
+    Example: ws://localhost:7681/v1/websocket/bt
     '''
 
-    remote_host, remote_port = spec.split(':')
-    uri = f'ws://{remote_host}:{remote_port}'
-    websocket = await websockets.connect(uri)
+    websocket = await websockets.client.connect(spec)
 
-    transport = PumpedTransport(
+    class WsTransport(PumpedTransport):
+        async def close(self):
+            await super().close()
+            await websocket.close()
+
+    transport = WsTransport(
         PumpedPacketSource(websocket.recv),
         PumpedPacketSink(websocket.send),
-        websocket.close,
     )
     transport.start()
     return transport
diff --git a/bumble/transport/ws_server.py b/bumble/transport/ws_server.py
index c7b7c6e..3c72c36 100644
--- a/bumble/transport/ws_server.py
+++ b/bumble/transport/ws_server.py
@@ -15,7 +15,6 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
-import asyncio
 import logging
 import websockets
 
@@ -28,7 +27,7 @@
 
 
 # -----------------------------------------------------------------------------
-async def open_ws_server_transport(spec):
+async def open_ws_server_transport(spec: str) -> Transport:
     '''
     Open a WebSocket server transport.
     The parameter string has this syntax:
@@ -43,7 +42,7 @@
         def __init__(self):
             source = ParserSource()
             sink = PumpedPacketSink(self.send_packet)
-            self.connection = asyncio.get_running_loop().create_future()
+            self.connection = None
             self.server = None
 
             super().__init__(source, sink)
@@ -63,7 +62,7 @@
                 f'new connection on {connection.local_address} '
                 f'from {connection.remote_address}'
             )
-            self.connection.set_result(connection)
+            self.connection = connection
             # pylint: disable=no-member
             try:
                 async for packet in connection:
@@ -74,12 +73,14 @@
             except websockets.WebSocketException as error:
                 logger.debug(f'exception while receiving packet: {error}')
 
-            # Wait for a new connection
-            self.connection = asyncio.get_running_loop().create_future()
+            # We're now disconnected
+            self.connection = None
 
         async def send_packet(self, packet):
-            connection = await self.connection
-            return await connection.send(packet)
+            if self.connection is None:
+                logger.debug('no connection, dropping packet')
+                return
+            return await self.connection.send(packet)
 
     local_host, local_port = spec.split(':')
     transport = WsServerTransport()
diff --git a/bumble/utils.py b/bumble/utils.py
index 8a55684..dc03725 100644
--- a/bumble/utils.py
+++ b/bumble/utils.py
@@ -15,12 +15,24 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
+from __future__ import annotations
 import asyncio
 import logging
 import traceback
 import collections
 import sys
-from typing import Awaitable, Set, TypeVar
+from typing import (
+    Awaitable,
+    Set,
+    TypeVar,
+    List,
+    Tuple,
+    Callable,
+    Any,
+    Optional,
+    Union,
+    overload,
+)
 from functools import wraps
 from pyee import EventEmitter
 
@@ -65,6 +77,102 @@
 
 
 # -----------------------------------------------------------------------------
+_Handler = TypeVar('_Handler', bound=Callable)
+
+
+class EventWatcher:
+    '''A wrapper class to control the lifecycle of event handlers better.
+
+    Usage:
+    ```
+    watcher = EventWatcher()
+
+    def on_foo():
+        ...
+    watcher.on(emitter, 'foo', on_foo)
+
+    @watcher.on(emitter, 'bar')
+    def on_bar():
+        ...
+
+    # Close all event handlers watching through this watcher
+    watcher.close()
+    ```
+
+    As context:
+    ```
+    with contextlib.closing(EventWatcher()) as context:
+        @context.on(emitter, 'foo')
+        def on_foo():
+            ...
+    # on_foo() has been removed here!
+    ```
+    '''
+
+    handlers: List[Tuple[EventEmitter, str, Callable[..., Any]]]
+
+    def __init__(self) -> None:
+        self.handlers = []
+
+    @overload
+    def on(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
+        ...
+
+    @overload
+    def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
+        ...
+
+    def on(
+        self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
+    ) -> Union[_Handler, Callable[[_Handler], _Handler]]:
+        '''Watch an event until the context is closed.
+
+        Args:
+            emitter: EventEmitter to watch
+            event: Event name
+            handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
+        '''
+
+        def wrapper(f: _Handler) -> _Handler:
+            self.handlers.append((emitter, event, f))
+            emitter.on(event, f)
+            return f
+
+        return wrapper if handler is None else wrapper(handler)
+
+    @overload
+    def once(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
+        ...
+
+    @overload
+    def once(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
+        ...
+
+    def once(
+        self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
+    ) -> Union[_Handler, Callable[[_Handler], _Handler]]:
+        '''Watch an event for once.
+
+        Args:
+            emitter: EventEmitter to watch
+            event: Event name
+            handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
+        '''
+
+        def wrapper(f: _Handler) -> _Handler:
+            self.handlers.append((emitter, event, f))
+            emitter.once(event, f)
+            return f
+
+        return wrapper if handler is None else wrapper(handler)
+
+    def close(self) -> None:
+        for emitter, event, handler in self.handlers:
+            if handler in emitter.listeners(event):
+                emitter.remove_listener(event, handler)
+
+
+# -----------------------------------------------------------------------------
 _T = TypeVar('_T')
 
 
diff --git a/bumble/vendor/__init__.py b/bumble/vendor/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bumble/vendor/__init__.py
diff --git a/bumble/vendor/android/__init__.py b/bumble/vendor/android/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bumble/vendor/android/__init__.py
diff --git a/bumble/vendor/android/hci.py b/bumble/vendor/android/hci.py
new file mode 100644
index 0000000..c411ecf
--- /dev/null
+++ b/bumble/vendor/android/hci.py
@@ -0,0 +1,318 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import struct
+
+from bumble.hci import (
+    name_or_number,
+    hci_vendor_command_op_code,
+    Address,
+    HCI_Constant,
+    HCI_Object,
+    HCI_Command,
+    HCI_Vendor_Event,
+    STATUS_SPEC,
+)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+# Android Vendor Specific Commands and Events.
+# Only a subset of the commands are implemented here currently.
+#
+# pylint: disable-next=line-too-long
+# See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#chip-capabilities-and-configuration
+HCI_LE_GET_VENDOR_CAPABILITIES_COMMAND = hci_vendor_command_op_code(0x153)
+HCI_LE_APCF_COMMAND = hci_vendor_command_op_code(0x157)
+HCI_GET_CONTROLLER_ACTIVITY_ENERGY_INFO_COMMAND = hci_vendor_command_op_code(0x159)
+HCI_A2DP_HARDWARE_OFFLOAD_COMMAND = hci_vendor_command_op_code(0x15D)
+HCI_BLUETOOTH_QUALITY_REPORT_COMMAND = hci_vendor_command_op_code(0x15E)
+HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci_vendor_command_op_code(0x15F)
+
+HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
+
+HCI_Command.register_commands(globals())
+HCI_Vendor_Event.register_subevents(globals())
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('max_advt_instances', 1),
+        ('offloaded_resolution_of_private_address', 1),
+        ('total_scan_results_storage', 2),
+        ('max_irk_list_sz', 1),
+        ('filtering_support', 1),
+        ('max_filter', 1),
+        ('activity_energy_info_support', 1),
+        ('version_supported', 2),
+        ('total_num_of_advt_tracked', 2),
+        ('extended_scan_support', 1),
+        ('debug_logging_supported', 1),
+        ('le_address_generation_offloading_support', 1),
+        ('a2dp_source_offload_capability_mask', 4),
+        ('bluetooth_quality_report_support', 1),
+        ('dynamic_audio_buffer_support', 4),
+    ]
+)
+class HCI_LE_Get_Vendor_Capabilities_Command(HCI_Command):
+    # pylint: disable=line-too-long
+    '''
+    See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
+    '''
+
+    @classmethod
+    def parse_return_parameters(cls, parameters):
+        # There are many versions of this data structure, so we need to parse until
+        # there are no more bytes to parse, and leave un-signal parameters set to
+        # None (older versions)
+        nones = {field: None for field, _ in cls.return_parameters_fields}
+        return_parameters = HCI_Object(cls.return_parameters_fields, **nones)
+
+        try:
+            offset = 0
+            for field in cls.return_parameters_fields:
+                field_name, field_type = field
+                field_value, field_size = HCI_Object.parse_field(
+                    parameters, offset, field_type
+                )
+                setattr(return_parameters, field_name, field_value)
+                offset += field_size
+        except struct.error:
+            pass
+
+        return return_parameters
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        (
+            'opcode',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
+            },
+        ),
+        ('payload', '*'),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        (
+            'opcode',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
+            },
+        ),
+        ('payload', '*'),
+    ],
+)
+class HCI_LE_APCF_Command(HCI_Command):
+    # pylint: disable=line-too-long
+    '''
+    See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_apcf_command
+
+    NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
+    implementation. A future enhancement may define subcommand-specific data structures.
+    '''
+
+    # APCF Subcommands
+    # TODO: use the OpenIntEnum class (when upcoming PR is merged)
+    APCF_ENABLE = 0x00
+    APCF_SET_FILTERING_PARAMETERS = 0x01
+    APCF_BROADCASTER_ADDRESS = 0x02
+    APCF_SERVICE_UUID = 0x03
+    APCF_SERVICE_SOLICITATION_UUID = 0x04
+    APCF_LOCAL_NAME = 0x05
+    APCF_MANUFACTURER_DATA = 0x06
+    APCF_SERVICE_DATA = 0x07
+    APCF_TRANSPORT_DISCOVERY_SERVICE = 0x08
+    APCF_AD_TYPE_FILTER = 0x09
+    APCF_READ_EXTENDED_FEATURES = 0xFF
+
+    OPCODE_NAMES = {
+        APCF_ENABLE: 'APCF_ENABLE',
+        APCF_SET_FILTERING_PARAMETERS: 'APCF_SET_FILTERING_PARAMETERS',
+        APCF_BROADCASTER_ADDRESS: 'APCF_BROADCASTER_ADDRESS',
+        APCF_SERVICE_UUID: 'APCF_SERVICE_UUID',
+        APCF_SERVICE_SOLICITATION_UUID: 'APCF_SERVICE_SOLICITATION_UUID',
+        APCF_LOCAL_NAME: 'APCF_LOCAL_NAME',
+        APCF_MANUFACTURER_DATA: 'APCF_MANUFACTURER_DATA',
+        APCF_SERVICE_DATA: 'APCF_SERVICE_DATA',
+        APCF_TRANSPORT_DISCOVERY_SERVICE: 'APCF_TRANSPORT_DISCOVERY_SERVICE',
+        APCF_AD_TYPE_FILTER: 'APCF_AD_TYPE_FILTER',
+        APCF_READ_EXTENDED_FEATURES: 'APCF_READ_EXTENDED_FEATURES',
+    }
+
+    @classmethod
+    def opcode_name(cls, opcode):
+        return name_or_number(cls.OPCODE_NAMES, opcode)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('total_tx_time_ms', 4),
+        ('total_rx_time_ms', 4),
+        ('total_idle_time_ms', 4),
+        ('total_energy_used', 4),
+    ],
+)
+class HCI_Get_Controller_Activity_Energy_Info_Command(HCI_Command):
+    # pylint: disable=line-too-long
+    '''
+    See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_get_controller_activity_energy_info
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        (
+            'opcode',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
+            },
+        ),
+        ('payload', '*'),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        (
+            'opcode',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
+            },
+        ),
+        ('payload', '*'),
+    ],
+)
+class HCI_A2DP_Hardware_Offload_Command(HCI_Command):
+    # pylint: disable=line-too-long
+    '''
+    See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#a2dp-hardware-offload-support
+
+    NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
+    implementation. A future enhancement may define subcommand-specific data structures.
+    '''
+
+    # A2DP Hardware Offload Subcommands
+    # TODO: use the OpenIntEnum class (when upcoming PR is merged)
+    START_A2DP_OFFLOAD = 0x01
+    STOP_A2DP_OFFLOAD = 0x02
+
+    OPCODE_NAMES = {
+        START_A2DP_OFFLOAD: 'START_A2DP_OFFLOAD',
+        STOP_A2DP_OFFLOAD: 'STOP_A2DP_OFFLOAD',
+    }
+
+    @classmethod
+    def opcode_name(cls, opcode):
+        return name_or_number(cls.OPCODE_NAMES, opcode)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[
+        (
+            'opcode',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
+            },
+        ),
+        ('payload', '*'),
+    ],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        (
+            'opcode',
+            {
+                'size': 1,
+                'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
+            },
+        ),
+        ('payload', '*'),
+    ],
+)
+class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
+    # pylint: disable=line-too-long
+    '''
+    See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#dynamic-audio-buffer-command
+
+    NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
+    implementation. A future enhancement may define subcommand-specific data structures.
+    '''
+
+    # Dynamic Audio Buffer Subcommands
+    # TODO: use the OpenIntEnum class (when upcoming PR is merged)
+    GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
+
+    OPCODE_NAMES = {
+        GET_AUDIO_BUFFER_TIME_CAPABILITY: 'GET_AUDIO_BUFFER_TIME_CAPABILITY',
+    }
+
+    @classmethod
+    def opcode_name(cls, opcode):
+        return name_or_number(cls.OPCODE_NAMES, opcode)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Vendor_Event.event(
+    fields=[
+        ('quality_report_id', 1),
+        ('packet_types', 1),
+        ('connection_handle', 2),
+        ('connection_role', {'size': 1, 'mapper': HCI_Constant.role_name}),
+        ('tx_power_level', -1),
+        ('rssi', -1),
+        ('snr', 1),
+        ('unused_afh_channel_count', 1),
+        ('afh_select_unideal_channel_count', 1),
+        ('lsto', 2),
+        ('connection_piconet_clock', 4),
+        ('retransmission_count', 4),
+        ('no_rx_count', 4),
+        ('nak_count', 4),
+        ('last_tx_ack_timestamp', 4),
+        ('flow_off_count', 4),
+        ('last_flow_on_timestamp', 4),
+        ('buffer_overflow_bytes', 4),
+        ('buffer_underflow_bytes', 4),
+        ('bdaddr', Address.parse_address),
+        ('cal_failed_item_count', 1),
+        ('tx_total_packets', 4),
+        ('tx_unacked_packets', 4),
+        ('tx_flushed_packets', 4),
+        ('tx_last_subevent_packets', 4),
+        ('crc_error_packets', 4),
+        ('rx_duplicate_packets', 4),
+        ('vendor_specific_parameters', '*'),
+    ]
+)
+class HCI_Bluetooth_Quality_Report_Event(HCI_Vendor_Event):
+    # pylint: disable=line-too-long
+    '''
+    See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
+    '''
diff --git a/bumble/vendor/zephyr/__init__.py b/bumble/vendor/zephyr/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bumble/vendor/zephyr/__init__.py
diff --git a/bumble/vendor/zephyr/hci.py b/bumble/vendor/zephyr/hci.py
new file mode 100644
index 0000000..9ffb3c3
--- /dev/null
+++ b/bumble/vendor/zephyr/hci.py
@@ -0,0 +1,88 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from bumble.hci import (
+    hci_vendor_command_op_code,
+    HCI_Command,
+    STATUS_SPEC,
+)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+# Zephyr RTOS Vendor Specific Commands and Events.
+# Only a subset of the commands are implemented here currently.
+#
+# pylint: disable-next=line-too-long
+# See https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
+HCI_WRITE_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000E)
+HCI_READ_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000F)
+
+HCI_Command.register_commands(globals())
+
+
+# -----------------------------------------------------------------------------
+class TX_Power_Level_Command:
+    '''
+    Base class for read and write TX power level HCI commands
+    '''
+
+    TX_POWER_HANDLE_TYPE_ADV = 0x00
+    TX_POWER_HANDLE_TYPE_SCAN = 0x01
+    TX_POWER_HANDLE_TYPE_CONN = 0x02
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[('handle_type', 1), ('connection_handle', 2), ('tx_power_level', -1)],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('handle_type', 1),
+        ('connection_handle', 2),
+        ('selected_tx_power_level', -1),
+    ],
+)
+class HCI_Write_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
+    '''
+    Write TX power level. See BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL in
+    https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
+
+    Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
+    TX_POWER_HANDLE_TYPE_SCAN should be zero.
+    '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+    fields=[('handle_type', 1), ('connection_handle', 2)],
+    return_parameters_fields=[
+        ('status', STATUS_SPEC),
+        ('handle_type', 1),
+        ('connection_handle', 2),
+        ('tx_power_level', -1),
+    ],
+)
+class HCI_Read_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
+    '''
+    Read TX power level. See BT_HCI_OP_VS_READ_TX_POWER_LEVEL in
+    https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
+
+    Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
+    TX_POWER_HANDLE_TYPE_SCAN should be zero.
+    '''
diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml
index 0ddc982..82a6f41 100644
--- a/docs/mkdocs/mkdocs.yml
+++ b/docs/mkdocs/mkdocs.yml
@@ -36,6 +36,9 @@
     - HCI Socket: transports/hci_socket.md
     - Android Emulator: transports/android_emulator.md
     - File: transports/file.md
+  - Drivers:
+    - Overview: drivers/index.md
+    - Realtek: drivers/realtek.md
   - API:
     - Guide: api/guide.md
     - Examples: api/examples.md
@@ -44,6 +47,7 @@
     - Overview: apps_and_tools/index.md
     - Console: apps_and_tools/console.md
     - Bench: apps_and_tools/bench.md
+    - Speaker: apps_and_tools/speaker.md
     - HCI Bridge: apps_and_tools/hci_bridge.md
     - Golden Gate Bridge: apps_and_tools/gg_bridge.md
     - Show: apps_and_tools/show.md
@@ -60,6 +64,7 @@
     - Linux: platforms/linux.md
     - Windows: platforms/windows.md
     - Android: platforms/android.md
+    - Zephyr: platforms/zephyr.md
   - Examples:
     - Overview: examples/index.md
 
diff --git a/docs/mkdocs/src/apps_and_tools/index.md b/docs/mkdocs/src/apps_and_tools/index.md
index fe7af56..0c2b4d5 100644
--- a/docs/mkdocs/src/apps_and_tools/index.md
+++ b/docs/mkdocs/src/apps_and_tools/index.md
@@ -11,4 +11,5 @@
   * [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
   * [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
   * [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
+  * [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI.
   * [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.
diff --git a/docs/mkdocs/src/apps_and_tools/speaker.md b/docs/mkdocs/src/apps_and_tools/speaker.md
new file mode 100644
index 0000000..5569b9d
--- /dev/null
+++ b/docs/mkdocs/src/apps_and_tools/speaker.md
@@ -0,0 +1,86 @@
+SPEAKER APP
+===========
+
+![logo](../images/speaker_screenshot.png){ width=400 height=320 }
+
+The Speaker app is virtual Bluetooth speaker (A2DP sink).
+The app runs as a command-line executable, but also offers an optional simple
+web-browser-based user interface.
+
+# General Usage
+You can invoke the app either as `bumble-speaker` when installed as command
+from `pip`, or `python3 apps/speaker/speaker.py` when running from a source
+distribution.
+
+```
+Usage: speaker.py [OPTIONS] TRANSPORT
+
+  Run the speaker.
+
+Options:
+  --codec [sbc|aac]          [default: aac]
+  --discover                 Discover remote endpoints once connected
+  --output NAME              Send audio to this named output (may be used more
+                             than once for multiple outputs)
+  --ui-port HTTP_PORT        HTTP port for the UI server  [default: 7654]
+  --connect ADDRESS_OR_NAME  Address or name to connect to
+  --device-config FILENAME   Device configuration file
+  --help                     Show this message and exit.
+```
+
+# Connection
+By default, the virtual speaker will wait for another device (like a phone or
+computer) to connect to it (and possibly pair). Alternatively, the speaker can
+be told to initiate a connection to a remote device, using the `--connect` 
+option.
+
+# Outputs
+The speaker can have one or more outputs. By default, the only output is a text
+display on the console, as well as a browser-based user interface if connected.
+In addition, a file output can be used, in which case the received audio data is
+saved to a specified file.
+Finally, if the host computer on which your are running the application has `ffplay`
+as an available command line executable, the `@ffplay` output can be selected, in 
+which case the received audio will be played on the computer's builtin speakers via
+a pipe to `ffplay`. (see the [ffplay documentation](https://www.ffmpeg.org/ffplay.html)
+for details)
+
+# Web User Interface
+When the speaker app starts, it prints out on the console the local URL at which you
+may point a browser (Chrome recommended for full functionality). The console line
+specifying the local UI URL will look like:
+```
+UI HTTP server at http://127.0.0.1:7654
+```
+
+By default, the web UI will show the status of the connection, as well as a realtime
+graph of the received audio bandwidth.
+In order to also hear the received audio, you need to click the `Audio on` button
+(this is due to the fact that most browsers will require some user interface with the
+page before granting access to the audio output APIs).
+
+# Examples
+
+In the following examples, we use a single USB Bluetooth controllers `usb:0`. Other 
+transports can be used of course.
+
+!!! example "Start the speaker and wait for a connection"
+    ```
+    $ bumble-speaker usb:0
+    ```
+
+!!! example "Start the speaker and save the AAC audio to a file named `audio.aac`."
+    ```
+    $ bumble-speaker --output audio.aac usb:0
+    ```
+
+!!! example "Start the speaker and save the SBC audio to a file named `audio.sbc`."
+    ```
+    $ bumble-speaker --codec sbc --output audio.sbc usb:0
+    ```
+
+!!! example "Start the speaker and connect it to a phone at address `B8:7B:C5:05:57:ED`."
+    ```
+    $ bumble-speaker --connect B8:7B:C5:05:57:ED usb:0
+    ```
+
diff --git a/docs/mkdocs/src/downloads/zephyr/hci_usb.zip b/docs/mkdocs/src/downloads/zephyr/hci_usb.zip
new file mode 100644
index 0000000..5e1dfc9
--- /dev/null
+++ b/docs/mkdocs/src/downloads/zephyr/hci_usb.zip
Binary files differ
diff --git a/docs/mkdocs/src/drivers/index.md b/docs/mkdocs/src/drivers/index.md
new file mode 100644
index 0000000..a904e00
--- /dev/null
+++ b/docs/mkdocs/src/drivers/index.md
@@ -0,0 +1,10 @@
+DRIVERS
+=======
+
+Some Bluetooth controllers require a driver to function properly.
+This may include, for instance, loading a Firmware image or patch,
+loading a configuration.
+
+Drivers included in the module are:
+
+  * [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.
\ No newline at end of file
diff --git a/docs/mkdocs/src/drivers/realtek.md b/docs/mkdocs/src/drivers/realtek.md
new file mode 100644
index 0000000..acbce49
--- /dev/null
+++ b/docs/mkdocs/src/drivers/realtek.md
@@ -0,0 +1,62 @@
+REALTEK DRIVER
+==============
+
+This driver supports loading firmware images and optional config data to 
+USB dongles with a Realtek chipset.
+A number of USB dongles are supported, but likely not all.
+When using a USB dongle, the USB product ID and manufacturer ID are used
+to find whether a matching set of firmware image and config data
+is needed for that specific model. If a match exists, the driver will try
+load the firmware image and, if needed, config data.
+The driver will look for those files by name, in order, in:
+
+  * The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
+    if set.
+  * The directory `<package-dir>/drivers/rtk_fw` where `<package-dir>` is the directory
+    where the `bumble` package is installed.
+  * The current directory.
+
+
+Obtaining Firmware Images and Config Data
+-----------------------------------------
+
+Firmware images and config data may be obtained from a variety of online
+sources.
+To facilitate finding a downloading the, the utility program `bumble-rtk-fw-download`
+may be used.
+
+```
+Usage: bumble-rtk-fw-download [OPTIONS]
+
+  Download RTK firmware images and configs.
+
+Options:
+  --output-dir TEXT               Output directory where the files will be
+                                  saved  [default: .]
+  --source [linux-kernel|realtek-opensource|linux-from-scratch]
+                                  [default: linux-kernel]
+  --single TEXT                   Only download a single image set, by its
+                                  base name
+  --force                         Overwrite files if they already exist
+  --parse                         Parse the FW image after saving
+  --help                          Show this message and exit.
+```
+
+Utility
+-------
+
+The `bumble-rtk-util` utility may be used to interact with a Realtek USB dongle
+and/or firmware images.
+
+```
+Usage: bumble-rtk-util [OPTIONS] COMMAND [ARGS]...
+
+Options:
+  --help  Show this message and exit.
+
+Commands:
+  drop   Drop a firmware image from the USB dongle.
+  info   Get the firmware info from a USB dongle.
+  load   Load a firmware image into the USB dongle.
+  parse  Parse a firmware image.
+```
\ No newline at end of file
diff --git a/docs/mkdocs/src/images/speaker_screenshot.png b/docs/mkdocs/src/images/speaker_screenshot.png
new file mode 100644
index 0000000..fd34880
--- /dev/null
+++ b/docs/mkdocs/src/images/speaker_screenshot.png
Binary files differ
diff --git a/docs/mkdocs/src/platforms/index.md b/docs/mkdocs/src/platforms/index.md
index a93e947..858785f 100644
--- a/docs/mkdocs/src/platforms/index.md
+++ b/docs/mkdocs/src/platforms/index.md
@@ -9,3 +9,4 @@
   * :material-linux: Linux - see the [Linux platform page](linux.md)
   * :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
   * :material-android: Android - see the [Android platform page](android.md)
+  * :material-memory: Zephyr - see the [Zephyr platform page](zephyr.md)
diff --git a/docs/mkdocs/src/platforms/zephyr.md b/docs/mkdocs/src/platforms/zephyr.md
new file mode 100644
index 0000000..0e68247
--- /dev/null
+++ b/docs/mkdocs/src/platforms/zephyr.md
@@ -0,0 +1,51 @@
+:material-memory: ZEPHYR PLATFORM
+=================================
+
+Set TX Power on nRF52840
+------------------------
+
+The Nordic nRF52840 supports Zephyr's vendor specific HCI command for setting TX
+power during advertising, connection, or scanning. With the example [HCI
+USB](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_usb/README.html)
+application, an [nRF52840
+dongle](https://www.nordicsemi.com/Products/Development-
+hardware/nRF52840-Dongle) can be used as a Bumble controller.
+
+To add dynamic TX power support to the HCI USB application, add the following to
+`zephyr/samples/bluetooth/hci_usb/prj.conf` and build.
+
+```
+CONFIG_BT_CTLR_ADVANCED_FEATURES=y
+CONFIG_BT_CTLR_CONN_RSSI=y
+CONFIG_BT_CTLR_TX_PWR_DYNAMIC_CONTROL=y
+```
+
+Alternatively, a prebuilt firmware application can be downloaded here:
+[hci_usb.zip](../downloads/zephyr/hci_usb.zip).
+
+Put the nRF52840 dongle into bootloader mode by pressing the RESET button. The
+LED should pulse red. Load the firmware application with the `nrfutil` tool:
+
+```
+nrfutil dfu usb-serial -pkg hci_usb.zip -p /dev/ttyACM0
+```
+
+The vendor specific HCI commands to read and write TX power are defined in
+`bumble/vendor/zephyr/hci.py` and may be used as such:
+
+```python
+from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
+
+# set advertising power to -4 dB
+response = await host.send_command(
+    HCI_Write_Tx_Power_Level_Command(
+        handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
+        connection_handle=0,
+        tx_power_level=-4,
+    )
+)
+
+if response.return_parameters.status == HCI_SUCCESS:
+    print(f"TX power set to {response.return_parameters.selected_tx_power_level}")
+
+```
diff --git a/docs/mkdocs/src/transports/ws_client.md b/docs/mkdocs/src/transports/ws_client.md
index ad9c245..6d9cacb 100644
--- a/docs/mkdocs/src/transports/ws_client.md
+++ b/docs/mkdocs/src/transports/ws_client.md
@@ -1,11 +1,11 @@
-UDP TRANSPORT
-=============
+WEBSOCKET CLIENT TRANSPORT
+==========================
 
-The UDP transport is a UDP socket, receiving packets on a specified port number, and sending packets to a specified host and port number.
+The WebSocket Client transport is WebSocket connection to a WebSocket server over which HCI packets
+are sent and received.
 
 ## Moniker
-The moniker syntax for a UDP transport is: `udp:<local-host>:<local-port>,<remote-host>:<remote-port>`.
+The moniker syntax for a WebSocket Client transport is: `ws-client:<ws-url>`
 
 !!! example
-    `udp:0.0.0.0:9000,127.0.0.1:9001`
-    UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
+    `ws-client:ws://localhost:1234/some/path`
diff --git a/docs/mkdocs/src/transports/ws_server.md b/docs/mkdocs/src/transports/ws_server.md
index ad9c245..8986d3f 100644
--- a/docs/mkdocs/src/transports/ws_server.md
+++ b/docs/mkdocs/src/transports/ws_server.md
@@ -1,11 +1,13 @@
-UDP TRANSPORT
-=============
+WEBSOCKET SERVER TRANSPORT
+==========================
 
-The UDP transport is a UDP socket, receiving packets on a specified port number, and sending packets to a specified host and port number.
+The WebSocket Server transport is WebSocket server that accepts connections from a WebSocket
+client. HCI packets are sent and received over the connection.
 
 ## Moniker
-The moniker syntax for a UDP transport is: `udp:<local-host>:<local-port>,<remote-host>:<remote-port>`.
+The moniker syntax for a WebSocket Server transport is: `ws-server:<host>:<port>`,
+where `<host>` may be the address of a local network interface, or `_`to accept connections on all local network interfaces. `<port>` is the TCP port number on which to accept connections.
+
 
 !!! example
-    `udp:0.0.0.0:9000,127.0.0.1:9001`
-    UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
+    `ws-server:_:9001`
diff --git a/environment.yml b/environment.yml
index 17b040c..2e927cb 100644
--- a/environment.yml
+++ b/environment.yml
@@ -3,7 +3,7 @@
   - defaults
   - conda-forge
 dependencies:
-  - pip=20
+  - pip=23
   - python=3.8
   - pip:
     - --editable .[development,documentation,test]
diff --git a/examples/run_classic_connect.py b/examples/run_classic_connect.py
index bb46bf7..3ae6ed8 100644
--- a/examples/run_classic_connect.py
+++ b/examples/run_classic_connect.py
@@ -23,7 +23,7 @@
 
 from bumble.device import Device
 from bumble.transport import open_transport_or_link
-from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID
+from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID, CommandTimeoutError
 from bumble.sdp import (
     Client as SDP_Client,
     SDP_PUBLIC_BROWSE_ROOT,
@@ -48,62 +48,70 @@
         # Create a device
         device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
         device.classic_enabled = True
+        device.le_enabled = False
         await device.power_on()
 
-    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}!')
-
-        # Connect to the SDP Server
-        sdp_client = SDP_Client(device)
-        await sdp_client.connect(connection)
-
-        # List all services in the root browse group
-        service_record_handles = await sdp_client.search_services(
-            [SDP_PUBLIC_BROWSE_ROOT]
-        )
-        print(color('\n==================================', 'blue'))
-        print(color('SERVICES:', 'yellow'), service_record_handles)
-
-        # For each service in the root browse group, get all its attributes
-        for service_record_handle in service_record_handles:
-            attributes = await sdp_client.get_attributes(
-                service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
-            )
-            print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow'))
-            for attribute in attributes:
-                print('  ', attribute.to_string(with_colors=True))
-
-        # Search for services with an L2CAP service attribute
-        search_result = await sdp_client.search_attributes(
-            [BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE]
-        )
-        print(color('\n==================================', 'blue'))
-        print(color('SEARCH RESULTS:', 'yellow'))
-        for attribute_list in search_result:
-            print(color('SERVICE:', 'green'))
-            print(
-                '  '
-                + '\n  '.join(
-                    [
-                        attribute.to_string(with_colors=True)
-                        for attribute in attribute_list
-                    ]
+        async def connect(target_address):
+            print(f'=== Connecting to {target_address}...')
+            try:
+                connection = await device.connect(
+                    target_address, transport=BT_BR_EDR_TRANSPORT
                 )
+            except CommandTimeoutError:
+                print('!!! Connection timed out')
+                return
+            print(f'=== Connected to {connection.peer_address}!')
+
+            # Connect to the SDP Server
+            sdp_client = SDP_Client(device)
+            await sdp_client.connect(connection)
+
+            # List all services in the root browse group
+            service_record_handles = await sdp_client.search_services(
+                [SDP_PUBLIC_BROWSE_ROOT]
             )
+            print(color('\n==================================', 'blue'))
+            print(color('SERVICES:', 'yellow'), service_record_handles)
 
-        await sdp_client.disconnect()
-        await hci_source.wait_for_termination()
+            # For each service in the root browse group, get all its attributes
+            for service_record_handle in service_record_handles:
+                attributes = await sdp_client.get_attributes(
+                    service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
+                )
+                print(
+                    color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow')
+                )
+                for attribute in attributes:
+                    print('  ', attribute.to_string(with_colors=True))
 
-    # Connect to a peer
-    target_addresses = sys.argv[3:]
-    await asyncio.wait(
-        [
-            asyncio.create_task(connect(target_address))
-            for target_address in target_addresses
-        ]
-    )
+            # Search for services with an L2CAP service attribute
+            search_result = await sdp_client.search_attributes(
+                [BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE]
+            )
+            print(color('\n==================================', 'blue'))
+            print(color('SEARCH RESULTS:', 'yellow'))
+            for attribute_list in search_result:
+                print(color('SERVICE:', 'green'))
+                print(
+                    '  '
+                    + '\n  '.join(
+                        [
+                            attribute.to_string(with_colors=True)
+                            for attribute in attribute_list
+                        ]
+                    )
+                )
+
+            await sdp_client.disconnect()
+
+        # Connect to a peer
+        target_addresses = sys.argv[3:]
+        await asyncio.wait(
+            [
+                asyncio.create_task(connect(target_address))
+                for target_address in target_addresses
+            ]
+        )
 
 
 # -----------------------------------------------------------------------------
diff --git a/examples/run_hfp_gateway.py b/examples/run_hfp_gateway.py
index 63a2a7c..13a2ed9 100644
--- a/examples/run_hfp_gateway.py
+++ b/examples/run_hfp_gateway.py
@@ -30,7 +30,7 @@
     BT_RFCOMM_PROTOCOL_ID,
     BT_BR_EDR_TRANSPORT,
 )
-from bumble.rfcomm import Client
+from bumble import rfcomm, hfp
 from bumble.sdp import (
     Client as SDP_Client,
     DataElement,
@@ -39,7 +39,9 @@
     SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
     SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
 )
-from bumble.hfp import HfpProtocol
+
+
+logger = logging.getLogger(__name__)
 
 
 # -----------------------------------------------------------------------------
@@ -181,7 +183,7 @@
 
         # Create a client and start it
         print('@@@ Starting to RFCOMM client...')
-        rfcomm_client = Client(device, connection)
+        rfcomm_client = rfcomm.Client(device, connection)
         rfcomm_mux = await rfcomm_client.start()
         print('@@@ Started')
 
@@ -196,7 +198,7 @@
             return
 
         # Protocol loop (just for testing at this point)
-        protocol = HfpProtocol(session)
+        protocol = hfp.HfpProtocol(session)
         while True:
             line = await protocol.next_line()
 
diff --git a/examples/run_hfp_handsfree.py b/examples/run_hfp_handsfree.py
index cef29c0..5f747fc 100644
--- a/examples/run_hfp_handsfree.py
+++ b/examples/run_hfp_handsfree.py
@@ -21,82 +21,22 @@
 import logging
 import json
 import websockets
-
+from typing import Optional
 
 from bumble.device import Device
 from bumble.transport import open_transport_or_link
 from bumble.rfcomm import Server as RfcommServer
-from bumble.sdp import (
-    DataElement,
-    ServiceAttribute,
-    SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
-    SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
-    SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-    SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-)
-from bumble.core import (
-    BT_GENERIC_AUDIO_SERVICE,
-    BT_HANDSFREE_SERVICE,
-    BT_L2CAP_PROTOCOL_ID,
-    BT_RFCOMM_PROTOCOL_ID,
-)
-from bumble.hfp import HfpProtocol
-
-
-# -----------------------------------------------------------------------------
-def make_sdp_records(rfcomm_channel):
-    return {
-        0x00010001: [
-            ServiceAttribute(
-                SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
-                DataElement.unsigned_integer_32(0x00010001),
-            ),
-            ServiceAttribute(
-                SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
-                DataElement.sequence(
-                    [
-                        DataElement.uuid(BT_HANDSFREE_SERVICE),
-                        DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
-                    ]
-                ),
-            ),
-            ServiceAttribute(
-                SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-                DataElement.sequence(
-                    [
-                        DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
-                        DataElement.sequence(
-                            [
-                                DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
-                                DataElement.unsigned_integer_8(rfcomm_channel),
-                            ]
-                        ),
-                    ]
-                ),
-            ),
-            ServiceAttribute(
-                SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-                DataElement.sequence(
-                    [
-                        DataElement.sequence(
-                            [
-                                DataElement.uuid(BT_HANDSFREE_SERVICE),
-                                DataElement.unsigned_integer_16(0x0105),
-                            ]
-                        )
-                    ]
-                ),
-            ),
-        ]
-    }
+from bumble import hfp
+from bumble.hfp import HfProtocol
 
 
 # -----------------------------------------------------------------------------
 class UiServer:
-    protocol = None
+    protocol: Optional[HfProtocol] = None
 
     async def start(self):
-        # Start a Websocket server to receive events from a web page
+        """Start a Websocket server to receive events from a web page."""
+
         async def serve(websocket, _path):
             while True:
                 try:
@@ -107,7 +47,7 @@
                     message_type = parsed['type']
                     if message_type == 'at_command':
                         if self.protocol is not None:
-                            self.protocol.send_command_line(parsed['command'])
+                            await self.protocol.execute_command(parsed['command'])
 
                 except websockets.exceptions.ConnectionClosedOK:
                     pass
@@ -117,19 +57,11 @@
 
 
 # -----------------------------------------------------------------------------
-async def protocol_loop(protocol):
-    await protocol.initialize_service()
-
-    while True:
-        await (protocol.next_line())
-
-
-# -----------------------------------------------------------------------------
-def on_dlc(dlc):
+def on_dlc(dlc, configuration: hfp.Configuration):
     print('*** DLC connected', dlc)
-    protocol = HfpProtocol(dlc)
+    protocol = HfProtocol(dlc, configuration)
     UiServer.protocol = protocol
-    asyncio.create_task(protocol_loop(protocol))
+    asyncio.create_task(protocol.run())
 
 
 # -----------------------------------------------------------------------------
@@ -143,6 +75,27 @@
     async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
         print('<<< connected')
 
+        # Hands-Free profile configuration.
+        # TODO: load configuration from file.
+        configuration = hfp.Configuration(
+            supported_hf_features=[
+                hfp.HfFeature.THREE_WAY_CALLING,
+                hfp.HfFeature.REMOTE_VOLUME_CONTROL,
+                hfp.HfFeature.ENHANCED_CALL_STATUS,
+                hfp.HfFeature.ENHANCED_CALL_CONTROL,
+                hfp.HfFeature.CODEC_NEGOTIATION,
+                hfp.HfFeature.HF_INDICATORS,
+                hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
+            ],
+            supported_hf_indicators=[
+                hfp.HfIndicator.BATTERY_LEVEL,
+            ],
+            supported_audio_codecs=[
+                hfp.AudioCodec.CVSD,
+                hfp.AudioCodec.MSBC,
+            ],
+        )
+
         # Create a device
         device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
         device.classic_enabled = True
@@ -151,11 +104,13 @@
         rfcomm_server = RfcommServer(device)
 
         # Listen for incoming DLC connections
-        channel_number = rfcomm_server.listen(on_dlc)
+        channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
         print(f'### Listening for connection on channel {channel_number}')
 
         # Advertise the HFP RFComm channel in the SDP
-        device.sdp_service_records = make_sdp_records(channel_number)
+        device.sdp_service_records = {
+            0x00010001: hfp.sdp_records(0x00010001, channel_number, configuration)
+        }
 
         # Let's go!
         await device.power_on()
diff --git a/examples/run_rfcomm_server.py b/examples/run_rfcomm_server.py
index 71feca9..41915a4 100644
--- a/examples/run_rfcomm_server.py
+++ b/examples/run_rfcomm_server.py
@@ -20,83 +20,109 @@
 import os
 import logging
 
+from bumble.core import UUID
 from bumble.device import Device
 from bumble.transport import open_transport_or_link
-from bumble.core import BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID, UUID
 from bumble.rfcomm import Server
-from bumble.sdp import (
-    DataElement,
-    ServiceAttribute,
-    SDP_PUBLIC_BROWSE_ROOT,
-    SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
-    SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
-    SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
-    SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-)
+from bumble.utils import AsyncRunner
+from bumble.rfcomm import make_service_sdp_records
 
 
 # -----------------------------------------------------------------------------
-def sdp_records(channel):
+def sdp_records(channel, uuid):
+    service_record_handle = 0x00010001
     return {
-        0x00010001: [
-            ServiceAttribute(
-                SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
-                DataElement.unsigned_integer_32(0x00010001),
-            ),
-            ServiceAttribute(
-                SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
-                DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
-            ),
-            ServiceAttribute(
-                SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
-                DataElement.sequence(
-                    [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
-                ),
-            ),
-            ServiceAttribute(
-                SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-                DataElement.sequence(
-                    [
-                        DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
-                        DataElement.sequence(
-                            [
-                                DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
-                                DataElement.unsigned_integer_8(channel),
-                            ]
-                        ),
-                    ]
-                ),
-            ),
-        ]
+        service_record_handle: make_service_sdp_records(
+            service_record_handle, channel, UUID(uuid)
+        )
     }
 
 
 # -----------------------------------------------------------------------------
-def on_dlc(dlc):
-    print('*** DLC connected', dlc)
-    dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
+def on_rfcomm_session(rfcomm_session, tcp_server):
+    print('*** RFComm session connected', rfcomm_session)
+    tcp_server.attach_session(rfcomm_session)
 
 
 # -----------------------------------------------------------------------------
-def on_rfcomm_data_received(dlc, data):
-    print(f'<<< Data received: {data.hex()}')
-    try:
-        message = data.decode('utf-8')
-        print(f'<<< Message = {message}')
-    except Exception:
-        pass
+class TcpServerProtocol(asyncio.Protocol):
+    def __init__(self, server):
+        self.server = server
 
-    # Echo everything back
-    dlc.write(data)
+    def connection_made(self, transport):
+        peer_name = transport.get_extra_info('peer_name')
+        print(f'<<< TCP Server: connection from {peer_name}')
+        if self.server:
+            self.server.tcp_transport = transport
+        else:
+            transport.close()
+
+    def connection_lost(self, exc):
+        print('<<< TCP Server: connection lost')
+        if self.server:
+            self.server.tcp_transport = None
+
+    def data_received(self, data):
+        print(f'<<< TCP Server: data received: {len(data)} bytes - {data.hex()}')
+        if self.server:
+            self.server.tcp_data_received(data)
+
+
+# -----------------------------------------------------------------------------
+class TcpServer:
+    def __init__(self, port):
+        self.rfcomm_session = None
+        self.tcp_transport = None
+        AsyncRunner.spawn(self.run(port))
+
+    def attach_session(self, rfcomm_session):
+        if self.rfcomm_session:
+            self.rfcomm_session.sink = None
+
+        self.rfcomm_session = rfcomm_session
+        rfcomm_session.sink = self.rfcomm_data_received
+
+    def rfcomm_data_received(self, data):
+        print(f'<<< RFCOMM Data: {data.hex()}')
+        if self.tcp_transport:
+            self.tcp_transport.write(data)
+        else:
+            print('!!! no TCP connection, dropping data')
+
+    def tcp_data_received(self, data):
+        if self.rfcomm_session:
+            self.rfcomm_session.write(data)
+        else:
+            print('!!! no RFComm session, dropping data')
+
+    async def run(self, port):
+        print(f'$$$ Starting TCP server on port {port}')
+
+        server = await asyncio.get_running_loop().create_server(
+            lambda: TcpServerProtocol(self), '127.0.0.1', port
+        )
+
+        async with server:
+            await server.serve_forever()
 
 
 # -----------------------------------------------------------------------------
 async def main():
-    if len(sys.argv) < 3:
-        print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
-        print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
+    if len(sys.argv) < 4:
+        print(
+            'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
+            '<tcp-port> [<uuid>]'
+        )
+        print('example: run_rfcomm_server.py classic2.json usb:0 8888')
         return
 
+    tcp_port = int(sys.argv[3])
+
+    if len(sys.argv) >= 5:
+        uuid = sys.argv[4]
+    else:
+        uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
+
     print('<<< connecting to HCI...')
     async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
         print('<<< connected')
@@ -105,15 +131,20 @@
         device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
         device.classic_enabled = True
 
-        # Create and register a server
+        # Create a TCP server
+        tcp_server = TcpServer(tcp_port)
+
+        # Create and register an RFComm server
         rfcomm_server = Server(device)
 
         # Listen for incoming DLC connections
-        channel_number = rfcomm_server.listen(on_dlc)
-        print(f'### Listening for connection on channel {channel_number}')
+        channel_number = rfcomm_server.listen(
+            lambda session: on_rfcomm_session(session, tcp_server)
+        )
+        print(f'### Listening for RFComm connections on channel {channel_number}')
 
         # Setup the SDP to advertise this channel
-        device.sdp_service_records = sdp_records(channel_number)
+        device.sdp_service_records = sdp_records(channel_number, uuid)
 
         # Start the controller
         await device.power_on()
diff --git a/examples/run_scanner.py b/examples/run_scanner.py
index bdd7fba..4a094b9 100644
--- a/examples/run_scanner.py
+++ b/examples/run_scanner.py
@@ -62,7 +62,7 @@
             print(
                 f'>>> {color(advertisement.address, address_color)} '
                 f'[{color(address_type_string, type_color)}]'
-                f'{address_qualifier}:{separator}RSSI:{advertisement.rssi}'
+                f'{address_qualifier}:{separator}RSSI: {advertisement.rssi}'
                 f'{separator}'
                 f'{advertisement.data.to_string(separator)}'
             )
diff --git a/examples/speaker.json b/examples/speaker.json
new file mode 100644
index 0000000..61ce80d
--- /dev/null
+++ b/examples/speaker.json
@@ -0,0 +1,5 @@
+{
+    "name": "Bumble Speaker",
+    "class_of_device": 2360324,
+    "keystore": "JsonKeyStore"
+}
diff --git a/rust/.gitignore b/rust/.gitignore
new file mode 100644
index 0000000..40d9aca
--- /dev/null
+++ b/rust/.gitignore
@@ -0,0 +1,2 @@
+/target
+/.idea
\ No newline at end of file
diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md
new file mode 100644
index 0000000..2cfed4e
--- /dev/null
+++ b/rust/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Next
+
+- Code-gen company ID table
+
+# 0.1.0
+
+- Initial release
\ No newline at end of file
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
new file mode 100644
index 0000000..c2d0cd3
--- /dev/null
+++ b/rust/Cargo.lock
@@ -0,0 +1,1976 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "addr2line"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "aho-corasick"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c378d78423fdad8089616f827526ee33c19f2fddbd5de1629152c9593ba4783"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15c4c2c83f81532e5845a733998b6971faca23490340a418e9b72a3ec9de12ea"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd"
+dependencies = [
+ "anstyle",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi 0.1.19",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+
+[[package]]
+name = "backtrace"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "base64"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
+
+[[package]]
+name = "bstr"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c2f7349907b712260e64b0afe2f84692af14a454be26187d9df565c7f69266a"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "bumble"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap 4.4.1",
+ "directories",
+ "env_logger",
+ "file-header",
+ "futures",
+ "globset",
+ "hex",
+ "itertools",
+ "lazy_static",
+ "log",
+ "nix",
+ "nom",
+ "owo-colors",
+ "pyo3",
+ "pyo3-asyncio",
+ "rand",
+ "reqwest",
+ "rusb",
+ "strum",
+ "strum_macros",
+ "tempfile",
+ "thiserror",
+ "tokio",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
+
+[[package]]
+name = "bytes"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
+
+[[package]]
+name = "cc"
+version = "1.0.83"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "3.2.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
+dependencies = [
+ "atty",
+ "bitflags 1.3.2",
+ "clap_lex 0.2.4",
+ "indexmap",
+ "strsim",
+ "termcolor",
+ "textwrap",
+]
+
+[[package]]
+name = "clap"
+version = "4.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c8d502cbaec4595d2e7d5f61e318f05417bd2b66fdc3809498f0d3fdf0bea27"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+ "once_cell",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5891c7bc0edb3e1c2204fc5e94009affabeb1821c9e5fdc3959536c5c0bb984d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex 0.5.1",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9fd1a5729c4548118d7d70ff234a44868d00489a4b6597b0b020918a0e91a1a"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
+dependencies = [
+ "os_str_bytes",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
+
+[[package]]
+name = "crossbeam"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-epoch",
+ "crossbeam-queue",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
+dependencies = [
+ "cfg-if",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "crossbeam-utils",
+ "memoffset 0.9.0",
+ "scopeguard",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys",
+]
+
+[[package]]
+name = "either"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0"
+dependencies = [
+ "humantime",
+ "is-terminal",
+ "log",
+ "regex",
+ "termcolor",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd"
+dependencies = [
+ "errno-dragonfly",
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "errno-dragonfly"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764"
+
+[[package]]
+name = "file-header"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5568149106e77ae33bc3a2c3ef3839cbe63ffa4a8dd4a81612a6f9dfdbc2e9f"
+dependencies = [
+ "crossbeam",
+ "lazy_static",
+ "license",
+ "thiserror",
+ "walkdir",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65"
+
+[[package]]
+name = "futures-util"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "gimli"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0"
+
+[[package]]
+name = "globset"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "fnv",
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "h2"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "http"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
+dependencies = [
+ "bytes",
+ "http",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[package]]
+name = "hyper"
+version = "0.14.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.4.9",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
+dependencies = [
+ "bytes",
+ "hyper",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+]
+
+[[package]]
+name = "idna"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown",
+]
+
+[[package]]
+name = "indoc"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306"
+
+[[package]]
+name = "inventory"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1be380c410bf0595e94992a648ea89db4dd3f3354ba54af206fd2a68cf5ac8e"
+
+[[package]]
+name = "ipnet"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
+
+[[package]]
+name = "is-terminal"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
+dependencies = [
+ "hermit-abi 0.3.2",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
+
+[[package]]
+name = "js-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.147"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
+
+[[package]]
+name = "libusb1-sys"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d0e2afce4245f2c9a418511e5af8718bcaf2fa408aefb259504d1a9cb25f27"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "license"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66615d42e949152327c402e03cd29dab8bff91ce470381ac2ca6d380d8d9946"
+dependencies = [
+ "reword",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503"
+
+[[package]]
+name = "lock_api"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
+
+[[package]]
+name = "memchr"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e"
+
+[[package]]
+name = "memoffset"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d61c719bcfbcf5d62b3a09efa6088de8c54bc0bfcd3ea7ae39fcc186108b8de1"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
+dependencies = [
+ "adler",
+]
+
+[[package]]
+name = "mio"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
+dependencies = [
+ "lazy_static",
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "nix"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+ "memoffset 0.7.1",
+ "pin-utils",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+dependencies = [
+ "hermit-abi 0.3.2",
+ "libc",
+]
+
+[[package]]
+name = "object"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ac5bbd07aea88c60a577a1ce218075ffd59208b2d7ca97adf9bfc5aeb21ebe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
+
+[[package]]
+name = "openssl"
+version = "0.10.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c"
+dependencies = [
+ "bitflags 2.4.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db7e971c2c2bba161b2d2fdf37080177eff520b3bc044787c7f1f5f9e78d869b"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "os_str_bytes"
+version = "6.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac"
+
+[[package]]
+name = "owo-colors"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.3.5",
+ "smallvec",
+ "windows-targets",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pyo3"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3b1ac5b3731ba34fdaa9785f8d74d17448cd18f30cf19e0c7e7b1fdb5272109"
+dependencies = [
+ "anyhow",
+ "cfg-if",
+ "indoc",
+ "libc",
+ "memoffset 0.8.0",
+ "parking_lot",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-asyncio"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3564762e37035cfc486228e10b0528460fa026d681b5763873c693aa0d5c260"
+dependencies = [
+ "clap 3.2.25",
+ "futures",
+ "inventory",
+ "once_cell",
+ "pin-project-lite",
+ "pyo3",
+ "pyo3-asyncio-macros",
+ "tokio",
+]
+
+[[package]]
+name = "pyo3-asyncio-macros"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be72d4cd43a27530306bd0d20d3932182fbdd072c6b98d3638bc37efb9d559dd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cb946f5ac61bb61a5014924910d936ebd2b23b705f7a4a3c40b05c720b079a3"
+dependencies = [
+ "once_cell",
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd4d7c5337821916ea2a1d21d1092e8443cf34879e53a0ac653fbb98f44ff65c"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d39c55dab3fc5a4b25bbd1ac10a2da452c4aca13bb450f22818a002e29648d"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97daff08a4c48320587b5224cc98d609e3c27b6d437315bd40b605c98eeb5918"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
+dependencies = [
+ "getrandom",
+ "redox_syscall 0.2.16",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12de2eff854e5fa4b1295edd650e227e9d8fb0c9e90b12e7f36d6a6811791a29"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49530408a136e16e5b486e883fbb6ba058e8e4e8ae6621a77b048b314336e629"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
+
+[[package]]
+name = "reqwest"
+version = "0.11.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "hyper",
+ "hyper-tls",
+ "ipnet",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "winreg",
+]
+
+[[package]]
+name = "reword"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe272098dce9ed76b479995953f748d1851261390b08f8a0ff619c885a1f0765"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "rusb"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45fff149b6033f25e825cbb7b2c625a11ee8e6dac09264d49beb125e39aa97bf"
+dependencies = [
+ "libc",
+ "libusb1-sys",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
+
+[[package]]
+name = "rustix"
+version = "0.38.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed6248e1caa625eb708e266e06159f135e8c26f2bb7ceb72dc4b2766d0340964"
+dependencies = [
+ "bitflags 2.4.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
+
+[[package]]
+name = "ryu"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "2.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.188"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.188"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
+
+[[package]]
+name = "socket2"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "socket2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "strum"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
+
+[[package]]
+name = "strum_macros"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d0e916b1148c8e263850e1ebcbd046f333e0683c724876bb0da63ea4373dc8a"
+
+[[package]]
+name = "tempfile"
+version = "3.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "redox_syscall 0.3.5",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"
+
+[[package]]
+name = "thiserror"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "num_cpus",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.5.3",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "tower-service"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+
+[[package]]
+name = "tracing"
+version = "0.1.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
+dependencies = [
+ "cfg-if",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
+
+[[package]]
+name = "unindent"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c"
+
+[[package]]
+name = "url"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "walkdir"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
+
+[[package]]
+name = "web-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "winreg"
+version = "0.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+dependencies = [
+ "cfg-if",
+ "windows-sys",
+]
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
new file mode 100644
index 0000000..a553afd
--- /dev/null
+++ b/rust/Cargo.toml
@@ -0,0 +1,86 @@
+[package]
+name = "bumble"
+description = "Rust API for the Bumble Bluetooth stack"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+homepage = "https://google.github.io/bumble/index.html"
+repository = "https://github.com/google/bumble"
+documentation = "https://docs.rs/crate/bumble"
+authors = ["Marshall Pierce <marshallpierce@google.com>"]
+keywords = ["bluetooth", "ble"]
+categories = ["api-bindings", "network-programming"]
+rust-version = "1.70.0"
+
+[dependencies]
+pyo3 = { version = "0.18.3", features = ["macros"] }
+pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
+tokio = { version = "1.28.2", features = ["macros", "signal"] }
+nom = "7.1.3"
+strum = "0.25.0"
+strum_macros = "0.25.0"
+hex = "0.4.3"
+itertools = "0.11.0"
+lazy_static = "1.4.0"
+thiserror = "1.0.41"
+
+# Dev tools
+file-header = { version = "0.1.2", optional = true }
+globset = { version = "0.4.13", optional = true }
+
+# CLI
+anyhow = { version = "1.0.71", optional = true }
+clap = { version = "4.3.3", features = ["derive"], optional = true }
+directories = { version = "5.0.1", optional = true }
+env_logger = { version = "0.10.0", optional = true }
+futures = { version = "0.3.28", optional = true }
+log = { version = "0.4.19", optional = true }
+owo-colors = { version = "3.5.0", optional = true }
+reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
+rusb = { version = "0.9.2", optional = true }
+
+[dev-dependencies]
+tokio = { version = "1.28.2", features = ["full"] }
+tempfile = "3.6.0"
+nix = "0.26.2"
+anyhow = "1.0.71"
+pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
+pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
+rusb = "0.9.2"
+rand = "0.8.5"
+clap = { version = "4.3.3", features = ["derive"] }
+owo-colors = "3.5.0"
+log = "0.4.19"
+env_logger = "0.10.0"
+
+[package.metadata.docs.rs]
+rustdoc-args = ["--generate-link-to-definition"]
+
+[[bin]]
+name = "file-header"
+path = "tools/file_header.rs"
+required-features = ["dev-tools"]
+
+[[bin]]
+name = "gen-assigned-numbers"
+path = "tools/gen_assigned_numbers.rs"
+required-features = ["dev-tools"]
+
+[[bin]]
+name = "bumble"
+path = "src/main.rs"
+required-features = ["bumble-tools"]
+
+# test entry point that uses pyo3_asyncio's test harness
+[[test]]
+name = "pytests"
+path = "pytests/pytests.rs"
+harness = false
+
+[features]
+anyhow = ["pyo3/anyhow"]
+pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
+dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"]
+# separate feature for CLI so that dependencies don't spend time building these
+bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
+default = []
diff --git a/rust/README.md b/rust/README.md
new file mode 100644
index 0000000..15a19b9
--- /dev/null
+++ b/rust/README.md
@@ -0,0 +1,66 @@
+# What is this?
+
+Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
+
+Method calls are mapped to the equivalent Python, and return types adapted where
+relevant.
+
+See the CLI in `src/main.rs` or the `examples` directory for how to use the
+Bumble API.
+
+# Usage
+
+Set up a virtualenv for Bumble, or otherwise have an isolated Python environment
+for Bumble and its dependencies.
+
+Due to Python being
+[picky about how its sys path is set up](https://github.com/PyO3/pyo3/issues/1741,
+it's necessary to explicitly point to the virtualenv's `site-packages`. Use
+suitable virtualenv paths as appropriate for your OS, as seen here running
+the `battery_client` example:
+
+```
+PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
+    cargo run --example battery_client -- \
+    --transport android-netsim --target-addr F0:F1:F2:F3:F4:F5
+```
+
+Run the corresponding `battery_server` Python example, and launch an emulator in
+Android Studio (currently, Canary is required) to run netsim.
+
+# CLI
+
+Explore the available subcommands:
+
+```
+PYTHONPATH=..:[virtualenv site-packages] \
+    cargo run --features bumble-tools --bin bumble -- --help
+```
+
+# Development
+
+Run the tests:
+
+```
+PYTHONPATH=.. cargo test
+```
+
+Check lints:
+
+```
+cargo clippy --all-targets
+```
+
+## Code gen
+
+To have the fastest startup while keeping the build simple, code gen for
+assigned numbers is done with the `gen_assigned_numbers` tool. It should
+be re-run whenever the Python assigned numbers are changed. To ensure that the
+generated code is kept up to date, the Rust data is compared to the Python
+in tests at `pytests/assigned_numbers.rs`.
+
+To regenerate the assigned number tables based on the Python codebase:
+
+```
+PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools
+```
\ No newline at end of file
diff --git a/rust/examples/battery_client.rs b/rust/examples/battery_client.rs
new file mode 100644
index 0000000..007ccb6
--- /dev/null
+++ b/rust/examples/battery_client.rs
@@ -0,0 +1,112 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Counterpart to the Python example `battery_server.py`.
+//!
+//! Start an Android emulator from Android Studio, or otherwise have netsim running.
+//!
+//! Run the server from the project root:
+//! ```
+//! PYTHONPATH=. python examples/battery_server.py \
+//!     examples/device1.json android-netsim
+//! ```
+//!
+//! Then run this example from the `rust` directory:
+//!
+//! ```
+//! PYTHONPATH=..:/path/to/virtualenv/site-packages/ \
+//!     cargo run --example battery_client -- \
+//!     --transport android-netsim \
+//!     --target-addr F0:F1:F2:F3:F4:F5
+//! ```
+
+use bumble::wrapper::{
+    device::{Device, Peer},
+    profile::BatteryServiceProxy,
+    transport::Transport,
+    PyObjectExt,
+};
+use clap::Parser as _;
+use log::info;
+use owo_colors::OwoColorize;
+use pyo3::prelude::*;
+
+#[pyo3_asyncio::tokio::main]
+async fn main() -> PyResult<()> {
+    env_logger::builder()
+        .filter_level(log::LevelFilter::Info)
+        .init();
+
+    let cli = Cli::parse();
+
+    let transport = Transport::open(cli.transport).await?;
+
+    let device = Device::with_hci(
+        "Bumble",
+        "F0:F1:F2:F3:F4:F5",
+        transport.source()?,
+        transport.sink()?,
+    )?;
+
+    device.power_on().await?;
+
+    let conn = device.connect(&cli.target_addr).await?;
+    let mut peer = Peer::new(conn)?;
+    for mut s in peer.discover_services().await? {
+        s.discover_characteristics().await?;
+    }
+    let battery_service = peer
+        .create_service_proxy::<BatteryServiceProxy>()?
+        .ok_or(anyhow::anyhow!("No battery service found"))?;
+
+    let mut battery_level_char = battery_service
+        .battery_level()?
+        .ok_or(anyhow::anyhow!("No battery level characteristic"))?;
+    info!(
+        "{} {}",
+        "Initial Battery Level:".green(),
+        battery_level_char
+            .read_value()
+            .await?
+            .extract_with_gil::<u32>()?
+    );
+    battery_level_char
+        .subscribe(|_py, args| {
+            info!(
+                "{} {:?}",
+                "Battery level update:".green(),
+                args.get_item(0)?.extract::<u32>()?,
+            );
+            Ok(())
+        })
+        .await?;
+
+    // wait until user kills the process
+    tokio::signal::ctrl_c().await?;
+    Ok(())
+}
+
+#[derive(clap::Parser)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+    /// Bumble transport spec.
+    ///
+    /// <https://google.github.io/bumble/transports/index.html>
+    #[arg(long)]
+    transport: String,
+
+    /// Address to connect to
+    #[arg(long)]
+    target_addr: String,
+}
diff --git a/rust/examples/broadcast.rs b/rust/examples/broadcast.rs
new file mode 100644
index 0000000..f87b644
--- /dev/null
+++ b/rust/examples/broadcast.rs
@@ -0,0 +1,98 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use anyhow::anyhow;
+use bumble::{
+    adv::{AdvertisementDataBuilder, CommonDataType},
+    wrapper::{
+        device::Device,
+        logging::{bumble_env_logging_level, py_logging_basic_config},
+        transport::Transport,
+    },
+};
+use clap::Parser as _;
+use pyo3::PyResult;
+use rand::Rng;
+use std::path;
+
+#[pyo3_asyncio::tokio::main]
+async fn main() -> PyResult<()> {
+    env_logger::builder()
+        .filter_level(log::LevelFilter::Info)
+        .init();
+
+    let cli = Cli::parse();
+
+    if cli.log_hci {
+        py_logging_basic_config(bumble_env_logging_level("DEBUG"))?;
+    }
+
+    let transport = Transport::open(cli.transport).await?;
+
+    let mut device = Device::from_config_file_with_hci(
+        &cli.device_config,
+        transport.source()?,
+        transport.sink()?,
+    )?;
+
+    let mut adv_data = AdvertisementDataBuilder::new();
+
+    adv_data
+        .append(
+            CommonDataType::CompleteLocalName,
+            "Bumble from Rust".as_bytes(),
+        )
+        .map_err(|e| anyhow!(e))?;
+
+    // Randomized TX power
+    adv_data
+        .append(
+            CommonDataType::TxPowerLevel,
+            &[rand::thread_rng().gen_range(-100_i8..=20) as u8],
+        )
+        .map_err(|e| anyhow!(e))?;
+
+    device.set_advertising_data(adv_data)?;
+    device.power_on().await?;
+
+    println!("Advertising...");
+    device.start_advertising(true).await?;
+
+    // wait until user kills the process
+    tokio::signal::ctrl_c().await?;
+
+    println!("Stopping...");
+    device.stop_advertising().await?;
+
+    Ok(())
+}
+
+#[derive(clap::Parser)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+    /// Bumble device config.
+    ///
+    /// See, for instance, `examples/device1.json` in the Python project.
+    #[arg(long)]
+    device_config: path::PathBuf,
+    /// Bumble transport spec.
+    ///
+    /// <https://google.github.io/bumble/transports/index.html>
+    #[arg(long)]
+    transport: String,
+
+    /// Log HCI commands
+    #[arg(long)]
+    log_hci: bool,
+}
diff --git a/rust/examples/scanner.rs b/rust/examples/scanner.rs
new file mode 100644
index 0000000..1b68ea5
--- /dev/null
+++ b/rust/examples/scanner.rs
@@ -0,0 +1,185 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Counterpart to the Python example `run_scanner.py`.
+//!
+//! Device deduplication is done here rather than relying on the controller's filtering to provide
+//! for additional features, like the ability to make deduplication time-bounded.
+
+use bumble::{
+    adv::CommonDataType,
+    wrapper::{
+        core::AdvertisementDataUnit, device::Device, hci::AddressType, transport::Transport,
+    },
+};
+use clap::Parser as _;
+use itertools::Itertools;
+use owo_colors::{OwoColorize, Style};
+use pyo3::PyResult;
+use std::{
+    collections,
+    sync::{Arc, Mutex},
+    time,
+};
+
+#[pyo3_asyncio::tokio::main]
+async fn main() -> PyResult<()> {
+    env_logger::builder()
+        .filter_level(log::LevelFilter::Info)
+        .init();
+
+    let cli = Cli::parse();
+
+    let transport = Transport::open(cli.transport).await?;
+
+    let mut device = Device::with_hci(
+        "Bumble",
+        "F0:F1:F2:F3:F4:F5",
+        transport.source()?,
+        transport.sink()?,
+    )?;
+
+    // in practice, devices can send multiple advertisements from the same address, so we keep
+    // track of a timestamp for each set of data
+    let seen_advertisements = Arc::new(Mutex::new(collections::HashMap::<
+        Vec<u8>,
+        collections::HashMap<Vec<AdvertisementDataUnit>, time::Instant>,
+    >::new()));
+
+    let seen_adv_clone = seen_advertisements.clone();
+    device.on_advertisement(move |_py, adv| {
+        let rssi = adv.rssi()?;
+        let data_units = adv.data()?.data_units()?;
+        let addr = adv.address()?;
+
+        let show_adv = if cli.filter_duplicates {
+            let addr_bytes = addr.as_le_bytes()?;
+
+            let mut seen_adv_cache = seen_adv_clone.lock().unwrap();
+            let expiry_duration = time::Duration::from_secs(cli.dedup_expiry_secs);
+
+            let advs_from_addr = seen_adv_cache
+                .entry(addr_bytes)
+                .or_insert_with(collections::HashMap::new);
+            // we expect cache hits to be the norm, so we do a separate lookup to avoid cloning
+            // on every lookup with entry()
+            let show = if let Some(prev) = advs_from_addr.get_mut(&data_units) {
+                let expired = prev.elapsed() > expiry_duration;
+                *prev = time::Instant::now();
+                expired
+            } else {
+                advs_from_addr.insert(data_units.clone(), time::Instant::now());
+                true
+            };
+
+            // clean out anything we haven't seen in a while
+            advs_from_addr.retain(|_, instant| instant.elapsed() <= expiry_duration);
+
+            show
+        } else {
+            true
+        };
+
+        if !show_adv {
+            return Ok(());
+        }
+
+        let addr_style = if adv.is_connectable()? {
+            Style::new().yellow()
+        } else {
+            Style::new().red()
+        };
+
+        let (type_style, qualifier) = match adv.address()?.address_type()? {
+            AddressType::PublicIdentity | AddressType::PublicDevice => (Style::new().cyan(), ""),
+            _ => {
+                if addr.is_static()? {
+                    (Style::new().green(), "(static)")
+                } else if addr.is_resolvable()? {
+                    (Style::new().magenta(), "(resolvable)")
+                } else {
+                    (Style::new().default_color(), "")
+                }
+            }
+        };
+
+        println!(
+            ">>> {} [{:?}] {qualifier}:\n  RSSI: {}",
+            addr.as_hex()?.style(addr_style),
+            addr.address_type()?.style(type_style),
+            rssi,
+        );
+
+        data_units.into_iter().for_each(|(code, data)| {
+            let matching = CommonDataType::for_type_code(code).collect::<Vec<_>>();
+            let code_str = if matching.is_empty() {
+                format!("0x{}", hex::encode_upper([code.into()]))
+            } else {
+                matching
+                    .iter()
+                    .map(|t| format!("{}", t))
+                    .join(" / ")
+                    .blue()
+                    .to_string()
+            };
+
+            // use the first matching type's formatted data, if any
+            let data_str = matching
+                .iter()
+                .filter_map(|t| {
+                    t.format_data(&data).map(|formatted| {
+                        format!(
+                            "{} {}",
+                            formatted,
+                            format!("(raw: 0x{})", hex::encode_upper(&data)).dimmed()
+                        )
+                    })
+                })
+                .next()
+                .unwrap_or_else(|| format!("0x{}", hex::encode_upper(&data)));
+
+            println!("  [{}]: {}", code_str, data_str)
+        });
+
+        Ok(())
+    })?;
+
+    device.power_on().await?;
+    // do our own dedup
+    device.start_scanning(false).await?;
+
+    // wait until user kills the process
+    tokio::signal::ctrl_c().await?;
+
+    Ok(())
+}
+
+#[derive(clap::Parser)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+    /// Bumble transport spec.
+    ///
+    /// <https://google.github.io/bumble/transports/index.html>
+    #[arg(long)]
+    transport: String,
+
+    /// Filter duplicate advertisements
+    #[arg(long, default_value_t = false)]
+    filter_duplicates: bool,
+
+    /// How long before a deduplicated advertisement that hasn't been seen in a while is considered
+    /// fresh again, in seconds
+    #[arg(long, default_value_t = 10, requires = "filter_duplicates")]
+    dedup_expiry_secs: u64,
+}
diff --git a/rust/pytests/assigned_numbers.rs b/rust/pytests/assigned_numbers.rs
new file mode 100644
index 0000000..7f8f1d1
--- /dev/null
+++ b/rust/pytests/assigned_numbers.rs
@@ -0,0 +1,44 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use bumble::wrapper::{self, core::Uuid16};
+use pyo3::{intern, prelude::*, types::PyDict};
+use std::collections;
+
+#[pyo3_asyncio::tokio::test]
+async fn company_ids_matches_python() -> PyResult<()> {
+    let ids_from_python = Python::with_gil(|py| {
+        PyModule::import(py, intern!(py, "bumble.company_ids"))?
+            .getattr(intern!(py, "COMPANY_IDENTIFIERS"))?
+            .downcast::<PyDict>()?
+            .into_iter()
+            .map(|(k, v)| {
+                Ok((
+                    Uuid16::from_be_bytes(k.extract::<u16>()?.to_be_bytes()),
+                    v.str()?.to_str()?.to_string(),
+                ))
+            })
+            .collect::<PyResult<collections::HashMap<_, _>>>()
+    })?;
+
+    assert_eq!(
+        wrapper::assigned_numbers::COMPANY_IDS
+            .iter()
+            .map(|(id, name)| (*id, name.to_string()))
+            .collect::<collections::HashMap<_, _>>(),
+        ids_from_python,
+        "Company ids do not match -- re-run gen_assigned_numbers?"
+    );
+    Ok(())
+}
diff --git a/rust/pytests/pytests.rs b/rust/pytests/pytests.rs
new file mode 100644
index 0000000..4a30e8d
--- /dev/null
+++ b/rust/pytests/pytests.rs
@@ -0,0 +1,21 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#[pyo3_asyncio::tokio::main]
+async fn main() -> pyo3::PyResult<()> {
+    pyo3_asyncio::testing::main().await
+}
+
+mod assigned_numbers;
+mod wrapper;
diff --git a/rust/pytests/wrapper.rs b/rust/pytests/wrapper.rs
new file mode 100644
index 0000000..8f69dd7
--- /dev/null
+++ b/rust/pytests/wrapper.rs
@@ -0,0 +1,37 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
+use nix::sys::stat::Mode;
+use pyo3::PyResult;
+
+#[pyo3_asyncio::tokio::test]
+async fn fifo_transport_can_open() -> PyResult<()> {
+    let dir = tempfile::tempdir().unwrap();
+    let mut fifo = dir.path().to_path_buf();
+    fifo.push("bumble-transport-fifo");
+    nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
+
+    let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
+
+    t.close().await?;
+
+    Ok(())
+}
+
+#[pyo3_asyncio::tokio::test]
+async fn realtek_driver_info_all_drivers() -> PyResult<()> {
+    assert_eq!(12, DriverInfo::all_drivers()?.len());
+    Ok(())
+}
diff --git a/rust/resources/test/firmware/realtek/README.md b/rust/resources/test/firmware/realtek/README.md
new file mode 100644
index 0000000..4c49608
--- /dev/null
+++ b/rust/resources/test/firmware/realtek/README.md
@@ -0,0 +1,4 @@
+This dir contains samples firmware images in the format used for Realtek chips,
+but with repetitions of the length of the section as a little-endian 32-bit int
+for the patch data instead of actual firmware, since we only need the structure
+to test parsing.
\ No newline at end of file
diff --git a/rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin b/rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
new file mode 100644
index 0000000..077cdc3
--- /dev/null
+++ b/rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Binary files differ
diff --git a/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin b/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
new file mode 100644
index 0000000..94df0ba
--- /dev/null
+++ b/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Binary files differ
diff --git a/rust/src/adv.rs b/rust/src/adv.rs
new file mode 100644
index 0000000..6f84cc5
--- /dev/null
+++ b/rust/src/adv.rs
@@ -0,0 +1,460 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! BLE advertisements.
+
+use crate::wrapper::assigned_numbers::{COMPANY_IDS, SERVICE_IDS};
+use crate::wrapper::core::{Uuid128, Uuid16, Uuid32};
+use itertools::Itertools;
+use nom::{combinator, multi, number};
+use std::fmt;
+use strum::IntoEnumIterator;
+
+/// The numeric code for a common data type.
+///
+/// For known types, see [CommonDataType], or use this type directly for non-assigned codes.
+#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)]
+pub struct CommonDataTypeCode(u8);
+
+impl From<CommonDataType> for CommonDataTypeCode {
+    fn from(value: CommonDataType) -> Self {
+        let byte = match value {
+            CommonDataType::Flags => 0x01,
+            CommonDataType::IncompleteListOf16BitServiceClassUuids => 0x02,
+            CommonDataType::CompleteListOf16BitServiceClassUuids => 0x03,
+            CommonDataType::IncompleteListOf32BitServiceClassUuids => 0x04,
+            CommonDataType::CompleteListOf32BitServiceClassUuids => 0x05,
+            CommonDataType::IncompleteListOf128BitServiceClassUuids => 0x06,
+            CommonDataType::CompleteListOf128BitServiceClassUuids => 0x07,
+            CommonDataType::ShortenedLocalName => 0x08,
+            CommonDataType::CompleteLocalName => 0x09,
+            CommonDataType::TxPowerLevel => 0x0A,
+            CommonDataType::ClassOfDevice => 0x0D,
+            CommonDataType::SimplePairingHashC192 => 0x0E,
+            CommonDataType::SimplePairingRandomizerR192 => 0x0F,
+            // These two both really have type code 0x10! D:
+            CommonDataType::DeviceId => 0x10,
+            CommonDataType::SecurityManagerTkValue => 0x10,
+            CommonDataType::SecurityManagerOutOfBandFlags => 0x11,
+            CommonDataType::PeripheralConnectionIntervalRange => 0x12,
+            CommonDataType::ListOf16BitServiceSolicitationUuids => 0x14,
+            CommonDataType::ListOf128BitServiceSolicitationUuids => 0x15,
+            CommonDataType::ServiceData16BitUuid => 0x16,
+            CommonDataType::PublicTargetAddress => 0x17,
+            CommonDataType::RandomTargetAddress => 0x18,
+            CommonDataType::Appearance => 0x19,
+            CommonDataType::AdvertisingInterval => 0x1A,
+            CommonDataType::LeBluetoothDeviceAddress => 0x1B,
+            CommonDataType::LeRole => 0x1C,
+            CommonDataType::SimplePairingHashC256 => 0x1D,
+            CommonDataType::SimplePairingRandomizerR256 => 0x1E,
+            CommonDataType::ListOf32BitServiceSolicitationUuids => 0x1F,
+            CommonDataType::ServiceData32BitUuid => 0x20,
+            CommonDataType::ServiceData128BitUuid => 0x21,
+            CommonDataType::LeSecureConnectionsConfirmationValue => 0x22,
+            CommonDataType::LeSecureConnectionsRandomValue => 0x23,
+            CommonDataType::Uri => 0x24,
+            CommonDataType::IndoorPositioning => 0x25,
+            CommonDataType::TransportDiscoveryData => 0x26,
+            CommonDataType::LeSupportedFeatures => 0x27,
+            CommonDataType::ChannelMapUpdateIndication => 0x28,
+            CommonDataType::PbAdv => 0x29,
+            CommonDataType::MeshMessage => 0x2A,
+            CommonDataType::MeshBeacon => 0x2B,
+            CommonDataType::BigInfo => 0x2C,
+            CommonDataType::BroadcastCode => 0x2D,
+            CommonDataType::ResolvableSetIdentifier => 0x2E,
+            CommonDataType::AdvertisingIntervalLong => 0x2F,
+            CommonDataType::ThreeDInformationData => 0x3D,
+            CommonDataType::ManufacturerSpecificData => 0xFF,
+        };
+
+        Self(byte)
+    }
+}
+
+impl From<u8> for CommonDataTypeCode {
+    fn from(value: u8) -> Self {
+        Self(value)
+    }
+}
+
+impl From<CommonDataTypeCode> for u8 {
+    fn from(value: CommonDataTypeCode) -> Self {
+        value.0
+    }
+}
+
+/// Data types for assigned type codes.
+///
+/// See Bluetooth Assigned Numbers § 2.3
+#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumIter)]
+#[allow(missing_docs)]
+pub enum CommonDataType {
+    Flags,
+    IncompleteListOf16BitServiceClassUuids,
+    CompleteListOf16BitServiceClassUuids,
+    IncompleteListOf32BitServiceClassUuids,
+    CompleteListOf32BitServiceClassUuids,
+    IncompleteListOf128BitServiceClassUuids,
+    CompleteListOf128BitServiceClassUuids,
+    ShortenedLocalName,
+    CompleteLocalName,
+    TxPowerLevel,
+    ClassOfDevice,
+    SimplePairingHashC192,
+    SimplePairingRandomizerR192,
+    DeviceId,
+    SecurityManagerTkValue,
+    SecurityManagerOutOfBandFlags,
+    PeripheralConnectionIntervalRange,
+    ListOf16BitServiceSolicitationUuids,
+    ListOf128BitServiceSolicitationUuids,
+    ServiceData16BitUuid,
+    PublicTargetAddress,
+    RandomTargetAddress,
+    Appearance,
+    AdvertisingInterval,
+    LeBluetoothDeviceAddress,
+    LeRole,
+    SimplePairingHashC256,
+    SimplePairingRandomizerR256,
+    ListOf32BitServiceSolicitationUuids,
+    ServiceData32BitUuid,
+    ServiceData128BitUuid,
+    LeSecureConnectionsConfirmationValue,
+    LeSecureConnectionsRandomValue,
+    Uri,
+    IndoorPositioning,
+    TransportDiscoveryData,
+    LeSupportedFeatures,
+    ChannelMapUpdateIndication,
+    PbAdv,
+    MeshMessage,
+    MeshBeacon,
+    BigInfo,
+    BroadcastCode,
+    ResolvableSetIdentifier,
+    AdvertisingIntervalLong,
+    ThreeDInformationData,
+    ManufacturerSpecificData,
+}
+
+impl CommonDataType {
+    /// Iterate over the zero, one, or more matching types for the provided code.
+    ///
+    /// `0x10` maps to both Device Id and Security Manager TK Value, so multiple matching types
+    /// may exist for a single code.
+    pub fn for_type_code(code: CommonDataTypeCode) -> impl Iterator<Item = CommonDataType> {
+        Self::iter().filter(move |t| CommonDataTypeCode::from(*t) == code)
+    }
+
+    /// Apply type-specific human-oriented formatting to data, if any is applicable
+    pub fn format_data(&self, data: &[u8]) -> Option<String> {
+        match self {
+            Self::Flags => Some(Flags::matching(data).map(|f| format!("{:?}", f)).join(",")),
+            Self::CompleteListOf16BitServiceClassUuids
+            | Self::IncompleteListOf16BitServiceClassUuids
+            | Self::ListOf16BitServiceSolicitationUuids => {
+                combinator::complete(multi::many0(Uuid16::parse_le))(data)
+                    .map(|(_res, uuids)| {
+                        uuids
+                            .into_iter()
+                            .map(|uuid| {
+                                SERVICE_IDS
+                                    .get(&uuid)
+                                    .map(|name| format!("{:?} ({name})", uuid))
+                                    .unwrap_or_else(|| format!("{:?}", uuid))
+                            })
+                            .join(", ")
+                    })
+                    .ok()
+            }
+            Self::CompleteListOf32BitServiceClassUuids
+            | Self::IncompleteListOf32BitServiceClassUuids
+            | Self::ListOf32BitServiceSolicitationUuids => {
+                combinator::complete(multi::many0(Uuid32::parse))(data)
+                    .map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", "))
+                    .ok()
+            }
+            Self::CompleteListOf128BitServiceClassUuids
+            | Self::IncompleteListOf128BitServiceClassUuids
+            | Self::ListOf128BitServiceSolicitationUuids => {
+                combinator::complete(multi::many0(Uuid128::parse_le))(data)
+                    .map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", "))
+                    .ok()
+            }
+            Self::ServiceData16BitUuid => Uuid16::parse_le(data)
+                .map(|(rem, uuid)| {
+                    format!(
+                        "service={:?}, data={}",
+                        SERVICE_IDS
+                            .get(&uuid)
+                            .map(|name| format!("{:?} ({name})", uuid))
+                            .unwrap_or_else(|| format!("{:?}", uuid)),
+                        hex::encode_upper(rem)
+                    )
+                })
+                .ok(),
+            Self::ServiceData32BitUuid => Uuid32::parse(data)
+                .map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem)))
+                .ok(),
+            Self::ServiceData128BitUuid => Uuid128::parse_le(data)
+                .map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem)))
+                .ok(),
+            Self::ShortenedLocalName | Self::CompleteLocalName => {
+                std::str::from_utf8(data).ok().map(|s| format!("\"{}\"", s))
+            }
+            Self::TxPowerLevel => {
+                let (_, tx) =
+                    combinator::complete(number::complete::i8::<_, nom::error::Error<_>>)(data)
+                        .ok()?;
+
+                Some(tx.to_string())
+            }
+            Self::ManufacturerSpecificData => {
+                let (rem, id) = Uuid16::parse_le(data).ok()?;
+                Some(format!(
+                    "company={}, data=0x{}",
+                    COMPANY_IDS
+                        .get(&id)
+                        .map(|s| s.to_string())
+                        .unwrap_or_else(|| format!("{:?}", id)),
+                    hex::encode_upper(rem)
+                ))
+            }
+            _ => None,
+        }
+    }
+}
+
+impl fmt::Display for CommonDataType {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            CommonDataType::Flags => write!(f, "Flags"),
+            CommonDataType::IncompleteListOf16BitServiceClassUuids => {
+                write!(f, "Incomplete List of 16-bit Service Class UUIDs")
+            }
+            CommonDataType::CompleteListOf16BitServiceClassUuids => {
+                write!(f, "Complete List of 16-bit Service Class UUIDs")
+            }
+            CommonDataType::IncompleteListOf32BitServiceClassUuids => {
+                write!(f, "Incomplete List of 32-bit Service Class UUIDs")
+            }
+            CommonDataType::CompleteListOf32BitServiceClassUuids => {
+                write!(f, "Complete List of 32-bit Service Class UUIDs")
+            }
+            CommonDataType::ListOf16BitServiceSolicitationUuids => {
+                write!(f, "List of 16-bit Service Solicitation UUIDs")
+            }
+            CommonDataType::ListOf32BitServiceSolicitationUuids => {
+                write!(f, "List of 32-bit Service Solicitation UUIDs")
+            }
+            CommonDataType::ListOf128BitServiceSolicitationUuids => {
+                write!(f, "List of 128-bit Service Solicitation UUIDs")
+            }
+            CommonDataType::IncompleteListOf128BitServiceClassUuids => {
+                write!(f, "Incomplete List of 128-bit Service Class UUIDs")
+            }
+            CommonDataType::CompleteListOf128BitServiceClassUuids => {
+                write!(f, "Complete List of 128-bit Service Class UUIDs")
+            }
+            CommonDataType::ShortenedLocalName => write!(f, "Shortened Local Name"),
+            CommonDataType::CompleteLocalName => write!(f, "Complete Local Name"),
+            CommonDataType::TxPowerLevel => write!(f, "TX Power Level"),
+            CommonDataType::ClassOfDevice => write!(f, "Class of Device"),
+            CommonDataType::SimplePairingHashC192 => {
+                write!(f, "Simple Pairing Hash C-192")
+            }
+            CommonDataType::SimplePairingHashC256 => {
+                write!(f, "Simple Pairing Hash C 256")
+            }
+            CommonDataType::SimplePairingRandomizerR192 => {
+                write!(f, "Simple Pairing Randomizer R-192")
+            }
+            CommonDataType::SimplePairingRandomizerR256 => {
+                write!(f, "Simple Pairing Randomizer R 256")
+            }
+            CommonDataType::DeviceId => write!(f, "Device Id"),
+            CommonDataType::SecurityManagerTkValue => {
+                write!(f, "Security Manager TK Value")
+            }
+            CommonDataType::SecurityManagerOutOfBandFlags => {
+                write!(f, "Security Manager Out of Band Flags")
+            }
+            CommonDataType::PeripheralConnectionIntervalRange => {
+                write!(f, "Peripheral Connection Interval Range")
+            }
+            CommonDataType::ServiceData16BitUuid => {
+                write!(f, "Service Data 16-bit UUID")
+            }
+            CommonDataType::ServiceData32BitUuid => {
+                write!(f, "Service Data 32-bit UUID")
+            }
+            CommonDataType::ServiceData128BitUuid => {
+                write!(f, "Service Data 128-bit UUID")
+            }
+            CommonDataType::PublicTargetAddress => write!(f, "Public Target Address"),
+            CommonDataType::RandomTargetAddress => write!(f, "Random Target Address"),
+            CommonDataType::Appearance => write!(f, "Appearance"),
+            CommonDataType::AdvertisingInterval => write!(f, "Advertising Interval"),
+            CommonDataType::LeBluetoothDeviceAddress => {
+                write!(f, "LE Bluetooth Device Address")
+            }
+            CommonDataType::LeRole => write!(f, "LE Role"),
+            CommonDataType::LeSecureConnectionsConfirmationValue => {
+                write!(f, "LE Secure Connections Confirmation Value")
+            }
+            CommonDataType::LeSecureConnectionsRandomValue => {
+                write!(f, "LE Secure Connections Random Value")
+            }
+            CommonDataType::LeSupportedFeatures => write!(f, "LE Supported Features"),
+            CommonDataType::Uri => write!(f, "URI"),
+            CommonDataType::IndoorPositioning => write!(f, "Indoor Positioning"),
+            CommonDataType::TransportDiscoveryData => {
+                write!(f, "Transport Discovery Data")
+            }
+            CommonDataType::ChannelMapUpdateIndication => {
+                write!(f, "Channel Map Update Indication")
+            }
+            CommonDataType::PbAdv => write!(f, "PB-ADV"),
+            CommonDataType::MeshMessage => write!(f, "Mesh Message"),
+            CommonDataType::MeshBeacon => write!(f, "Mesh Beacon"),
+            CommonDataType::BigInfo => write!(f, "BIGIInfo"),
+            CommonDataType::BroadcastCode => write!(f, "Broadcast Code"),
+            CommonDataType::ResolvableSetIdentifier => {
+                write!(f, "Resolvable Set Identifier")
+            }
+            CommonDataType::AdvertisingIntervalLong => {
+                write!(f, "Advertising Interval Long")
+            }
+            CommonDataType::ThreeDInformationData => write!(f, "3D Information Data"),
+            CommonDataType::ManufacturerSpecificData => {
+                write!(f, "Manufacturer Specific Data")
+            }
+        }
+    }
+}
+
+/// Accumulates advertisement data to broadcast on a [crate::wrapper::device::Device].
+#[derive(Debug, Clone, Default)]
+pub struct AdvertisementDataBuilder {
+    encoded_data: Vec<u8>,
+}
+
+impl AdvertisementDataBuilder {
+    /// Returns a new, empty instance.
+    pub fn new() -> Self {
+        Self {
+            encoded_data: Vec::new(),
+        }
+    }
+
+    /// Append advertising data to the builder.
+    ///
+    /// Returns an error if the data cannot be appended.
+    pub fn append(
+        &mut self,
+        type_code: impl Into<CommonDataTypeCode>,
+        data: &[u8],
+    ) -> Result<(), AdvertisementDataBuilderError> {
+        self.encoded_data.push(
+            data.len()
+                .try_into()
+                .ok()
+                .and_then(|len: u8| len.checked_add(1))
+                .ok_or(AdvertisementDataBuilderError::DataTooLong)?,
+        );
+        self.encoded_data.push(type_code.into().0);
+        self.encoded_data.extend_from_slice(data);
+
+        Ok(())
+    }
+
+    pub(crate) fn into_bytes(self) -> Vec<u8> {
+        self.encoded_data
+    }
+}
+
+/// Errors that can occur when building advertisement data with [AdvertisementDataBuilder].
+#[derive(Debug, PartialEq, Eq, thiserror::Error)]
+pub enum AdvertisementDataBuilderError {
+    /// The provided adv data is too long to be encoded
+    #[error("Data too long")]
+    DataTooLong,
+}
+
+#[derive(PartialEq, Eq, strum_macros::EnumIter)]
+#[allow(missing_docs)]
+/// Features in the Flags AD
+pub enum Flags {
+    LeLimited,
+    LeDiscoverable,
+    NoBrEdr,
+    BrEdrController,
+    BrEdrHost,
+}
+
+impl fmt::Debug for Flags {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.short_name())
+    }
+}
+
+impl Flags {
+    /// Iterates over the flags that are present in the provided `flags` bytes.
+    pub fn matching(flags: &[u8]) -> impl Iterator<Item = Self> + '_ {
+        // The encoding is not clear from the spec: do we look at the first byte? or the last?
+        // In practice it's only one byte.
+        let first_byte = flags.first().unwrap_or(&0_u8);
+
+        Self::iter().filter(move |f| {
+            let mask = match f {
+                Flags::LeLimited => 0x01_u8,
+                Flags::LeDiscoverable => 0x02,
+                Flags::NoBrEdr => 0x04,
+                Flags::BrEdrController => 0x08,
+                Flags::BrEdrHost => 0x10,
+            };
+
+            mask & first_byte > 0
+        })
+    }
+
+    /// An abbreviated form of the flag name.
+    ///
+    /// See [Flags::name] for the full name.
+    pub fn short_name(&self) -> &'static str {
+        match self {
+            Flags::LeLimited => "LE Limited",
+            Flags::LeDiscoverable => "LE General",
+            Flags::NoBrEdr => "No BR/EDR",
+            Flags::BrEdrController => "BR/EDR C",
+            Flags::BrEdrHost => "BR/EDR H",
+        }
+    }
+
+    /// The human-readable name of the flag.
+    ///
+    /// See [Flags::short_name] for a shorter string for use if compactness is important.
+    pub fn name(&self) -> &'static str {
+        match self {
+            Flags::LeLimited => "LE Limited Discoverable Mode",
+            Flags::LeDiscoverable => "LE General Discoverable Mode",
+            Flags::NoBrEdr => "BR/EDR Not Supported",
+            Flags::BrEdrController => "Simultaneous LE and BR/EDR (Controller)",
+            Flags::BrEdrHost => "Simultaneous LE and BR/EDR (Host)",
+        }
+    }
+}
diff --git a/rust/src/cli/firmware/mod.rs b/rust/src/cli/firmware/mod.rs
new file mode 100644
index 0000000..1fa1417
--- /dev/null
+++ b/rust/src/cli/firmware/mod.rs
@@ -0,0 +1,15 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+pub(crate) mod rtk;
diff --git a/rust/src/cli/firmware/rtk.rs b/rust/src/cli/firmware/rtk.rs
new file mode 100644
index 0000000..f5524a4
--- /dev/null
+++ b/rust/src/cli/firmware/rtk.rs
@@ -0,0 +1,265 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Realtek firmware tools
+
+use crate::{Download, Source};
+use anyhow::anyhow;
+use bumble::wrapper::{
+    drivers::rtk::{Driver, DriverInfo, Firmware},
+    host::{DriverFactory, Host},
+    transport::Transport,
+};
+use owo_colors::{colors::css, OwoColorize};
+use pyo3::PyResult;
+use std::{fs, path};
+
+pub(crate) async fn download(dl: Download) -> PyResult<()> {
+    let data_dir = dl
+        .output_dir
+        .or_else(|| {
+            directories::ProjectDirs::from("com", "google", "bumble")
+                .map(|pd| pd.data_local_dir().join("firmware").join("realtek"))
+        })
+        .unwrap_or_else(|| {
+            eprintln!("Could not determine standard data directory");
+            path::PathBuf::from(".")
+        });
+    fs::create_dir_all(&data_dir)?;
+
+    let (base_url, uses_bin_suffix) = match dl.source {
+        Source::LinuxKernel => ("https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt", true),
+        Source::RealtekOpensource => ("https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT", false),
+        Source::LinuxFromScratch => ("https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt", true),
+    };
+
+    println!("Downloading");
+    println!("{} {}", "FROM:".green(), base_url);
+    println!("{} {}", "TO:".green(), data_dir.to_string_lossy());
+
+    let url_for_file = |file_name: &str| {
+        let url_suffix = if uses_bin_suffix {
+            file_name
+        } else {
+            file_name.trim_end_matches(".bin")
+        };
+
+        let mut url = base_url.to_string();
+        url.push('/');
+        url.push_str(url_suffix);
+        url
+    };
+
+    let to_download = if let Some(single) = dl.single {
+        vec![(
+            format!("{single}_fw.bin"),
+            Some(format!("{single}_config.bin")),
+            false,
+        )]
+    } else {
+        DriverInfo::all_drivers()?
+            .iter()
+            .map(|di| Ok((di.firmware_name()?, di.config_name()?, di.config_needed()?)))
+            .collect::<PyResult<Vec<_>>>()?
+    };
+
+    let client = SimpleClient::new();
+
+    for (fw_filename, config_filename, config_needed) in to_download {
+        println!("{}", "---".yellow());
+        let fw_path = data_dir.join(&fw_filename);
+        let config_path = config_filename.as_ref().map(|f| data_dir.join(f));
+
+        if fw_path.exists() && !dl.overwrite {
+            println!(
+                "{}",
+                format!("{} already exists, skipping", fw_path.to_string_lossy())
+                    .fg::<css::Orange>()
+            );
+            continue;
+        }
+        if let Some(cp) = config_path.as_ref() {
+            if cp.exists() && !dl.overwrite {
+                println!(
+                    "{}",
+                    format!("{} already exists, skipping", cp.to_string_lossy())
+                        .fg::<css::Orange>()
+                );
+                continue;
+            }
+        }
+
+        let fw_contents = match client.get(&url_for_file(&fw_filename)).await {
+            Ok(data) => {
+                println!("Downloaded {}: {} bytes", fw_filename, data.len());
+                data
+            }
+            Err(e) => {
+                eprintln!(
+                    "{} {} {:?}",
+                    "Failed to download".red(),
+                    fw_filename.red(),
+                    e
+                );
+                continue;
+            }
+        };
+
+        let config_contents = if let Some(cn) = &config_filename {
+            match client.get(&url_for_file(cn)).await {
+                Ok(data) => {
+                    println!("Downloaded {}: {} bytes", cn, data.len());
+                    Some(data)
+                }
+                Err(e) => {
+                    if config_needed {
+                        eprintln!("{} {} {:?}", "Failed to download".red(), cn.red(), e);
+                        continue;
+                    } else {
+                        eprintln!(
+                            "{}",
+                            format!("No config available as {cn}").fg::<css::Orange>()
+                        );
+                        None
+                    }
+                }
+            }
+        } else {
+            None
+        };
+
+        fs::write(&fw_path, &fw_contents)?;
+        if !dl.no_parse && config_filename.is_some() {
+            println!("{} {}", "Parsing:".cyan(), &fw_filename);
+            match Firmware::parse(&fw_contents).map_err(|e| anyhow!("Parse error: {:?}", e)) {
+                Ok(fw) => dump_firmware_desc(&fw),
+                Err(e) => {
+                    eprintln!(
+                        "{} {:?}",
+                        "Could not parse firmware:".fg::<css::Orange>(),
+                        e
+                    );
+                }
+            }
+        }
+        if let Some((cp, cd)) = config_path
+            .as_ref()
+            .and_then(|p| config_contents.map(|c| (p, c)))
+        {
+            fs::write(cp, &cd)?;
+        }
+    }
+
+    Ok(())
+}
+
+pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
+    let contents = fs::read(firmware_path)?;
+    let fw = Firmware::parse(&contents)
+        // squish the error into a string to avoid the error type requiring that the input be
+        // 'static
+        .map_err(|e| anyhow!("Parse error: {:?}", e))?;
+
+    dump_firmware_desc(&fw);
+
+    Ok(())
+}
+
+pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
+    let transport = Transport::open(transport).await?;
+
+    let mut host = Host::new(transport.source()?, transport.sink()?)?;
+    host.reset(DriverFactory::None).await?;
+
+    if !force && !Driver::check(&host).await? {
+        println!("USB device not supported by this RTK driver");
+    } else if let Some(driver_info) = Driver::driver_info_for_host(&host).await? {
+        println!("Driver:");
+        println!("  {:10} {:04X}", "ROM:", driver_info.rom()?);
+        println!("  {:10} {}", "Firmware:", driver_info.firmware_name()?);
+        println!(
+            "  {:10} {}",
+            "Config:",
+            driver_info.config_name()?.unwrap_or_default()
+        );
+    } else {
+        println!("Firmware already loaded or no supported driver for this device.")
+    }
+
+    Ok(())
+}
+
+pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
+    let transport = Transport::open(transport).await?;
+
+    let mut host = Host::new(transport.source()?, transport.sink()?)?;
+    host.reset(DriverFactory::None).await?;
+
+    match Driver::for_host(&host, force).await? {
+        None => {
+            eprintln!("Firmware already loaded or no supported driver for this device.");
+        }
+        Some(mut d) => d.download_firmware().await?,
+    };
+
+    Ok(())
+}
+
+pub(crate) async fn drop(transport: &str) -> PyResult<()> {
+    let transport = Transport::open(transport).await?;
+
+    let mut host = Host::new(transport.source()?, transport.sink()?)?;
+    host.reset(DriverFactory::None).await?;
+
+    Driver::drop_firmware(&mut host).await?;
+
+    Ok(())
+}
+
+fn dump_firmware_desc(fw: &Firmware) {
+    println!(
+        "Firmware: version=0x{:08X} project_id=0x{:04X}",
+        fw.version(),
+        fw.project_id()
+    );
+    for p in fw.patches() {
+        println!(
+            "  Patch: chip_id=0x{:04X}, {} bytes, SVN Version={:08X}",
+            p.chip_id(),
+            p.contents().len(),
+            p.svn_version()
+        )
+    }
+}
+
+struct SimpleClient {
+    client: reqwest::Client,
+}
+
+impl SimpleClient {
+    fn new() -> Self {
+        Self {
+            client: reqwest::Client::new(),
+        }
+    }
+
+    async fn get(&self, url: &str) -> anyhow::Result<Vec<u8>> {
+        let resp = self.client.get(url).send().await?;
+        if !resp.status().is_success() {
+            return Err(anyhow!("Bad status: {}", resp.status()));
+        }
+        let bytes = resp.bytes().await?;
+        Ok(bytes.as_ref().to_vec())
+    }
+}
diff --git a/rust/src/cli/l2cap/client_bridge.rs b/rust/src/cli/l2cap/client_bridge.rs
new file mode 100644
index 0000000..37606fc
--- /dev/null
+++ b/rust/src/cli/l2cap/client_bridge.rs
@@ -0,0 +1,191 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/// 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, the TCP connection is closed as well.
+use crate::cli::l2cap::{
+    proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
+    BridgeData,
+};
+use bumble::wrapper::{
+    device::{Connection, Device},
+    hci::HciConstant,
+};
+use futures::executor::block_on;
+use owo_colors::OwoColorize;
+use pyo3::{PyResult, Python};
+use std::{net::SocketAddr, sync::Arc};
+use tokio::{
+    join,
+    net::{TcpListener, TcpStream},
+    sync::{mpsc, Mutex},
+};
+
+pub struct Args {
+    pub psm: u16,
+    pub max_credits: Option<u16>,
+    pub mtu: Option<u16>,
+    pub mps: Option<u16>,
+    pub bluetooth_address: String,
+    pub tcp_host: String,
+    pub tcp_port: u16,
+}
+
+pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
+    println!(
+        "{}",
+        format!("### Connecting to {}...", args.bluetooth_address).yellow()
+    );
+    let mut ble_connection = device.connect(&args.bluetooth_address).await?;
+    ble_connection.on_disconnection(|_py, reason| {
+        let disconnection_info = match HciConstant::error_name(reason) {
+            Ok(info_string) => info_string,
+            Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
+        };
+        println!(
+            "{} {}",
+            "@@@ Bluetooth disconnection: ".red(),
+            disconnection_info,
+        );
+        Ok(())
+    })?;
+
+    // Start the TCP server.
+    let listener = TcpListener::bind(format!("{}:{}", args.tcp_host, args.tcp_port))
+        .await
+        .expect("failed to bind tcp to address");
+    println!(
+        "{}",
+        format!(
+            "### Listening for TCP connections on port {}",
+            args.tcp_port
+        )
+        .magenta()
+    );
+
+    let psm = args.psm;
+    let max_credits = args.max_credits;
+    let mtu = args.mtu;
+    let mps = args.mps;
+    let ble_connection = Arc::new(Mutex::new(ble_connection));
+    // Ensure Python event loop is available to l2cap `disconnect`
+    let _ = run_future_with_current_task_locals(async move {
+        while let Ok((tcp_stream, addr)) = listener.accept().await {
+            let ble_connection = ble_connection.clone();
+            let _ = run_future_with_current_task_locals(proxy_data_between_tcp_and_l2cap(
+                ble_connection,
+                tcp_stream,
+                addr,
+                psm,
+                max_credits,
+                mtu,
+                mps,
+            ));
+        }
+        Ok(())
+    });
+    Ok(())
+}
+
+async fn proxy_data_between_tcp_and_l2cap(
+    ble_connection: Arc<Mutex<Connection>>,
+    tcp_stream: TcpStream,
+    addr: SocketAddr,
+    psm: u16,
+    max_credits: Option<u16>,
+    mtu: Option<u16>,
+    mps: Option<u16>,
+) -> PyResult<()> {
+    println!("{}", format!("<<< TCP connection from {}", addr).magenta());
+    println!(
+        "{}",
+        format!(">>> Opening L2CAP channel on PSM = {}", psm).yellow()
+    );
+
+    let mut l2cap_channel = match ble_connection
+        .lock()
+        .await
+        .open_l2cap_channel(psm, max_credits, mtu, mps)
+        .await
+    {
+        Ok(channel) => channel,
+        Err(e) => {
+            println!("{}", format!("!!! Connection failed: {e}").red());
+            // TCP stream will get dropped after returning, automatically shutting it down.
+            return Err(e);
+        }
+    };
+    let channel_info = l2cap_channel
+        .debug_string()
+        .unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
+
+    println!("{}{}", "*** L2CAP channel: ".cyan(), channel_info);
+
+    let (l2cap_to_tcp_tx, l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
+
+    // Set l2cap callback (`set_sink`) for when data is received.
+    let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
+    l2cap_channel
+        .set_sink(move |_py, sdu| {
+            block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
+                .expect("failed to channel data to tcp");
+            Ok(())
+        })
+        .expect("failed to set sink for l2cap connection");
+
+    // Set l2cap callback for when the channel is closed.
+    l2cap_channel
+        .on_close(move |_py| {
+            println!("{}", "*** L2CAP channel closed".red());
+            block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
+                .expect("failed to channel close signal to tcp");
+            Ok(())
+        })
+        .expect("failed to set on_close callback for l2cap channel");
+
+    let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
+    let (tcp_reader, tcp_writer) = tcp_stream.into_split();
+
+    // Do tcp stuff when something happens on the l2cap channel.
+    let handle_l2cap_data_future =
+        proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
+
+    // Do l2cap stuff when something happens on tcp.
+    let handle_tcp_data_future = proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), true);
+
+    let (handle_l2cap_result, handle_tcp_result) =
+        join!(handle_l2cap_data_future, handle_tcp_data_future);
+
+    if let Err(e) = handle_l2cap_result {
+        println!("!!! Error: {e}");
+    }
+
+    if let Err(e) = handle_tcp_result {
+        println!("!!! Error: {e}");
+    }
+
+    Python::with_gil(|_| {
+        // Must hold GIL at least once while/after dropping for Python heap object to ensure
+        // de-allocation.
+        drop(l2cap_channel);
+    });
+
+    Ok(())
+}
diff --git a/rust/src/cli/l2cap/mod.rs b/rust/src/cli/l2cap/mod.rs
new file mode 100644
index 0000000..31097ed
--- /dev/null
+++ b/rust/src/cli/l2cap/mod.rs
@@ -0,0 +1,190 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Rust version of the Python `l2cap_bridge.py` found under the `apps` folder.
+
+use crate::L2cap;
+use anyhow::anyhow;
+use bumble::wrapper::{device::Device, l2cap::LeConnectionOrientedChannel, transport::Transport};
+use owo_colors::{colors::css::Orange, OwoColorize};
+use pyo3::{PyObject, PyResult, Python};
+use std::{future::Future, path::PathBuf, sync::Arc};
+use tokio::{
+    io::{AsyncReadExt, AsyncWriteExt},
+    net::tcp::{OwnedReadHalf, OwnedWriteHalf},
+    sync::{mpsc::Receiver, Mutex},
+};
+
+mod client_bridge;
+mod server_bridge;
+
+pub(crate) async fn run(
+    command: L2cap,
+    device_config: PathBuf,
+    transport: String,
+    psm: u16,
+    max_credits: Option<u16>,
+    mtu: Option<u16>,
+    mps: Option<u16>,
+) -> PyResult<()> {
+    println!("<<< connecting to HCI...");
+    let transport = Transport::open(transport).await?;
+    println!("<<< connected");
+
+    let mut device =
+        Device::from_config_file_with_hci(&device_config, transport.source()?, transport.sink()?)?;
+
+    device.power_on().await?;
+
+    match command {
+        L2cap::Server { tcp_host, tcp_port } => {
+            let args = server_bridge::Args {
+                psm,
+                max_credits,
+                mtu,
+                mps,
+                tcp_host,
+                tcp_port,
+            };
+
+            server_bridge::start(&args, &mut device).await?
+        }
+        L2cap::Client {
+            bluetooth_address,
+            tcp_host,
+            tcp_port,
+        } => {
+            let args = client_bridge::Args {
+                psm,
+                max_credits,
+                mtu,
+                mps,
+                bluetooth_address,
+                tcp_host,
+                tcp_port,
+            };
+
+            client_bridge::start(&args, &mut device).await?
+        }
+    };
+
+    // wait until user kills the process
+    tokio::signal::ctrl_c().await?;
+
+    Ok(())
+}
+
+/// Used for channeling data from Python callbacks to a Rust consumer.
+enum BridgeData {
+    Data(Vec<u8>),
+    CloseSignal,
+}
+
+async fn proxy_l2cap_rx_to_tcp_tx(
+    mut l2cap_data_receiver: Receiver<BridgeData>,
+    mut tcp_writer: OwnedWriteHalf,
+    l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
+) -> anyhow::Result<()> {
+    while let Some(bridge_data) = l2cap_data_receiver.recv().await {
+        match bridge_data {
+            BridgeData::Data(sdu) => {
+                println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
+                tcp_writer
+                    .write_all(sdu.as_ref())
+                    .await
+                    .map_err(|_| anyhow!("Failed to write to tcp stream"))?;
+                tcp_writer
+                    .flush()
+                    .await
+                    .map_err(|_| anyhow!("Failed to flush tcp stream"))?;
+            }
+            BridgeData::CloseSignal => {
+                l2cap_channel.lock().await.take();
+                tcp_writer
+                    .shutdown()
+                    .await
+                    .map_err(|_| anyhow!("Failed to shut down write half of tcp stream"))?;
+                return Ok(());
+            }
+        }
+    }
+    Ok(())
+}
+
+async fn proxy_tcp_rx_to_l2cap_tx(
+    mut tcp_reader: OwnedReadHalf,
+    l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
+    drain_l2cap_after_write: bool,
+) -> PyResult<()> {
+    let mut buf = [0; 4096];
+    loop {
+        match tcp_reader.read(&mut buf).await {
+            Ok(len) => {
+                if len == 0 {
+                    println!("{}", "!!! End of stream".fg::<Orange>());
+
+                    if let Some(mut channel) = l2cap_channel.lock().await.take() {
+                        channel.disconnect().await.map_err(|e| {
+                            eprintln!("Failed to call disconnect on l2cap channel: {e}");
+                            e
+                        })?;
+                    }
+                    return Ok(());
+                }
+
+                println!("{}", format!("<<< [TCP DATA]: {len} bytes").blue());
+                match l2cap_channel.lock().await.as_mut() {
+                    None => {
+                        println!("{}", "!!! L2CAP channel not connected, dropping".red());
+                        return Ok(());
+                    }
+                    Some(channel) => {
+                        channel.write(&buf[..len])?;
+                        if drain_l2cap_after_write {
+                            channel.drain().await?;
+                        }
+                    }
+                }
+            }
+            Err(e) => {
+                println!("{}", format!("!!! TCP connection lost: {}", e).red());
+                if let Some(mut channel) = l2cap_channel.lock().await.take() {
+                    let _ = channel.disconnect().await.map_err(|e| {
+                        eprintln!("Failed to call disconnect on l2cap channel: {e}");
+                    });
+                }
+                return Err(e.into());
+            }
+        }
+    }
+}
+
+/// Copies the current thread's TaskLocals into a Python "awaitable" and encapsulates it in a Rust
+/// future, running it as a Python Task.
+/// `TaskLocals` stores the current event loop, and allows the user to copy the current Python
+/// context if necessary. In this case, the python event loop is used when calling `disconnect` on
+/// an l2cap connection, or else the call will fail.
+pub fn run_future_with_current_task_locals<F>(
+    fut: F,
+) -> PyResult<impl Future<Output = PyResult<PyObject>> + Send>
+where
+    F: Future<Output = PyResult<()>> + Send + 'static,
+{
+    Python::with_gil(|py| {
+        let locals = pyo3_asyncio::tokio::get_current_locals(py)?;
+        let future = pyo3_asyncio::tokio::scope(locals.clone(), fut);
+        pyo3_asyncio::tokio::future_into_py_with_locals(py, locals, future)
+            .and_then(pyo3_asyncio::tokio::into_future)
+    })
+}
diff --git a/rust/src/cli/l2cap/server_bridge.rs b/rust/src/cli/l2cap/server_bridge.rs
new file mode 100644
index 0000000..3a32db9
--- /dev/null
+++ b/rust/src/cli/l2cap/server_bridge.rs
@@ -0,0 +1,205 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/// 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, the L2CAP connection is closed as well.
+use crate::cli::l2cap::{
+    proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
+    BridgeData,
+};
+use bumble::wrapper::{device::Device, hci::HciConstant, l2cap::LeConnectionOrientedChannel};
+use futures::executor::block_on;
+use owo_colors::OwoColorize;
+use pyo3::{PyResult, Python};
+use std::{sync::Arc, time::Duration};
+use tokio::{
+    join,
+    net::TcpStream,
+    select,
+    sync::{mpsc, Mutex},
+};
+
+pub struct Args {
+    pub psm: u16,
+    pub max_credits: Option<u16>,
+    pub mtu: Option<u16>,
+    pub mps: Option<u16>,
+    pub tcp_host: String,
+    pub tcp_port: u16,
+}
+
+pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
+    let host = args.tcp_host.clone();
+    let port = args.tcp_port;
+    device.register_l2cap_channel_server(
+        args.psm,
+        move |_py, l2cap_channel| {
+            let channel_info = l2cap_channel
+                .debug_string()
+                .unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
+            println!("{} {channel_info}", "*** L2CAP channel:".cyan());
+
+            let host = host.clone();
+            // Ensure Python event loop is available to l2cap `disconnect`
+            let _ = run_future_with_current_task_locals(proxy_data_between_l2cap_and_tcp(
+                l2cap_channel,
+                host,
+                port,
+            ));
+            Ok(())
+        },
+        args.max_credits,
+        args.mtu,
+        args.mps,
+    )?;
+
+    println!(
+        "{}",
+        format!("### Listening for CoC connection on PSM {}", args.psm).yellow()
+    );
+
+    device.on_connection(|_py, mut connection| {
+        let connection_info = connection
+            .debug_string()
+            .unwrap_or_else(|e| format!("failed to get connection info ({e})"));
+        println!(
+            "{} {}",
+            "@@@ Bluetooth connection: ".green(),
+            connection_info,
+        );
+        connection.on_disconnection(|_py, reason| {
+            let disconnection_info = match HciConstant::error_name(reason) {
+                Ok(info_string) => info_string,
+                Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
+            };
+            println!(
+                "{} {}",
+                "@@@ Bluetooth disconnection: ".red(),
+                disconnection_info,
+            );
+            Ok(())
+        })?;
+        Ok(())
+    })?;
+
+    device.start_advertising(false).await?;
+
+    Ok(())
+}
+
+async fn proxy_data_between_l2cap_and_tcp(
+    mut l2cap_channel: LeConnectionOrientedChannel,
+    tcp_host: String,
+    tcp_port: u16,
+) -> PyResult<()> {
+    let (l2cap_to_tcp_tx, mut l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
+
+    // Set callback (`set_sink`) for when l2cap data is received.
+    let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
+    l2cap_channel
+        .set_sink(move |_py, sdu| {
+            block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
+                .expect("failed to channel data to tcp");
+            Ok(())
+        })
+        .expect("failed to set sink for l2cap connection");
+
+    // Set l2cap callback for when the channel is closed.
+    l2cap_channel
+        .on_close(move |_py| {
+            println!("{}", "*** L2CAP channel closed".red());
+            block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
+                .expect("failed to channel close signal to tcp");
+            Ok(())
+        })
+        .expect("failed to set on_close callback for l2cap channel");
+
+    println!(
+        "{}",
+        format!("### Connecting to TCP {tcp_host}:{tcp_port}...").yellow()
+    );
+
+    let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
+    let tcp_stream = match TcpStream::connect(format!("{tcp_host}:{tcp_port}")).await {
+        Ok(stream) => {
+            println!("{}", "### Connected".green());
+            Some(stream)
+        }
+        Err(err) => {
+            println!("{}", format!("!!! Connection failed: {err}").red());
+            if let Some(mut channel) = l2cap_channel.lock().await.take() {
+                // Bumble might enter an invalid state if disconnection request is received from
+                // l2cap client before receiving a disconnection response from the same client,
+                // blocking this async call from returning.
+                // See: https://github.com/google/bumble/issues/257
+                select! {
+                    res = channel.disconnect() => {
+                        let _ = res.map_err(|e| eprintln!("Failed to call disconnect on l2cap channel: {e}"));
+                    },
+                    _ = tokio::time::sleep(Duration::from_secs(1)) => eprintln!("Timed out while calling disconnect on l2cap channel."),
+                }
+            }
+            None
+        }
+    };
+
+    match tcp_stream {
+        None => {
+            while let Some(bridge_data) = l2cap_to_tcp_rx.recv().await {
+                match bridge_data {
+                    BridgeData::Data(sdu) => {
+                        println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
+                        println!("{}", "!!! TCP socket not open, dropping".red())
+                    }
+                    BridgeData::CloseSignal => break,
+                }
+            }
+        }
+        Some(tcp_stream) => {
+            let (tcp_reader, tcp_writer) = tcp_stream.into_split();
+
+            // Do tcp stuff when something happens on the l2cap channel.
+            let handle_l2cap_data_future =
+                proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
+
+            // Do l2cap stuff when something happens on tcp.
+            let handle_tcp_data_future =
+                proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), false);
+
+            let (handle_l2cap_result, handle_tcp_result) =
+                join!(handle_l2cap_data_future, handle_tcp_data_future);
+
+            if let Err(e) = handle_l2cap_result {
+                println!("!!! Error: {e}");
+            }
+
+            if let Err(e) = handle_tcp_result {
+                println!("!!! Error: {e}");
+            }
+        }
+    };
+
+    Python::with_gil(|_| {
+        // Must hold GIL at least once while/after dropping for Python heap object to ensure
+        // de-allocation.
+        drop(l2cap_channel);
+    });
+
+    Ok(())
+}
diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs
new file mode 100644
index 0000000..e58f88c
--- /dev/null
+++ b/rust/src/cli/mod.rs
@@ -0,0 +1,19 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+pub(crate) mod firmware;
+
+pub(crate) mod usb;
+
+pub(crate) mod l2cap;
diff --git a/rust/src/cli/usb/mod.rs b/rust/src/cli/usb/mod.rs
new file mode 100644
index 0000000..7adbd75
--- /dev/null
+++ b/rust/src/cli/usb/mod.rs
@@ -0,0 +1,330 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Rust version of the Python `usb_probe.py`.
+//!
+//! This tool lists all the USB devices, with details about each device.
+//! For each device, the different possible Bumble transport strings that can
+//! refer to it are listed. If the device is known to be a Bluetooth HCI device,
+//! its identifier is printed in reverse colors, and the transport names in cyan color.
+//! For other devices, regardless of their type, the transport names are printed
+//! in red. Whether that device is actually a Bluetooth device or not depends on
+//! whether it is a Bluetooth device that uses a non-standard Class, or some other
+//! type of device (there's no way to tell).
+
+use itertools::Itertools as _;
+use owo_colors::{OwoColorize, Style};
+use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
+use std::{
+    collections::{HashMap, HashSet},
+    time::Duration,
+};
+const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
+const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
+const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
+const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
+
+pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
+    let mut bt_dev_count = 0;
+    let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
+    for device in rusb::devices()?.iter() {
+        let device_desc = device.device_descriptor().unwrap();
+
+        let class_info = ClassInfo::from(&device_desc);
+        let handle = device.open()?;
+        let timeout = Duration::from_secs(1);
+        // some devices don't have languages
+        let lang = handle
+            .read_languages(timeout)
+            .ok()
+            .and_then(|langs| langs.into_iter().next());
+        let serial = lang.and_then(|l| {
+            handle
+                .read_serial_number_string(l, &device_desc, timeout)
+                .ok()
+        });
+        let mfg = lang.and_then(|l| {
+            handle
+                .read_manufacturer_string(l, &device_desc, timeout)
+                .ok()
+        });
+        let product = lang.and_then(|l| handle.read_product_string(l, &device_desc, timeout).ok());
+
+        let is_hci = is_bluetooth_hci(&device, &device_desc)?;
+        let addr_style = if is_hci {
+            bt_dev_count += 1;
+            Style::new().black().on_yellow()
+        } else {
+            Style::new().yellow().on_black()
+        };
+
+        let mut transport_names = Vec::new();
+        let basic_transport_name = format!(
+            "usb:{:04X}:{:04X}",
+            device_desc.vendor_id(),
+            device_desc.product_id()
+        );
+
+        if is_hci {
+            transport_names.push(format!("usb:{}", bt_dev_count - 1));
+        }
+
+        let device_id = (device_desc.vendor_id(), device_desc.product_id());
+        if !device_serials_by_id.contains_key(&device_id) {
+            transport_names.push(basic_transport_name.clone());
+        } else {
+            transport_names.push(format!(
+                "{}#{}",
+                basic_transport_name,
+                device_serials_by_id
+                    .get(&device_id)
+                    .map(|serials| serials.len())
+                    .unwrap_or(0)
+            ))
+        }
+
+        if let Some(s) = &serial {
+            if !device_serials_by_id
+                .get(&device_id)
+                .map(|serials| serials.contains(s))
+                .unwrap_or(false)
+            {
+                transport_names.push(format!("{}/{}", basic_transport_name, s))
+            }
+        }
+
+        println!(
+            "{}",
+            format!(
+                "ID {:04X}:{:04X}",
+                device_desc.vendor_id(),
+                device_desc.product_id()
+            )
+            .style(addr_style)
+        );
+        if !transport_names.is_empty() {
+            let style = if is_hci {
+                Style::new().cyan()
+            } else {
+                Style::new().red()
+            };
+            println!(
+                "{:26}{}",
+                "  Bumble Transport Names:".blue(),
+                transport_names.iter().map(|n| n.style(style)).join(" or ")
+            )
+        }
+        println!(
+            "{:26}{:03}/{:03}",
+            "  Bus/Device:".green(),
+            device.bus_number(),
+            device.address()
+        );
+        println!(
+            "{:26}{}",
+            "  Class:".green(),
+            class_info.formatted_class_name()
+        );
+        println!(
+            "{:26}{}",
+            "  Subclass/Protocol:".green(),
+            class_info.formatted_subclass_protocol()
+        );
+        if let Some(s) = serial {
+            println!("{:26}{}", "  Serial:".green(), s);
+            device_serials_by_id
+                .entry(device_id)
+                .or_insert(HashSet::new())
+                .insert(s);
+        }
+        if let Some(m) = mfg {
+            println!("{:26}{}", "  Manufacturer:".green(), m);
+        }
+        if let Some(p) = product {
+            println!("{:26}{}", "  Product:".green(), p);
+        }
+
+        if verbose {
+            print_device_details(&device, &device_desc)?;
+        }
+
+        println!();
+    }
+
+    Ok(())
+}
+
+fn is_bluetooth_hci<T: UsbContext>(
+    device: &Device<T>,
+    device_desc: &DeviceDescriptor,
+) -> rusb::Result<bool> {
+    if device_desc.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER
+        && device_desc.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER
+        && device_desc.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+    {
+        Ok(true)
+    } else if device_desc.class_code() == USB_DEVICE_CLASS_DEVICE {
+        for i in 0..device_desc.num_configurations() {
+            for interface in device.config_descriptor(i)?.interfaces() {
+                for d in interface.descriptors() {
+                    if d.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER
+                        && d.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER
+                        && d.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+                    {
+                        return Ok(true);
+                    }
+                }
+            }
+        }
+
+        Ok(false)
+    } else {
+        Ok(false)
+    }
+}
+
+fn print_device_details<T: UsbContext>(
+    device: &Device<T>,
+    device_desc: &DeviceDescriptor,
+) -> anyhow::Result<()> {
+    for i in 0..device_desc.num_configurations() {
+        println!("  Configuration {}", i + 1);
+        for interface in device.config_descriptor(i)?.interfaces() {
+            let interface_descriptors: Vec<_> = interface.descriptors().collect();
+            for d in &interface_descriptors {
+                let class_info =
+                    ClassInfo::new(d.class_code(), d.sub_class_code(), d.protocol_code());
+
+                println!(
+                    "      Interface: {}{} ({}, {})",
+                    interface.number(),
+                    if interface_descriptors.len() > 1 {
+                        format!("/{}", d.setting_number())
+                    } else {
+                        String::new()
+                    },
+                    class_info.formatted_class_name(),
+                    class_info.formatted_subclass_protocol()
+                );
+
+                for e in d.endpoint_descriptors() {
+                    println!(
+                        "        Endpoint {:#04X}: {} {}",
+                        e.address(),
+                        match e.transfer_type() {
+                            TransferType::Control => "CONTROL",
+                            TransferType::Isochronous => "ISOCHRONOUS",
+                            TransferType::Bulk => "BULK",
+                            TransferType::Interrupt => "INTERRUPT",
+                        },
+                        match e.direction() {
+                            Direction::In => "IN",
+                            Direction::Out => "OUT",
+                        }
+                    )
+                }
+            }
+        }
+    }
+
+    Ok(())
+}
+
+struct ClassInfo {
+    class: u8,
+    sub_class: u8,
+    protocol: u8,
+}
+
+impl ClassInfo {
+    fn new(class: u8, sub_class: u8, protocol: u8) -> Self {
+        Self {
+            class,
+            sub_class,
+            protocol,
+        }
+    }
+
+    fn class_name(&self) -> Option<&str> {
+        match self.class {
+            0x00 => Some("Device"),
+            0x01 => Some("Audio"),
+            0x02 => Some("Communications and CDC Control"),
+            0x03 => Some("Human Interface Device"),
+            0x05 => Some("Physical"),
+            0x06 => Some("Still Imaging"),
+            0x07 => Some("Printer"),
+            0x08 => Some("Mass Storage"),
+            0x09 => Some("Hub"),
+            0x0A => Some("CDC Data"),
+            0x0B => Some("Smart Card"),
+            0x0D => Some("Content Security"),
+            0x0E => Some("Video"),
+            0x0F => Some("Personal Healthcare"),
+            0x10 => Some("Audio/Video"),
+            0x11 => Some("Billboard"),
+            0x12 => Some("USB Type-C Bridge"),
+            0x3C => Some("I3C"),
+            0xDC => Some("Diagnostic"),
+            USB_DEVICE_CLASS_WIRELESS_CONTROLLER => Some("Wireless Controller"),
+            0xEF => Some("Miscellaneous"),
+            0xFE => Some("Application Specific"),
+            0xFF => Some("Vendor Specific"),
+            _ => None,
+        }
+    }
+
+    fn protocol_name(&self) -> Option<&str> {
+        match self.class {
+            USB_DEVICE_CLASS_WIRELESS_CONTROLLER => match self.sub_class {
+                0x01 => match self.protocol {
+                    0x01 => Some("Bluetooth"),
+                    0x02 => Some("UWB"),
+                    0x03 => Some("Remote NDIS"),
+                    0x04 => Some("Bluetooth AMP"),
+                    _ => None,
+                },
+                _ => None,
+            },
+            _ => None,
+        }
+    }
+
+    fn formatted_class_name(&self) -> String {
+        self.class_name()
+            .map(|s| s.to_string())
+            .unwrap_or_else(|| format!("{:#04X}", self.class))
+    }
+
+    fn formatted_subclass_protocol(&self) -> String {
+        format!(
+            "{}/{}{}",
+            self.sub_class,
+            self.protocol,
+            self.protocol_name()
+                .map(|s| format!(" [{}]", s))
+                .unwrap_or_else(String::new)
+        )
+    }
+}
+
+impl From<&DeviceDescriptor> for ClassInfo {
+    fn from(value: &DeviceDescriptor) -> Self {
+        Self::new(
+            value.class_code(),
+            value.sub_class_code(),
+            value.protocol_code(),
+        )
+    }
+}
diff --git a/rust/src/internal/drivers/mod.rs b/rust/src/internal/drivers/mod.rs
new file mode 100644
index 0000000..5e72c59
--- /dev/null
+++ b/rust/src/internal/drivers/mod.rs
@@ -0,0 +1,17 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Device drivers
+
+pub(crate) mod rtk;
diff --git a/rust/src/internal/drivers/rtk.rs b/rust/src/internal/drivers/rtk.rs
new file mode 100644
index 0000000..2d4e685
--- /dev/null
+++ b/rust/src/internal/drivers/rtk.rs
@@ -0,0 +1,253 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Drivers for Realtek controllers
+
+use nom::{bytes, combinator, error, multi, number, sequence};
+
+/// Realtek firmware file contents
+pub struct Firmware {
+    version: u32,
+    project_id: u8,
+    patches: Vec<Patch>,
+}
+
+impl Firmware {
+    /// Parse a `*_fw.bin` file
+    pub fn parse(input: &[u8]) -> Result<Self, nom::Err<error::Error<&[u8]>>> {
+        let extension_sig = [0x51, 0x04, 0xFD, 0x77];
+
+        let (_rem, (_tag, fw_version, patch_count, payload)) =
+            combinator::all_consuming(combinator::map_parser(
+                // ignore the sig suffix
+                sequence::terminated(
+                    bytes::complete::take(
+                        // underflow will show up as parse failure
+                        input.len().saturating_sub(extension_sig.len()),
+                    ),
+                    bytes::complete::tag(extension_sig.as_slice()),
+                ),
+                sequence::tuple((
+                    bytes::complete::tag(b"Realtech"),
+                    // version
+                    number::complete::le_u32,
+                    // patch count
+                    combinator::map(number::complete::le_u16, |c| c as usize),
+                    // everything else except suffix
+                    combinator::rest,
+                )),
+            ))(input)?;
+
+        // ignore remaining input, since patch offsets are relative to the complete input
+        let (_rem, (chip_ids, patch_lengths, patch_offsets)) = sequence::tuple((
+            // chip id
+            multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
+            // patch length
+            multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
+            // patch offset
+            multi::many_m_n(patch_count, patch_count, number::complete::le_u32),
+        ))(payload)?;
+
+        let patches = chip_ids
+            .into_iter()
+            .zip(patch_lengths.into_iter())
+            .zip(patch_offsets.into_iter())
+            .map(|((chip_id, patch_length), patch_offset)| {
+                combinator::map(
+                    sequence::preceded(
+                        bytes::complete::take(patch_offset),
+                        // ignore trailing 4-byte suffix
+                        sequence::terminated(
+                            // patch including svn version, but not suffix
+                            combinator::consumed(sequence::preceded(
+                                // patch before svn version or version suffix
+                                // prefix length underflow will show up as parse failure
+                                bytes::complete::take(patch_length.saturating_sub(8)),
+                                // svn version
+                                number::complete::le_u32,
+                            )),
+                            // dummy suffix, overwritten with firmware version
+                            bytes::complete::take(4_usize),
+                        ),
+                    ),
+                    |(patch_contents_before_version, svn_version): (&[u8], u32)| {
+                        let mut contents = patch_contents_before_version.to_vec();
+                        // replace what would have been the trailing dummy suffix with fw version
+                        contents.extend_from_slice(&fw_version.to_le_bytes());
+
+                        Patch {
+                            contents,
+                            svn_version,
+                            chip_id,
+                        }
+                    },
+                )(input)
+                .map(|(_rem, output)| output)
+            })
+            .collect::<Result<Vec<_>, _>>()?;
+
+        // look for project id from the end
+        let mut offset = payload.len();
+        let mut project_id: Option<u8> = None;
+        while offset >= 2 {
+            // Won't panic, since offset >= 2
+            let chunk = &payload[offset - 2..offset];
+            let length: usize = chunk[0].into();
+            let opcode = chunk[1];
+            offset -= 2;
+
+            if opcode == 0xFF {
+                break;
+            }
+            if length == 0 {
+                // report what nom likely would have done, if nom was good at parsing backwards
+                return Err(nom::Err::Error(error::Error::new(
+                    chunk,
+                    error::ErrorKind::Verify,
+                )));
+            }
+            if opcode == 0 && length == 1 {
+                project_id = offset
+                    .checked_sub(1)
+                    .and_then(|index| payload.get(index))
+                    .copied();
+                break;
+            }
+
+            offset -= length;
+        }
+
+        match project_id {
+            Some(project_id) => Ok(Firmware {
+                project_id,
+                version: fw_version,
+                patches,
+            }),
+            None => {
+                // we ran out of file without finding a project id
+                Err(nom::Err::Error(error::Error::new(
+                    payload,
+                    error::ErrorKind::Eof,
+                )))
+            }
+        }
+    }
+
+    /// Patch version
+    pub fn version(&self) -> u32 {
+        self.version
+    }
+
+    /// Project id
+    pub fn project_id(&self) -> u8 {
+        self.project_id
+    }
+
+    /// Patches
+    pub fn patches(&self) -> &[Patch] {
+        &self.patches
+    }
+}
+
+/// Patch in a [Firmware}
+pub struct Patch {
+    chip_id: u16,
+    contents: Vec<u8>,
+    svn_version: u32,
+}
+
+impl Patch {
+    /// Chip id
+    pub fn chip_id(&self) -> u16 {
+        self.chip_id
+    }
+    /// Contents of the patch, including the 4-byte firmware version suffix
+    pub fn contents(&self) -> &[u8] {
+        &self.contents
+    }
+    /// SVN version
+    pub fn svn_version(&self) -> u32 {
+        self.svn_version
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use anyhow::anyhow;
+    use std::{fs, io, path};
+
+    #[test]
+    fn parse_firmware_rtl8723b() -> anyhow::Result<()> {
+        let fw = Firmware::parse(&firmware_contents("rtl8723b_fw_structure.bin")?)
+            .map_err(|e| anyhow!("{:?}", e))?;
+
+        let fw_version = 0x0E2F9F73;
+        assert_eq!(fw_version, fw.version());
+        assert_eq!(0x0001, fw.project_id());
+        assert_eq!(
+            vec![(0x0001, 0x00002BBF, 22368,), (0x0002, 0x00002BBF, 22496,),],
+            patch_summaries(fw, fw_version)
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn parse_firmware_rtl8761bu() -> anyhow::Result<()> {
+        let fw = Firmware::parse(&firmware_contents("rtl8761bu_fw_structure.bin")?)
+            .map_err(|e| anyhow!("{:?}", e))?;
+
+        let fw_version = 0xDFC6D922;
+        assert_eq!(fw_version, fw.version());
+        assert_eq!(0x000E, fw.project_id());
+        assert_eq!(
+            vec![(0x0001, 0x00005060, 14048,), (0x0002, 0xD6D525A4, 30204,),],
+            patch_summaries(fw, fw_version)
+        );
+
+        Ok(())
+    }
+
+    fn firmware_contents(filename: &str) -> io::Result<Vec<u8>> {
+        fs::read(
+            path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+                .join("resources/test/firmware/realtek")
+                .join(filename),
+        )
+    }
+
+    /// Return a tuple of (chip id, svn version, contents len, contents sha256)
+    fn patch_summaries(fw: Firmware, fw_version: u32) -> Vec<(u16, u32, usize)> {
+        fw.patches()
+            .iter()
+            .map(|p| {
+                let contents = p.contents();
+                let mut dummy_contents = dummy_contents(contents.len());
+                dummy_contents.extend_from_slice(&p.svn_version().to_le_bytes());
+                dummy_contents.extend_from_slice(&fw_version.to_le_bytes());
+                assert_eq!(&dummy_contents, contents);
+                (p.chip_id(), p.svn_version(), contents.len())
+            })
+            .collect::<Vec<_>>()
+    }
+
+    fn dummy_contents(len: usize) -> Vec<u8> {
+        let mut vec = (len as u32).to_le_bytes().as_slice().repeat(len / 4 + 1);
+        assert!(vec.len() >= len);
+        // leave room for svn version and firmware version
+        vec.truncate(len - 8);
+        vec
+    }
+}
diff --git a/rust/src/internal/mod.rs b/rust/src/internal/mod.rs
new file mode 100644
index 0000000..f474c2d
--- /dev/null
+++ b/rust/src/internal/mod.rs
@@ -0,0 +1,20 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! It's not clear where to put Rust code that isn't simply a wrapper around Python. Until we have
+//! a good answer for what to do there, the idea is to put it in this (non-public) module, and
+//! `pub use` it into the relevant areas of the `wrapper` module so that it's still easy for users
+//! to discover.
+
+pub(crate) mod drivers;
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
new file mode 100644
index 0000000..2bcb398
--- /dev/null
+++ b/rust/src/lib.rs
@@ -0,0 +1,33 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Rust API for [Bumble](https://github.com/google/bumble).
+//!
+//! Bumble is a userspace Bluetooth stack that works with more or less anything that uses HCI. This
+//! could be physical Bluetooth USB dongles, netsim, HCI proxied over a network from some device
+//! elsewhere, etc.
+//!
+//! It also does not restrict what you can do with Bluetooth the way that OS Bluetooth APIs
+//! typically do, making it good for prototyping, experimentation, test tools, etc.
+//!
+//! Bumble is primarily written in Python. Rust types that wrap the Python API, which is currently
+//! the bulk of the code, are in the [wrapper] module.
+
+#![deny(missing_docs, unsafe_code)]
+
+pub mod wrapper;
+
+pub mod adv;
+
+pub(crate) mod internal;
diff --git a/rust/src/main.rs b/rust/src/main.rs
new file mode 100644
index 0000000..c21f4c8
--- /dev/null
+++ b/rust/src/main.rs
@@ -0,0 +1,271 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! CLI tools for Bumble
+
+#![deny(missing_docs, unsafe_code)]
+
+use bumble::wrapper::logging::{bumble_env_logging_level, py_logging_basic_config};
+use clap::Parser as _;
+use pyo3::PyResult;
+use std::{fmt, path};
+
+mod cli;
+
+#[pyo3_asyncio::tokio::main]
+async fn main() -> PyResult<()> {
+    env_logger::builder()
+        .filter_level(log::LevelFilter::Info)
+        .init();
+
+    py_logging_basic_config(bumble_env_logging_level("INFO"))?;
+
+    let cli: Cli = Cli::parse();
+
+    match cli.subcommand {
+        Subcommand::Firmware { subcommand: fw } => match fw {
+            Firmware::Realtek { subcommand: rtk } => match rtk {
+                Realtek::Download(dl) => {
+                    cli::firmware::rtk::download(dl).await?;
+                }
+                Realtek::Drop { transport } => cli::firmware::rtk::drop(&transport).await?,
+                Realtek::Info { transport, force } => {
+                    cli::firmware::rtk::info(&transport, force).await?;
+                }
+                Realtek::Load { transport, force } => {
+                    cli::firmware::rtk::load(&transport, force).await?
+                }
+                Realtek::Parse { firmware_path } => cli::firmware::rtk::parse(&firmware_path)?,
+            },
+        },
+        Subcommand::L2cap {
+            subcommand,
+            device_config,
+            transport,
+            psm,
+            l2cap_coc_max_credits,
+            l2cap_coc_mtu,
+            l2cap_coc_mps,
+        } => {
+            cli::l2cap::run(
+                subcommand,
+                device_config,
+                transport,
+                psm,
+                l2cap_coc_max_credits,
+                l2cap_coc_mtu,
+                l2cap_coc_mps,
+            )
+            .await?
+        }
+        Subcommand::Usb { subcommand } => match subcommand {
+            Usb::Probe(probe) => cli::usb::probe(probe.verbose)?,
+        },
+    }
+
+    Ok(())
+}
+
+#[derive(clap::Parser)]
+struct Cli {
+    #[clap(subcommand)]
+    subcommand: Subcommand,
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum Subcommand {
+    /// Manage device firmware
+    Firmware {
+        #[clap(subcommand)]
+        subcommand: Firmware,
+    },
+    /// L2cap client/server operations
+    L2cap {
+        #[command(subcommand)]
+        subcommand: L2cap,
+
+        /// Device configuration file.
+        ///
+        /// See, for instance, `examples/device1.json` in the Python project.
+        #[arg(long)]
+        device_config: path::PathBuf,
+        /// Bumble transport spec.
+        ///
+        /// <https://google.github.io/bumble/transports/index.html>
+        #[arg(long)]
+        transport: String,
+
+        /// PSM for L2CAP Connection-oriented Channel.
+        ///
+        /// Must be in the range [0, 65535].
+        #[arg(long)]
+        psm: u16,
+
+        /// Maximum L2CAP CoC Credits. When not specified, lets Bumble set the default.
+        ///
+        /// Must be in the range [1, 65535].
+        #[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
+        l2cap_coc_max_credits: Option<u16>,
+
+        /// L2CAP CoC MTU. When not specified, lets Bumble set the default.
+        ///
+        /// Must be in the range [23, 65535].
+        #[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
+        l2cap_coc_mtu: Option<u16>,
+
+        /// L2CAP CoC MPS. When not specified, lets Bumble set the default.
+        ///
+        /// Must be in the range [23, 65535].
+        #[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
+        l2cap_coc_mps: Option<u16>,
+    },
+    /// USB operations
+    Usb {
+        #[clap(subcommand)]
+        subcommand: Usb,
+    },
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum Firmware {
+    /// Manage Realtek chipset firmware
+    Realtek {
+        #[clap(subcommand)]
+        subcommand: Realtek,
+    },
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+
+enum Realtek {
+    /// Download Realtek firmware
+    Download(Download),
+    /// Drop firmware from a USB device
+    Drop {
+        /// Bumble transport spec. Must be for a USB device.
+        ///
+        /// <https://google.github.io/bumble/transports/index.html>
+        #[arg(long)]
+        transport: String,
+    },
+    /// Show driver info for a USB device
+    Info {
+        /// Bumble transport spec. Must be for a USB device.
+        ///
+        /// <https://google.github.io/bumble/transports/index.html>
+        #[arg(long)]
+        transport: String,
+        /// Try to resolve driver info even if USB info is not available, or if the USB
+        /// (vendor,product) tuple is not in the list of known compatible RTK USB dongles.
+        #[arg(long, default_value_t = false)]
+        force: bool,
+    },
+    /// Load firmware onto a USB device
+    Load {
+        /// Bumble transport spec. Must be for a USB device.
+        ///
+        /// <https://google.github.io/bumble/transports/index.html>
+        #[arg(long)]
+        transport: String,
+        /// Load firmware even if the USB info doesn't match.
+        #[arg(long, default_value_t = false)]
+        force: bool,
+    },
+    /// Parse a firmware file
+    Parse {
+        /// Firmware file to parse
+        firmware_path: path::PathBuf,
+    },
+}
+
+#[derive(clap::Args, Debug, Clone)]
+struct Download {
+    /// Directory to download to. Defaults to an OS-specific path specific to the Bumble tool.
+    #[arg(long)]
+    output_dir: Option<path::PathBuf>,
+    /// Source to download from
+    #[arg(long, default_value_t = Source::LinuxKernel)]
+    source: Source,
+    /// Only download a single image
+    #[arg(long, value_name = "base name")]
+    single: Option<String>,
+    /// Overwrite existing files
+    #[arg(long, default_value_t = false)]
+    overwrite: bool,
+    /// Don't print the parse results for the downloaded file names
+    #[arg(long)]
+    no_parse: bool,
+}
+
+#[derive(Debug, Clone, clap::ValueEnum)]
+enum Source {
+    LinuxKernel,
+    RealtekOpensource,
+    LinuxFromScratch,
+}
+
+impl fmt::Display for Source {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Source::LinuxKernel => write!(f, "linux-kernel"),
+            Source::RealtekOpensource => write!(f, "realtek-opensource"),
+            Source::LinuxFromScratch => write!(f, "linux-from-scratch"),
+        }
+    }
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum L2cap {
+    /// Starts an L2CAP server
+    Server {
+        /// TCP host that the l2cap server will connect to.
+        /// Data is bridged like so:
+        ///     TCP server <-> (TCP client / **L2CAP server**) <-> (L2CAP client / TCP server) <-> TCP client
+        #[arg(long, default_value = "localhost")]
+        tcp_host: String,
+        /// TCP port that the server will connect to.
+        ///
+        /// Must be in the range [1, 65535].
+        #[arg(long, default_value_t = 9544)]
+        tcp_port: u16,
+    },
+    /// Starts an L2CAP client
+    Client {
+        /// L2cap server address that this l2cap client will connect to.
+        bluetooth_address: String,
+        /// TCP host that the l2cap client will bind to and listen for incoming TCP connections.
+        /// Data is bridged like so:
+        ///     TCP client <-> (TCP server / **L2CAP client**) <-> (L2CAP server / TCP client) <-> TCP server
+        #[arg(long, default_value = "localhost")]
+        tcp_host: String,
+        /// TCP port that the client will connect to.
+        ///
+        /// Must be in the range [1, 65535].
+        #[arg(long, default_value_t = 9543)]
+        tcp_port: u16,
+    },
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum Usb {
+    /// Probe the USB bus for Bluetooth devices
+    Probe(Probe),
+}
+
+#[derive(clap::Args, Debug, Clone)]
+struct Probe {
+    /// Show additional info for each USB device
+    #[arg(long, default_value_t = false)]
+    verbose: bool,
+}
diff --git a/rust/src/wrapper/assigned_numbers/company_ids.rs b/rust/src/wrapper/assigned_numbers/company_ids.rs
new file mode 100644
index 0000000..2eebcd5
--- /dev/null
+++ b/rust/src/wrapper/assigned_numbers/company_ids.rs
@@ -0,0 +1,2715 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// auto-generated by gen_assigned_numbers, do not edit
+
+use crate::wrapper::core::Uuid16;
+use lazy_static::lazy_static;
+use std::collections;
+
+lazy_static! {
+    /// Assigned company IDs
+    pub static ref COMPANY_IDS: collections::HashMap<Uuid16, &'static str> = [
+        (0_u16, r#"Ericsson Technology Licensing"#),
+        (1_u16, r#"Nokia Mobile Phones"#),
+        (2_u16, r#"Intel Corp."#),
+        (3_u16, r#"IBM Corp."#),
+        (4_u16, r#"Toshiba Corp."#),
+        (5_u16, r#"3Com"#),
+        (6_u16, r#"Microsoft"#),
+        (7_u16, r#"Lucent"#),
+        (8_u16, r#"Motorola"#),
+        (9_u16, r#"Infineon Technologies AG"#),
+        (10_u16, r#"Qualcomm Technologies International, Ltd. (QTIL)"#),
+        (11_u16, r#"Silicon Wave"#),
+        (12_u16, r#"Digianswer A/S"#),
+        (13_u16, r#"Texas Instruments Inc."#),
+        (14_u16, r#"Parthus Technologies Inc."#),
+        (15_u16, r#"Broadcom Corporation"#),
+        (16_u16, r#"Mitel Semiconductor"#),
+        (17_u16, r#"Widcomm, Inc."#),
+        (18_u16, r#"Zeevo, Inc."#),
+        (19_u16, r#"Atmel Corporation"#),
+        (20_u16, r#"Mitsubishi Electric Corporation"#),
+        (21_u16, r#"RTX Telecom A/S"#),
+        (22_u16, r#"KC Technology Inc."#),
+        (23_u16, r#"Newlogic"#),
+        (24_u16, r#"Transilica, Inc."#),
+        (25_u16, r#"Rohde & Schwarz GmbH & Co. KG"#),
+        (26_u16, r#"TTPCom Limited"#),
+        (27_u16, r#"Signia Technologies, Inc."#),
+        (28_u16, r#"Conexant Systems Inc."#),
+        (29_u16, r#"Qualcomm"#),
+        (30_u16, r#"Inventel"#),
+        (31_u16, r#"AVM Berlin"#),
+        (32_u16, r#"BandSpeed, Inc."#),
+        (33_u16, r#"Mansella Ltd"#),
+        (34_u16, r#"NEC Corporation"#),
+        (35_u16, r#"WavePlus Technology Co., Ltd."#),
+        (36_u16, r#"Alcatel"#),
+        (37_u16, r#"NXP Semiconductors (formerly Philips Semiconductors)"#),
+        (38_u16, r#"C Technologies"#),
+        (39_u16, r#"Open Interface"#),
+        (40_u16, r#"R F Micro Devices"#),
+        (41_u16, r#"Hitachi Ltd"#),
+        (42_u16, r#"Symbol Technologies, Inc."#),
+        (43_u16, r#"Tenovis"#),
+        (44_u16, r#"Macronix International Co. Ltd."#),
+        (45_u16, r#"GCT Semiconductor"#),
+        (46_u16, r#"Norwood Systems"#),
+        (47_u16, r#"MewTel Technology Inc."#),
+        (48_u16, r#"ST Microelectronics"#),
+        (49_u16, r#"Synopsys, Inc."#),
+        (50_u16, r#"Red-M (Communications) Ltd"#),
+        (51_u16, r#"Commil Ltd"#),
+        (52_u16, r#"Computer Access Technology Corporation (CATC)"#),
+        (53_u16, r#"Eclipse (HQ Espana) S.L."#),
+        (54_u16, r#"Renesas Electronics Corporation"#),
+        (55_u16, r#"Mobilian Corporation"#),
+        (56_u16, r#"Syntronix Corporation"#),
+        (57_u16, r#"Integrated System Solution Corp."#),
+        (58_u16, r#"Panasonic Corporation (formerly Matsushita Electric Industrial Co., Ltd.)"#),
+        (59_u16, r#"Gennum Corporation"#),
+        (60_u16, r#"BlackBerry Limited  (formerly Research In Motion)"#),
+        (61_u16, r#"IPextreme, Inc."#),
+        (62_u16, r#"Systems and Chips, Inc"#),
+        (63_u16, r#"Bluetooth SIG, Inc"#),
+        (64_u16, r#"Seiko Epson Corporation"#),
+        (65_u16, r#"Integrated Silicon Solution Taiwan, Inc."#),
+        (66_u16, r#"CONWISE Technology Corporation Ltd"#),
+        (67_u16, r#"PARROT AUTOMOTIVE SAS"#),
+        (68_u16, r#"Socket Mobile"#),
+        (69_u16, r#"Atheros Communications, Inc."#),
+        (70_u16, r#"MediaTek, Inc."#),
+        (71_u16, r#"Bluegiga"#),
+        (72_u16, r#"Marvell Technology Group Ltd."#),
+        (73_u16, r#"3DSP Corporation"#),
+        (74_u16, r#"Accel Semiconductor Ltd."#),
+        (75_u16, r#"Continental Automotive Systems"#),
+        (76_u16, r#"Apple, Inc."#),
+        (77_u16, r#"Staccato Communications, Inc."#),
+        (78_u16, r#"Avago Technologies"#),
+        (79_u16, r#"APT Ltd."#),
+        (80_u16, r#"SiRF Technology, Inc."#),
+        (81_u16, r#"Tzero Technologies, Inc."#),
+        (82_u16, r#"J&M Corporation"#),
+        (83_u16, r#"Free2move AB"#),
+        (84_u16, r#"3DiJoy Corporation"#),
+        (85_u16, r#"Plantronics, Inc."#),
+        (86_u16, r#"Sony Ericsson Mobile Communications"#),
+        (87_u16, r#"Harman International Industries, Inc."#),
+        (88_u16, r#"Vizio, Inc."#),
+        (89_u16, r#"Nordic Semiconductor ASA"#),
+        (90_u16, r#"EM Microelectronic-Marin SA"#),
+        (91_u16, r#"Ralink Technology Corporation"#),
+        (92_u16, r#"Belkin International, Inc."#),
+        (93_u16, r#"Realtek Semiconductor Corporation"#),
+        (94_u16, r#"Stonestreet One, LLC"#),
+        (95_u16, r#"Wicentric, Inc."#),
+        (96_u16, r#"RivieraWaves S.A.S"#),
+        (97_u16, r#"RDA Microelectronics"#),
+        (98_u16, r#"Gibson Guitars"#),
+        (99_u16, r#"MiCommand Inc."#),
+        (100_u16, r#"Band XI International, LLC"#),
+        (101_u16, r#"Hewlett-Packard Company"#),
+        (102_u16, r#"9Solutions Oy"#),
+        (103_u16, r#"GN Netcom A/S"#),
+        (104_u16, r#"General Motors"#),
+        (105_u16, r#"A&D Engineering, Inc."#),
+        (106_u16, r#"MindTree Ltd."#),
+        (107_u16, r#"Polar Electro OY"#),
+        (108_u16, r#"Beautiful Enterprise Co., Ltd."#),
+        (109_u16, r#"BriarTek, Inc"#),
+        (110_u16, r#"Summit Data Communications, Inc."#),
+        (111_u16, r#"Sound ID"#),
+        (112_u16, r#"Monster, LLC"#),
+        (113_u16, r#"connectBlue AB"#),
+        (114_u16, r#"ShangHai Super Smart Electronics Co. Ltd."#),
+        (115_u16, r#"Group Sense Ltd."#),
+        (116_u16, r#"Zomm, LLC"#),
+        (117_u16, r#"Samsung Electronics Co. Ltd."#),
+        (118_u16, r#"Creative Technology Ltd."#),
+        (119_u16, r#"Laird Technologies"#),
+        (120_u16, r#"Nike, Inc."#),
+        (121_u16, r#"lesswire AG"#),
+        (122_u16, r#"MStar Semiconductor, Inc."#),
+        (123_u16, r#"Hanlynn Technologies"#),
+        (124_u16, r#"A & R Cambridge"#),
+        (125_u16, r#"Seers Technology Co., Ltd."#),
+        (126_u16, r#"Sports Tracking Technologies Ltd."#),
+        (127_u16, r#"Autonet Mobile"#),
+        (128_u16, r#"DeLorme Publishing Company, Inc."#),
+        (129_u16, r#"WuXi Vimicro"#),
+        (130_u16, r#"Sennheiser Communications A/S"#),
+        (131_u16, r#"TimeKeeping Systems, Inc."#),
+        (132_u16, r#"Ludus Helsinki Ltd."#),
+        (133_u16, r#"BlueRadios, Inc."#),
+        (134_u16, r#"Equinux AG"#),
+        (135_u16, r#"Garmin International, Inc."#),
+        (136_u16, r#"Ecotest"#),
+        (137_u16, r#"GN ReSound A/S"#),
+        (138_u16, r#"Jawbone"#),
+        (139_u16, r#"Topcon Positioning Systems, LLC"#),
+        (140_u16, r#"Gimbal Inc. (formerly Qualcomm Labs, Inc. and Qualcomm Retail Solutions, Inc.)"#),
+        (141_u16, r#"Zscan Software"#),
+        (142_u16, r#"Quintic Corp"#),
+        (143_u16, r#"Telit Wireless Solutions GmbH (formerly Stollmann E+V GmbH)"#),
+        (144_u16, r#"Funai Electric Co., Ltd."#),
+        (145_u16, r#"Advanced PANMOBIL systems GmbH & Co. KG"#),
+        (146_u16, r#"ThinkOptics, Inc."#),
+        (147_u16, r#"Universal Electronics, Inc."#),
+        (148_u16, r#"Airoha Technology Corp."#),
+        (149_u16, r#"NEC Lighting, Ltd."#),
+        (150_u16, r#"ODM Technology, Inc."#),
+        (151_u16, r#"ConnecteDevice Ltd."#),
+        (152_u16, r#"zero1.tv GmbH"#),
+        (153_u16, r#"i.Tech Dynamic Global Distribution Ltd."#),
+        (154_u16, r#"Alpwise"#),
+        (155_u16, r#"Jiangsu Toppower Automotive Electronics Co., Ltd."#),
+        (156_u16, r#"Colorfy, Inc."#),
+        (157_u16, r#"Geoforce Inc."#),
+        (158_u16, r#"Bose Corporation"#),
+        (159_u16, r#"Suunto Oy"#),
+        (160_u16, r#"Kensington Computer Products Group"#),
+        (161_u16, r#"SR-Medizinelektronik"#),
+        (162_u16, r#"Vertu Corporation Limited"#),
+        (163_u16, r#"Meta Watch Ltd."#),
+        (164_u16, r#"LINAK A/S"#),
+        (165_u16, r#"OTL Dynamics LLC"#),
+        (166_u16, r#"Panda Ocean Inc."#),
+        (167_u16, r#"Visteon Corporation"#),
+        (168_u16, r#"ARP Devices Limited"#),
+        (169_u16, r#"MARELLI EUROPE S.P.A. (formerly Magneti Marelli S.p.A.)"#),
+        (170_u16, r#"CAEN RFID srl"#),
+        (171_u16, r#"Ingenieur-Systemgruppe Zahn GmbH"#),
+        (172_u16, r#"Green Throttle Games"#),
+        (173_u16, r#"Peter Systemtechnik GmbH"#),
+        (174_u16, r#"Omegawave Oy"#),
+        (175_u16, r#"Cinetix"#),
+        (176_u16, r#"Passif Semiconductor Corp"#),
+        (177_u16, r#"Saris Cycling Group, Inc"#),
+        (178_u16, r#"Bekey A/S"#),
+        (179_u16, r#"Clarinox Technologies Pty. Ltd."#),
+        (180_u16, r#"BDE Technology Co., Ltd."#),
+        (181_u16, r#"Swirl Networks"#),
+        (182_u16, r#"Meso international"#),
+        (183_u16, r#"TreLab Ltd"#),
+        (184_u16, r#"Qualcomm Innovation Center, Inc. (QuIC)"#),
+        (185_u16, r#"Johnson Controls, Inc."#),
+        (186_u16, r#"Starkey Laboratories Inc."#),
+        (187_u16, r#"S-Power Electronics Limited"#),
+        (188_u16, r#"Ace Sensor Inc"#),
+        (189_u16, r#"Aplix Corporation"#),
+        (190_u16, r#"AAMP of America"#),
+        (191_u16, r#"Stalmart Technology Limited"#),
+        (192_u16, r#"AMICCOM Electronics Corporation"#),
+        (193_u16, r#"Shenzhen Excelsecu Data Technology Co.,Ltd"#),
+        (194_u16, r#"Geneq Inc."#),
+        (195_u16, r#"adidas AG"#),
+        (196_u16, r#"LG Electronics"#),
+        (197_u16, r#"Onset Computer Corporation"#),
+        (198_u16, r#"Selfly BV"#),
+        (199_u16, r#"Quuppa Oy."#),
+        (200_u16, r#"GeLo Inc"#),
+        (201_u16, r#"Evluma"#),
+        (202_u16, r#"MC10"#),
+        (203_u16, r#"Binauric SE"#),
+        (204_u16, r#"Beats Electronics"#),
+        (205_u16, r#"Microchip Technology Inc."#),
+        (206_u16, r#"Elgato Systems GmbH"#),
+        (207_u16, r#"ARCHOS SA"#),
+        (208_u16, r#"Dexcom, Inc."#),
+        (209_u16, r#"Polar Electro Europe B.V."#),
+        (210_u16, r#"Dialog Semiconductor B.V."#),
+        (211_u16, r#"Taixingbang Technology (HK) Co,. LTD."#),
+        (212_u16, r#"Kawantech"#),
+        (213_u16, r#"Austco Communication Systems"#),
+        (214_u16, r#"Timex Group USA, Inc."#),
+        (215_u16, r#"Qualcomm Technologies, Inc."#),
+        (216_u16, r#"Qualcomm Connected Experiences, Inc."#),
+        (217_u16, r#"Voyetra Turtle Beach"#),
+        (218_u16, r#"txtr GmbH"#),
+        (219_u16, r#"Biosentronics"#),
+        (220_u16, r#"Procter & Gamble"#),
+        (221_u16, r#"Hosiden Corporation"#),
+        (222_u16, r#"Muzik LLC"#),
+        (223_u16, r#"Misfit Wearables Corp"#),
+        (224_u16, r#"Google"#),
+        (225_u16, r#"Danlers Ltd"#),
+        (226_u16, r#"Semilink Inc"#),
+        (227_u16, r#"inMusic Brands, Inc"#),
+        (228_u16, r#"Laird Connectivity, Inc. formerly L.S. Research Inc."#),
+        (229_u16, r#"Eden Software Consultants Ltd."#),
+        (230_u16, r#"Freshtemp"#),
+        (231_u16, r#"KS Technologies"#),
+        (232_u16, r#"ACTS Technologies"#),
+        (233_u16, r#"Vtrack Systems"#),
+        (234_u16, r#"Nielsen-Kellerman Company"#),
+        (235_u16, r#"Server Technology Inc."#),
+        (236_u16, r#"BioResearch Associates"#),
+        (237_u16, r#"Jolly Logic, LLC"#),
+        (238_u16, r#"Above Average Outcomes, Inc."#),
+        (239_u16, r#"Bitsplitters GmbH"#),
+        (240_u16, r#"PayPal, Inc."#),
+        (241_u16, r#"Witron Technology Limited"#),
+        (242_u16, r#"Morse Project Inc."#),
+        (243_u16, r#"Kent Displays Inc."#),
+        (244_u16, r#"Nautilus Inc."#),
+        (245_u16, r#"Smartifier Oy"#),
+        (246_u16, r#"Elcometer Limited"#),
+        (247_u16, r#"VSN Technologies, Inc."#),
+        (248_u16, r#"AceUni Corp., Ltd."#),
+        (249_u16, r#"StickNFind"#),
+        (250_u16, r#"Crystal Code AB"#),
+        (251_u16, r#"KOUKAAM a.s."#),
+        (252_u16, r#"Delphi Corporation"#),
+        (253_u16, r#"ValenceTech Limited"#),
+        (254_u16, r#"Stanley Black and Decker"#),
+        (255_u16, r#"Typo Products, LLC"#),
+        (256_u16, r#"TomTom International BV"#),
+        (257_u16, r#"Fugoo, Inc."#),
+        (258_u16, r#"Keiser Corporation"#),
+        (259_u16, r#"Bang & Olufsen A/S"#),
+        (260_u16, r#"PLUS Location Systems Pty Ltd"#),
+        (261_u16, r#"Ubiquitous Computing Technology Corporation"#),
+        (262_u16, r#"Innovative Yachtter Solutions"#),
+        (263_u16, r#"William Demant Holding A/S"#),
+        (264_u16, r#"Chicony Electronics Co., Ltd."#),
+        (265_u16, r#"Atus BV"#),
+        (266_u16, r#"Codegate Ltd"#),
+        (267_u16, r#"ERi, Inc"#),
+        (268_u16, r#"Transducers Direct, LLC"#),
+        (269_u16, r#"DENSO TEN LIMITED (formerly Fujitsu Ten LImited)"#),
+        (270_u16, r#"Audi AG"#),
+        (271_u16, r#"HiSilicon Technologies CO., LIMITED"#),
+        (272_u16, r#"Nippon Seiki Co., Ltd."#),
+        (273_u16, r#"Steelseries ApS"#),
+        (274_u16, r#"Visybl Inc."#),
+        (275_u16, r#"Openbrain Technologies, Co., Ltd."#),
+        (276_u16, r#"Xensr"#),
+        (277_u16, r#"e.solutions"#),
+        (278_u16, r#"10AK Technologies"#),
+        (279_u16, r#"Wimoto Technologies Inc"#),
+        (280_u16, r#"Radius Networks, Inc."#),
+        (281_u16, r#"Wize Technology Co., Ltd."#),
+        (282_u16, r#"Qualcomm Labs, Inc."#),
+        (283_u16, r#"Hewlett Packard Enterprise"#),
+        (284_u16, r#"Baidu"#),
+        (285_u16, r#"Arendi AG"#),
+        (286_u16, r#"Skoda Auto a.s."#),
+        (287_u16, r#"Volkswagen AG"#),
+        (288_u16, r#"Porsche AG"#),
+        (289_u16, r#"Sino Wealth Electronic Ltd."#),
+        (290_u16, r#"AirTurn, Inc."#),
+        (291_u16, r#"Kinsa, Inc"#),
+        (292_u16, r#"HID Global"#),
+        (293_u16, r#"SEAT es"#),
+        (294_u16, r#"Promethean Ltd."#),
+        (295_u16, r#"Salutica Allied Solutions"#),
+        (296_u16, r#"GPSI Group Pty Ltd"#),
+        (297_u16, r#"Nimble Devices Oy"#),
+        (298_u16, r#"Changzhou Yongse Infotech  Co., Ltd."#),
+        (299_u16, r#"SportIQ"#),
+        (300_u16, r#"TEMEC Instruments B.V."#),
+        (301_u16, r#"Sony Corporation"#),
+        (302_u16, r#"ASSA ABLOY"#),
+        (303_u16, r#"Clarion Co. Inc."#),
+        (304_u16, r#"Warehouse Innovations"#),
+        (305_u16, r#"Cypress Semiconductor"#),
+        (306_u16, r#"MADS Inc"#),
+        (307_u16, r#"Blue Maestro Limited"#),
+        (308_u16, r#"Resolution Products, Ltd."#),
+        (309_u16, r#"Aireware LLC"#),
+        (310_u16, r#"Silvair, Inc."#),
+        (311_u16, r#"Prestigio Plaza Ltd."#),
+        (312_u16, r#"NTEO Inc."#),
+        (313_u16, r#"Focus Systems Corporation"#),
+        (314_u16, r#"Tencent Holdings Ltd."#),
+        (315_u16, r#"Allegion"#),
+        (316_u16, r#"Murata Manufacturing Co., Ltd."#),
+        (317_u16, r#"WirelessWERX"#),
+        (318_u16, r#"Nod, Inc."#),
+        (319_u16, r#"B&B Manufacturing Company"#),
+        (320_u16, r#"Alpine Electronics (China) Co., Ltd"#),
+        (321_u16, r#"FedEx Services"#),
+        (322_u16, r#"Grape Systems Inc."#),
+        (323_u16, r#"Bkon Connect"#),
+        (324_u16, r#"Lintech GmbH"#),
+        (325_u16, r#"Novatel Wireless"#),
+        (326_u16, r#"Ciright"#),
+        (327_u16, r#"Mighty Cast, Inc."#),
+        (328_u16, r#"Ambimat Electronics"#),
+        (329_u16, r#"Perytons Ltd."#),
+        (330_u16, r#"Tivoli Audio, LLC"#),
+        (331_u16, r#"Master Lock"#),
+        (332_u16, r#"Mesh-Net Ltd"#),
+        (333_u16, r#"HUIZHOU DESAY SV AUTOMOTIVE CO., LTD."#),
+        (334_u16, r#"Tangerine, Inc."#),
+        (335_u16, r#"B&W Group Ltd."#),
+        (336_u16, r#"Pioneer Corporation"#),
+        (337_u16, r#"OnBeep"#),
+        (338_u16, r#"Vernier Software & Technology"#),
+        (339_u16, r#"ROL Ergo"#),
+        (340_u16, r#"Pebble Technology"#),
+        (341_u16, r#"NETATMO"#),
+        (342_u16, r#"Accumulate AB"#),
+        (343_u16, r#"Anhui Huami Information Technology Co., Ltd."#),
+        (344_u16, r#"Inmite s.r.o."#),
+        (345_u16, r#"ChefSteps, Inc."#),
+        (346_u16, r#"micas AG"#),
+        (347_u16, r#"Biomedical Research Ltd."#),
+        (348_u16, r#"Pitius Tec S.L."#),
+        (349_u16, r#"Estimote, Inc."#),
+        (350_u16, r#"Unikey Technologies, Inc."#),
+        (351_u16, r#"Timer Cap Co."#),
+        (352_u16, r#"Awox formerly AwoX"#),
+        (353_u16, r#"yikes"#),
+        (354_u16, r#"MADSGlobalNZ Ltd."#),
+        (355_u16, r#"PCH International"#),
+        (356_u16, r#"Qingdao Yeelink Information Technology Co., Ltd."#),
+        (357_u16, r#"Milwaukee Tool (Formally Milwaukee Electric Tools)"#),
+        (358_u16, r#"MISHIK Pte Ltd"#),
+        (359_u16, r#"Ascensia Diabetes Care US Inc."#),
+        (360_u16, r#"Spicebox LLC"#),
+        (361_u16, r#"emberlight"#),
+        (362_u16, r#"Cooper-Atkins Corporation"#),
+        (363_u16, r#"Qblinks"#),
+        (364_u16, r#"MYSPHERA"#),
+        (365_u16, r#"LifeScan Inc"#),
+        (366_u16, r#"Volantic AB"#),
+        (367_u16, r#"Podo Labs, Inc"#),
+        (368_u16, r#"Roche Diabetes Care AG"#),
+        (369_u16, r#"Amazon.com Services, LLC (formerly Amazon Fulfillment Service)"#),
+        (370_u16, r#"Connovate Technology Private Limited"#),
+        (371_u16, r#"Kocomojo, LLC"#),
+        (372_u16, r#"Everykey Inc."#),
+        (373_u16, r#"Dynamic Controls"#),
+        (374_u16, r#"SentriLock"#),
+        (375_u16, r#"I-SYST inc."#),
+        (376_u16, r#"CASIO COMPUTER CO., LTD."#),
+        (377_u16, r#"LAPIS Technology Co., Ltd. formerly LAPIS Semiconductor Co., Ltd."#),
+        (378_u16, r#"Telemonitor, Inc."#),
+        (379_u16, r#"taskit GmbH"#),
+        (380_u16, r#"Daimler AG"#),
+        (381_u16, r#"BatAndCat"#),
+        (382_u16, r#"BluDotz Ltd"#),
+        (383_u16, r#"XTel Wireless ApS"#),
+        (384_u16, r#"Gigaset Communications GmbH"#),
+        (385_u16, r#"Gecko Health Innovations, Inc."#),
+        (386_u16, r#"HOP Ubiquitous"#),
+        (387_u16, r#"Walt Disney"#),
+        (388_u16, r#"Nectar"#),
+        (389_u16, r#"bel'apps LLC"#),
+        (390_u16, r#"CORE Lighting Ltd"#),
+        (391_u16, r#"Seraphim Sense Ltd"#),
+        (392_u16, r#"Unico RBC"#),
+        (393_u16, r#"Physical Enterprises Inc."#),
+        (394_u16, r#"Able Trend Technology Limited"#),
+        (395_u16, r#"Konica Minolta, Inc."#),
+        (396_u16, r#"Wilo SE"#),
+        (397_u16, r#"Extron Design Services"#),
+        (398_u16, r#"Fitbit, Inc."#),
+        (399_u16, r#"Fireflies Systems"#),
+        (400_u16, r#"Intelletto Technologies Inc."#),
+        (401_u16, r#"FDK CORPORATION"#),
+        (402_u16, r#"Cloudleaf, Inc"#),
+        (403_u16, r#"Maveric Automation LLC"#),
+        (404_u16, r#"Acoustic Stream Corporation"#),
+        (405_u16, r#"Zuli"#),
+        (406_u16, r#"Paxton Access Ltd"#),
+        (407_u16, r#"WiSilica Inc."#),
+        (408_u16, r#"VENGIT Korlatolt Felelossegu Tarsasag"#),
+        (409_u16, r#"SALTO SYSTEMS S.L."#),
+        (410_u16, r#"TRON Forum (formerly T-Engine Forum)"#),
+        (411_u16, r#"CUBETECH s.r.o."#),
+        (412_u16, r#"Cokiya Incorporated"#),
+        (413_u16, r#"CVS Health"#),
+        (414_u16, r#"Ceruus"#),
+        (415_u16, r#"Strainstall Ltd"#),
+        (416_u16, r#"Channel Enterprises (HK) Ltd."#),
+        (417_u16, r#"FIAMM"#),
+        (418_u16, r#"GIGALANE.CO.,LTD"#),
+        (419_u16, r#"EROAD"#),
+        (420_u16, r#"Mine Safety Appliances"#),
+        (421_u16, r#"Icon Health and Fitness"#),
+        (422_u16, r#"Wille Engineering (formely as Asandoo GmbH)"#),
+        (423_u16, r#"ENERGOUS CORPORATION"#),
+        (424_u16, r#"Taobao"#),
+        (425_u16, r#"Canon Inc."#),
+        (426_u16, r#"Geophysical Technology Inc."#),
+        (427_u16, r#"Facebook, Inc."#),
+        (428_u16, r#"Trividia Health, Inc."#),
+        (429_u16, r#"FlightSafety International"#),
+        (430_u16, r#"Earlens Corporation"#),
+        (431_u16, r#"Sunrise Micro Devices, Inc."#),
+        (432_u16, r#"Star Micronics Co., Ltd."#),
+        (433_u16, r#"Netizens Sp. z o.o."#),
+        (434_u16, r#"Nymi Inc."#),
+        (435_u16, r#"Nytec, Inc."#),
+        (436_u16, r#"Trineo Sp. z o.o."#),
+        (437_u16, r#"Nest Labs Inc."#),
+        (438_u16, r#"LM Technologies Ltd"#),
+        (439_u16, r#"General Electric Company"#),
+        (440_u16, r#"i+D3 S.L."#),
+        (441_u16, r#"HANA Micron"#),
+        (442_u16, r#"Stages Cycling LLC"#),
+        (443_u16, r#"Cochlear Bone Anchored Solutions AB"#),
+        (444_u16, r#"SenionLab AB"#),
+        (445_u16, r#"Syszone Co., Ltd"#),
+        (446_u16, r#"Pulsate Mobile Ltd."#),
+        (447_u16, r#"Hong Kong HunterSun Electronic Limited"#),
+        (448_u16, r#"pironex GmbH"#),
+        (449_u16, r#"BRADATECH Corp."#),
+        (450_u16, r#"Transenergooil AG"#),
+        (451_u16, r#"Bunch"#),
+        (452_u16, r#"DME Microelectronics"#),
+        (453_u16, r#"Bitcraze AB"#),
+        (454_u16, r#"HASWARE Inc."#),
+        (455_u16, r#"Abiogenix Inc."#),
+        (456_u16, r#"Poly-Control ApS"#),
+        (457_u16, r#"Avi-on"#),
+        (458_u16, r#"Laerdal Medical AS"#),
+        (459_u16, r#"Fetch My Pet"#),
+        (460_u16, r#"Sam Labs Ltd."#),
+        (461_u16, r#"Chengdu Synwing Technology Ltd"#),
+        (462_u16, r#"HOUWA SYSTEM DESIGN, k.k."#),
+        (463_u16, r#"BSH"#),
+        (464_u16, r#"Primus Inter Pares Ltd"#),
+        (465_u16, r#"August Home, Inc"#),
+        (466_u16, r#"Gill Electronics"#),
+        (467_u16, r#"Sky Wave Design"#),
+        (468_u16, r#"Newlab S.r.l."#),
+        (469_u16, r#"ELAD srl"#),
+        (470_u16, r#"G-wearables inc."#),
+        (471_u16, r#"Squadrone Systems Inc."#),
+        (472_u16, r#"Code Corporation"#),
+        (473_u16, r#"Savant Systems LLC"#),
+        (474_u16, r#"Logitech International SA"#),
+        (475_u16, r#"Innblue Consulting"#),
+        (476_u16, r#"iParking Ltd."#),
+        (477_u16, r#"Koninklijke Philips Electronics N.V."#),
+        (478_u16, r#"Minelab Electronics Pty Limited"#),
+        (479_u16, r#"Bison Group Ltd."#),
+        (480_u16, r#"Widex A/S"#),
+        (481_u16, r#"Jolla Ltd"#),
+        (482_u16, r#"Lectronix, Inc."#),
+        (483_u16, r#"Caterpillar Inc"#),
+        (484_u16, r#"Freedom Innovations"#),
+        (485_u16, r#"Dynamic Devices Ltd"#),
+        (486_u16, r#"Technology Solutions (UK) Ltd"#),
+        (487_u16, r#"IPS Group Inc."#),
+        (488_u16, r#"STIR"#),
+        (489_u16, r#"Sano, Inc."#),
+        (490_u16, r#"Advanced Application Design, Inc."#),
+        (491_u16, r#"AutoMap LLC"#),
+        (492_u16, r#"Spreadtrum Communications Shanghai Ltd"#),
+        (493_u16, r#"CuteCircuit LTD"#),
+        (494_u16, r#"Valeo Service"#),
+        (495_u16, r#"Fullpower Technologies, Inc."#),
+        (496_u16, r#"KloudNation"#),
+        (497_u16, r#"Zebra Technologies Corporation"#),
+        (498_u16, r#"Itron, Inc."#),
+        (499_u16, r#"The University of Tokyo"#),
+        (500_u16, r#"UTC Fire and Security"#),
+        (501_u16, r#"Cool Webthings Limited"#),
+        (502_u16, r#"DJO Global"#),
+        (503_u16, r#"Gelliner Limited"#),
+        (504_u16, r#"Anyka (Guangzhou) Microelectronics Technology Co, LTD"#),
+        (505_u16, r#"Medtronic Inc."#),
+        (506_u16, r#"Gozio Inc."#),
+        (507_u16, r#"Form Lifting, LLC"#),
+        (508_u16, r#"Wahoo Fitness, LLC"#),
+        (509_u16, r#"Kontakt Micro-Location Sp. z o.o."#),
+        (510_u16, r#"Radio Systems Corporation"#),
+        (511_u16, r#"Freescale Semiconductor, Inc."#),
+        (512_u16, r#"Verifone Systems Pte Ltd. Taiwan Branch"#),
+        (513_u16, r#"AR Timing"#),
+        (514_u16, r#"Rigado LLC"#),
+        (515_u16, r#"Kemppi Oy"#),
+        (516_u16, r#"Tapcentive Inc."#),
+        (517_u16, r#"Smartbotics Inc."#),
+        (518_u16, r#"Otter Products, LLC"#),
+        (519_u16, r#"STEMP Inc."#),
+        (520_u16, r#"LumiGeek LLC"#),
+        (521_u16, r#"InvisionHeart Inc."#),
+        (522_u16, r#"Macnica Inc."#),
+        (523_u16, r#"Jaguar Land Rover Limited"#),
+        (524_u16, r#"CoroWare Technologies, Inc"#),
+        (525_u16, r#"Simplo Technology Co., LTD"#),
+        (526_u16, r#"Omron Healthcare Co., LTD"#),
+        (527_u16, r#"Comodule GMBH"#),
+        (528_u16, r#"ikeGPS"#),
+        (529_u16, r#"Telink Semiconductor Co. Ltd"#),
+        (530_u16, r#"Interplan Co., Ltd"#),
+        (531_u16, r#"Wyler AG"#),
+        (532_u16, r#"IK Multimedia Production srl"#),
+        (533_u16, r#"Lukoton Experience Oy"#),
+        (534_u16, r#"MTI Ltd"#),
+        (535_u16, r#"Tech4home, Lda"#),
+        (536_u16, r#"Hiotech AB"#),
+        (537_u16, r#"DOTT Limited"#),
+        (538_u16, r#"Blue Speck Labs, LLC"#),
+        (539_u16, r#"Cisco Systems, Inc"#),
+        (540_u16, r#"Mobicomm Inc"#),
+        (541_u16, r#"Edamic"#),
+        (542_u16, r#"Goodnet, Ltd"#),
+        (543_u16, r#"Luster Leaf Products  Inc"#),
+        (544_u16, r#"Manus Machina BV"#),
+        (545_u16, r#"Mobiquity Networks Inc"#),
+        (546_u16, r#"Praxis Dynamics"#),
+        (547_u16, r#"Philip Morris Products S.A."#),
+        (548_u16, r#"Comarch SA"#),
+        (549_u16, r#"Nestlé Nespresso S.A."#),
+        (550_u16, r#"Merlinia A/S"#),
+        (551_u16, r#"LifeBEAM Technologies"#),
+        (552_u16, r#"Twocanoes Labs, LLC"#),
+        (553_u16, r#"Muoverti Limited"#),
+        (554_u16, r#"Stamer Musikanlagen GMBH"#),
+        (555_u16, r#"Tesla Motors"#),
+        (556_u16, r#"Pharynks Corporation"#),
+        (557_u16, r#"Lupine"#),
+        (558_u16, r#"Siemens AG"#),
+        (559_u16, r#"Huami (Shanghai) Culture Communication CO., LTD"#),
+        (560_u16, r#"Foster Electric Company, Ltd"#),
+        (561_u16, r#"ETA SA"#),
+        (562_u16, r#"x-Senso Solutions Kft"#),
+        (563_u16, r#"Shenzhen SuLong Communication Ltd"#),
+        (564_u16, r#"FengFan (BeiJing) Technology Co, Ltd"#),
+        (565_u16, r#"Qrio Inc"#),
+        (566_u16, r#"Pitpatpet Ltd"#),
+        (567_u16, r#"MSHeli s.r.l."#),
+        (568_u16, r#"Trakm8 Ltd"#),
+        (569_u16, r#"JIN CO, Ltd"#),
+        (570_u16, r#"Alatech Tehnology"#),
+        (571_u16, r#"Beijing CarePulse Electronic Technology Co, Ltd"#),
+        (572_u16, r#"Awarepoint"#),
+        (573_u16, r#"ViCentra B.V."#),
+        (574_u16, r#"Raven Industries"#),
+        (575_u16, r#"WaveWare Technologies Inc."#),
+        (576_u16, r#"Argenox Technologies"#),
+        (577_u16, r#"Bragi GmbH"#),
+        (578_u16, r#"16Lab Inc"#),
+        (579_u16, r#"Masimo Corp"#),
+        (580_u16, r#"Iotera Inc"#),
+        (581_u16, r#"Endress+Hauser "#),
+        (582_u16, r#"ACKme Networks, Inc."#),
+        (583_u16, r#"FiftyThree Inc."#),
+        (584_u16, r#"Parker Hannifin Corp"#),
+        (585_u16, r#"Transcranial Ltd"#),
+        (586_u16, r#"Uwatec AG"#),
+        (587_u16, r#"Orlan LLC"#),
+        (588_u16, r#"Blue Clover Devices"#),
+        (589_u16, r#"M-Way Solutions GmbH"#),
+        (590_u16, r#"Microtronics Engineering GmbH"#),
+        (591_u16, r#"Schneider Schreibgeräte GmbH"#),
+        (592_u16, r#"Sapphire Circuits LLC"#),
+        (593_u16, r#"Lumo Bodytech Inc."#),
+        (594_u16, r#"UKC Technosolution"#),
+        (595_u16, r#"Xicato Inc."#),
+        (596_u16, r#"Playbrush"#),
+        (597_u16, r#"Dai Nippon Printing Co., Ltd."#),
+        (598_u16, r#"G24 Power Limited"#),
+        (599_u16, r#"AdBabble Local Commerce Inc."#),
+        (600_u16, r#"Devialet SA"#),
+        (601_u16, r#"ALTYOR"#),
+        (602_u16, r#"University of Applied Sciences Valais/Haute Ecole Valaisanne"#),
+        (603_u16, r#"Five Interactive, LLC dba Zendo"#),
+        (604_u16, r#"NetEase(Hangzhou)Network co.Ltd."#),
+        (605_u16, r#"Lexmark International Inc."#),
+        (606_u16, r#"Fluke Corporation"#),
+        (607_u16, r#"Yardarm Technologies"#),
+        (608_u16, r#"SensaRx"#),
+        (609_u16, r#"SECVRE GmbH"#),
+        (610_u16, r#"Glacial Ridge Technologies"#),
+        (611_u16, r#"Identiv, Inc."#),
+        (612_u16, r#"DDS, Inc."#),
+        (613_u16, r#"SMK Corporation"#),
+        (614_u16, r#"Schawbel Technologies LLC"#),
+        (615_u16, r#"XMI Systems SA"#),
+        (616_u16, r#"Cerevo"#),
+        (617_u16, r#"Torrox GmbH & Co KG"#),
+        (618_u16, r#"Gemalto"#),
+        (619_u16, r#"DEKA Research & Development Corp."#),
+        (620_u16, r#"Domster Tadeusz Szydlowski"#),
+        (621_u16, r#"Technogym SPA"#),
+        (622_u16, r#"FLEURBAEY BVBA"#),
+        (623_u16, r#"Aptcode Solutions"#),
+        (624_u16, r#"LSI ADL Technology"#),
+        (625_u16, r#"Animas Corp"#),
+        (626_u16, r#"Alps Alpine Co., Ltd."#),
+        (627_u16, r#"OCEASOFT"#),
+        (628_u16, r#"Motsai Research"#),
+        (629_u16, r#"Geotab"#),
+        (630_u16, r#"E.G.O. Elektro-Geraetebau GmbH"#),
+        (631_u16, r#"bewhere inc"#),
+        (632_u16, r#"Johnson Outdoors Inc"#),
+        (633_u16, r#"steute Schaltgerate GmbH & Co. KG"#),
+        (634_u16, r#"Ekomini inc."#),
+        (635_u16, r#"DEFA AS"#),
+        (636_u16, r#"Aseptika Ltd"#),
+        (637_u16, r#"HUAWEI Technologies Co., Ltd."#),
+        (638_u16, r#"HabitAware, LLC"#),
+        (639_u16, r#"ruwido austria gmbh"#),
+        (640_u16, r#"ITEC corporation"#),
+        (641_u16, r#"StoneL"#),
+        (642_u16, r#"Sonova AG"#),
+        (643_u16, r#"Maven Machines, Inc."#),
+        (644_u16, r#"Synapse Electronics"#),
+        (645_u16, r#"Standard Innovation Inc."#),
+        (646_u16, r#"RF Code, Inc."#),
+        (647_u16, r#"Wally Ventures S.L."#),
+        (648_u16, r#"Willowbank Electronics Ltd"#),
+        (649_u16, r#"SK Telecom"#),
+        (650_u16, r#"Jetro AS"#),
+        (651_u16, r#"Code Gears LTD"#),
+        (652_u16, r#"NANOLINK APS"#),
+        (653_u16, r#"IF, LLC"#),
+        (654_u16, r#"RF Digital Corp"#),
+        (655_u16, r#"Church & Dwight Co., Inc"#),
+        (656_u16, r#"Multibit Oy"#),
+        (657_u16, r#"CliniCloud Inc"#),
+        (658_u16, r#"SwiftSensors"#),
+        (659_u16, r#"Blue Bite"#),
+        (660_u16, r#"ELIAS GmbH"#),
+        (661_u16, r#"Sivantos GmbH"#),
+        (662_u16, r#"Petzl"#),
+        (663_u16, r#"storm power ltd"#),
+        (664_u16, r#"EISST Ltd"#),
+        (665_u16, r#"Inexess Technology Simma KG"#),
+        (666_u16, r#"Currant, Inc."#),
+        (667_u16, r#"C2 Development, Inc."#),
+        (668_u16, r#"Blue Sky Scientific, LLC"#),
+        (669_u16, r#"ALOTTAZS LABS, LLC"#),
+        (670_u16, r#"Kupson spol. s r.o."#),
+        (671_u16, r#"Areus Engineering GmbH"#),
+        (672_u16, r#"Impossible Camera GmbH"#),
+        (673_u16, r#"InventureTrack Systems"#),
+        (674_u16, r#"LockedUp"#),
+        (675_u16, r#"Itude"#),
+        (676_u16, r#"Pacific Lock Company"#),
+        (677_u16, r#"Tendyron Corporation ( 天地融科技股份有限公司 )"#),
+        (678_u16, r#"Robert Bosch GmbH"#),
+        (679_u16, r#"Illuxtron international B.V."#),
+        (680_u16, r#"miSport Ltd."#),
+        (681_u16, r#"Chargelib"#),
+        (682_u16, r#"Doppler Lab"#),
+        (683_u16, r#"BBPOS Limited"#),
+        (684_u16, r#"RTB Elektronik GmbH & Co. KG"#),
+        (685_u16, r#"Rx Networks, Inc."#),
+        (686_u16, r#"WeatherFlow, Inc."#),
+        (687_u16, r#"Technicolor USA Inc."#),
+        (688_u16, r#"Bestechnic(Shanghai),Ltd"#),
+        (689_u16, r#"Raden Inc"#),
+        (690_u16, r#"JouZen Oy"#),
+        (691_u16, r#"CLABER S.P.A."#),
+        (692_u16, r#"Hyginex, Inc."#),
+        (693_u16, r#"HANSHIN ELECTRIC RAILWAY CO.,LTD."#),
+        (694_u16, r#"Schneider Electric"#),
+        (695_u16, r#"Oort Technologies LLC"#),
+        (696_u16, r#"Chrono Therapeutics"#),
+        (697_u16, r#"Rinnai Corporation"#),
+        (698_u16, r#"Swissprime Technologies AG"#),
+        (699_u16, r#"Koha.,Co.Ltd"#),
+        (700_u16, r#"Genevac Ltd"#),
+        (701_u16, r#"Chemtronics"#),
+        (702_u16, r#"Seguro Technology Sp. z o.o."#),
+        (703_u16, r#"Redbird Flight Simulations"#),
+        (704_u16, r#"Dash Robotics"#),
+        (705_u16, r#"LINE Corporation"#),
+        (706_u16, r#"Guillemot Corporation"#),
+        (707_u16, r#"Techtronic Power Tools Technology Limited"#),
+        (708_u16, r#"Wilson Sporting Goods"#),
+        (709_u16, r#"Lenovo (Singapore) Pte Ltd. ( 联想(新加坡) )"#),
+        (710_u16, r#"Ayatan Sensors"#),
+        (711_u16, r#"Electronics Tomorrow Limited"#),
+        (712_u16, r#"VASCO Data Security International, Inc."#),
+        (713_u16, r#"PayRange Inc."#),
+        (714_u16, r#"ABOV Semiconductor"#),
+        (715_u16, r#"AINA-Wireless Inc."#),
+        (716_u16, r#"Eijkelkamp Soil & Water"#),
+        (717_u16, r#"BMA ergonomics b.v."#),
+        (718_u16, r#"Teva Branded Pharmaceutical Products R&D, Inc."#),
+        (719_u16, r#"Anima"#),
+        (720_u16, r#"3M"#),
+        (721_u16, r#"Empatica Srl"#),
+        (722_u16, r#"Afero, Inc."#),
+        (723_u16, r#"Powercast Corporation"#),
+        (724_u16, r#"Secuyou ApS"#),
+        (725_u16, r#"OMRON Corporation"#),
+        (726_u16, r#"Send Solutions"#),
+        (727_u16, r#"NIPPON SYSTEMWARE CO.,LTD."#),
+        (728_u16, r#"Neosfar"#),
+        (729_u16, r#"Fliegl Agrartechnik GmbH"#),
+        (730_u16, r#"Gilvader"#),
+        (731_u16, r#"Digi International Inc (R)"#),
+        (732_u16, r#"DeWalch Technologies, Inc."#),
+        (733_u16, r#"Flint Rehabilitation Devices, LLC"#),
+        (734_u16, r#"Samsung SDS Co., Ltd."#),
+        (735_u16, r#"Blur Product Development"#),
+        (736_u16, r#"University of Michigan"#),
+        (737_u16, r#"Victron Energy BV"#),
+        (738_u16, r#"NTT docomo"#),
+        (739_u16, r#"Carmanah Technologies Corp."#),
+        (740_u16, r#"Bytestorm Ltd."#),
+        (741_u16, r#"Espressif Incorporated ( 乐鑫信息科技(上海)有限公司 )"#),
+        (742_u16, r#"Unwire"#),
+        (743_u16, r#"Connected Yard, Inc."#),
+        (744_u16, r#"American Music Environments"#),
+        (745_u16, r#"Sensogram Technologies, Inc."#),
+        (746_u16, r#"Fujitsu Limited"#),
+        (747_u16, r#"Ardic Technology"#),
+        (748_u16, r#"Delta Systems, Inc"#),
+        (749_u16, r#"HTC Corporation "#),
+        (750_u16, r#"Citizen Holdings Co., Ltd. "#),
+        (751_u16, r#"SMART-INNOVATION.inc"#),
+        (752_u16, r#"Blackrat Software "#),
+        (753_u16, r#"The Idea Cave, LLC"#),
+        (754_u16, r#"GoPro, Inc."#),
+        (755_u16, r#"AuthAir, Inc"#),
+        (756_u16, r#"Vensi, Inc."#),
+        (757_u16, r#"Indagem Tech LLC"#),
+        (758_u16, r#"Intemo Technologies"#),
+        (759_u16, r#"DreamVisions co., Ltd."#),
+        (760_u16, r#"Runteq Oy Ltd"#),
+        (761_u16, r#"IMAGINATION TECHNOLOGIES LTD "#),
+        (762_u16, r#"CoSTAR TEchnologies"#),
+        (763_u16, r#"Clarius Mobile Health Corp."#),
+        (764_u16, r#"Shanghai Frequen Microelectronics Co., Ltd."#),
+        (765_u16, r#"Uwanna, Inc."#),
+        (766_u16, r#"Lierda Science & Technology Group Co., Ltd."#),
+        (767_u16, r#"Silicon Laboratories"#),
+        (768_u16, r#"World Moto Inc."#),
+        (769_u16, r#"Giatec Scientific Inc."#),
+        (770_u16, r#"Loop Devices, Inc"#),
+        (771_u16, r#"IACA electronique"#),
+        (772_u16, r#"Proxy Technologies, Inc."#),
+        (773_u16, r#"Swipp ApS"#),
+        (774_u16, r#"Life Laboratory Inc. "#),
+        (775_u16, r#"FUJI INDUSTRIAL CO.,LTD."#),
+        (776_u16, r#"Surefire, LLC"#),
+        (777_u16, r#"Dolby Labs"#),
+        (778_u16, r#"Ellisys"#),
+        (779_u16, r#"Magnitude Lighting Converters"#),
+        (780_u16, r#"Hilti AG"#),
+        (781_u16, r#"Devdata S.r.l."#),
+        (782_u16, r#"Deviceworx"#),
+        (783_u16, r#"Shortcut Labs"#),
+        (784_u16, r#"SGL Italia S.r.l."#),
+        (785_u16, r#"PEEQ DATA"#),
+        (786_u16, r#"Ducere Technologies Pvt Ltd "#),
+        (787_u16, r#"DiveNav, Inc. "#),
+        (788_u16, r#"RIIG AI Sp. z o.o."#),
+        (789_u16, r#"Thermo Fisher Scientific "#),
+        (790_u16, r#"AG Measurematics Pvt. Ltd. "#),
+        (791_u16, r#"CHUO Electronics CO., LTD. "#),
+        (792_u16, r#"Aspenta International "#),
+        (793_u16, r#"Eugster Frismag AG "#),
+        (794_u16, r#"Amber wireless GmbH "#),
+        (795_u16, r#"HQ Inc "#),
+        (796_u16, r#"Lab Sensor Solutions "#),
+        (797_u16, r#"Enterlab ApS "#),
+        (798_u16, r#"Eyefi, Inc."#),
+        (799_u16, r#"MetaSystem S.p.A. "#),
+        (800_u16, r#"SONO ELECTRONICS. CO., LTD "#),
+        (801_u16, r#"Jewelbots "#),
+        (802_u16, r#"Compumedics Limited "#),
+        (803_u16, r#"Rotor Bike Components "#),
+        (804_u16, r#"Astro, Inc. "#),
+        (805_u16, r#"Amotus Solutions "#),
+        (806_u16, r#"Healthwear Technologies (Changzhou)Ltd "#),
+        (807_u16, r#"Essex Electronics "#),
+        (808_u16, r#"Grundfos A/S"#),
+        (809_u16, r#"Eargo, Inc. "#),
+        (810_u16, r#"Electronic Design Lab "#),
+        (811_u16, r#"ESYLUX "#),
+        (812_u16, r#"NIPPON SMT.CO.,Ltd"#),
+        (813_u16, r#"BM innovations GmbH "#),
+        (814_u16, r#"indoormap"#),
+        (815_u16, r#"OttoQ Inc "#),
+        (816_u16, r#"North Pole Engineering "#),
+        (817_u16, r#"3flares Technologies Inc."#),
+        (818_u16, r#"Electrocompaniet A.S. "#),
+        (819_u16, r#"Mul-T-Lock"#),
+        (820_u16, r#"Corentium AS "#),
+        (821_u16, r#"Enlighted Inc"#),
+        (822_u16, r#"GISTIC"#),
+        (823_u16, r#"AJP2 Holdings, LLC"#),
+        (824_u16, r#"COBI GmbH "#),
+        (825_u16, r#"Blue Sky Scientific, LLC "#),
+        (826_u16, r#"Appception, Inc."#),
+        (827_u16, r#"Courtney Thorne Limited "#),
+        (828_u16, r#"Virtuosys"#),
+        (829_u16, r#"TPV Technology Limited "#),
+        (830_u16, r#"Monitra SA"#),
+        (831_u16, r#"Automation Components, Inc. "#),
+        (832_u16, r#"Letsense s.r.l. "#),
+        (833_u16, r#"Etesian Technologies LLC "#),
+        (834_u16, r#"GERTEC BRASIL LTDA. "#),
+        (835_u16, r#"Drekker Development Pty. Ltd."#),
+        (836_u16, r#"Whirl Inc "#),
+        (837_u16, r#"Locus Positioning "#),
+        (838_u16, r#"Acuity Brands Lighting, Inc "#),
+        (839_u16, r#"Prevent Biometrics "#),
+        (840_u16, r#"Arioneo"#),
+        (841_u16, r#"VersaMe "#),
+        (842_u16, r#"Vaddio "#),
+        (843_u16, r#"Libratone A/S "#),
+        (844_u16, r#"HM Electronics, Inc. "#),
+        (845_u16, r#"TASER International, Inc."#),
+        (846_u16, r#"SafeTrust Inc. "#),
+        (847_u16, r#"Heartland Payment Systems "#),
+        (848_u16, r#"Bitstrata Systems Inc. "#),
+        (849_u16, r#"Pieps GmbH "#),
+        (850_u16, r#"iRiding(Xiamen)Technology Co.,Ltd."#),
+        (851_u16, r#"Alpha Audiotronics, Inc. "#),
+        (852_u16, r#"TOPPAN FORMS CO.,LTD. "#),
+        (853_u16, r#"Sigma Designs, Inc. "#),
+        (854_u16, r#"Spectrum Brands, Inc. "#),
+        (855_u16, r#"Polymap Wireless "#),
+        (856_u16, r#"MagniWare Ltd."#),
+        (857_u16, r#"Novotec Medical GmbH "#),
+        (858_u16, r#"Medicom Innovation Partner a/s "#),
+        (859_u16, r#"Matrix Inc. "#),
+        (860_u16, r#"Eaton Corporation "#),
+        (861_u16, r#"KYS"#),
+        (862_u16, r#"Naya Health, Inc. "#),
+        (863_u16, r#"Acromag "#),
+        (864_u16, r#"Insulet Corporation "#),
+        (865_u16, r#"Wellinks Inc. "#),
+        (866_u16, r#"ON Semiconductor"#),
+        (867_u16, r#"FREELAP SA "#),
+        (868_u16, r#"Favero Electronics Srl "#),
+        (869_u16, r#"BioMech Sensor LLC "#),
+        (870_u16, r#"BOLTT Sports technologies Private limited"#),
+        (871_u16, r#"Saphe International "#),
+        (872_u16, r#"Metormote AB "#),
+        (873_u16, r#"littleBits "#),
+        (874_u16, r#"SetPoint Medical "#),
+        (875_u16, r#"BRControls Products BV "#),
+        (876_u16, r#"Zipcar "#),
+        (877_u16, r#"AirBolt Pty Ltd "#),
+        (878_u16, r#"KeepTruckin Inc "#),
+        (879_u16, r#"Motiv, Inc. "#),
+        (880_u16, r#"Wazombi Labs OÜ "#),
+        (881_u16, r#"ORBCOMM"#),
+        (882_u16, r#"Nixie Labs, Inc."#),
+        (883_u16, r#"AppNearMe Ltd"#),
+        (884_u16, r#"Holman Industries"#),
+        (885_u16, r#"Expain AS"#),
+        (886_u16, r#"Electronic Temperature Instruments Ltd"#),
+        (887_u16, r#"Plejd AB"#),
+        (888_u16, r#"Propeller Health"#),
+        (889_u16, r#"Shenzhen iMCO Electronic Technology Co.,Ltd"#),
+        (890_u16, r#"Algoria"#),
+        (891_u16, r#"Apption Labs Inc."#),
+        (892_u16, r#"Cronologics Corporation"#),
+        (893_u16, r#"MICRODIA Ltd."#),
+        (894_u16, r#"lulabytes S.L."#),
+        (895_u16, r#"Société des Produits Nestlé S.A. (formerly Nestec S.A.)"#),
+        (896_u16, r#"LLC "MEGA-F service""#),
+        (897_u16, r#"Sharp Corporation"#),
+        (898_u16, r#"Precision Outcomes Ltd"#),
+        (899_u16, r#"Kronos Incorporated"#),
+        (900_u16, r#"OCOSMOS Co., Ltd."#),
+        (901_u16, r#"Embedded Electronic Solutions Ltd. dba e2Solutions"#),
+        (902_u16, r#"Aterica Inc."#),
+        (903_u16, r#"BluStor PMC, Inc."#),
+        (904_u16, r#"Kapsch TrafficCom AB"#),
+        (905_u16, r#"ActiveBlu Corporation"#),
+        (906_u16, r#"Kohler Mira Limited"#),
+        (907_u16, r#"Noke"#),
+        (908_u16, r#"Appion Inc."#),
+        (909_u16, r#"Resmed Ltd"#),
+        (910_u16, r#"Crownstone B.V."#),
+        (911_u16, r#"Xiaomi Inc."#),
+        (912_u16, r#"INFOTECH s.r.o."#),
+        (913_u16, r#"Thingsquare AB"#),
+        (914_u16, r#"T&D"#),
+        (915_u16, r#"LAVAZZA S.p.A."#),
+        (916_u16, r#"Netclearance Systems, Inc."#),
+        (917_u16, r#"SDATAWAY"#),
+        (918_u16, r#"BLOKS GmbH"#),
+        (919_u16, r#"LEGO System A/S"#),
+        (920_u16, r#"Thetatronics Ltd"#),
+        (921_u16, r#"Nikon Corporation"#),
+        (922_u16, r#"NeST"#),
+        (923_u16, r#"South Silicon Valley Microelectronics"#),
+        (924_u16, r#"ALE International"#),
+        (925_u16, r#"CareView Communications, Inc."#),
+        (926_u16, r#"SchoolBoard Limited"#),
+        (927_u16, r#"Molex Corporation"#),
+        (928_u16, r#"IVT Wireless Limited"#),
+        (929_u16, r#"Alpine Labs LLC"#),
+        (930_u16, r#"Candura Instruments"#),
+        (931_u16, r#"SmartMovt Technology Co., Ltd"#),
+        (932_u16, r#"Token Zero Ltd"#),
+        (933_u16, r#"ACE CAD Enterprise Co., Ltd. (ACECAD)"#),
+        (934_u16, r#"Medela, Inc"#),
+        (935_u16, r#"AeroScout"#),
+        (936_u16, r#"Esrille Inc."#),
+        (937_u16, r#"THINKERLY SRL"#),
+        (938_u16, r#"Exon Sp. z o.o."#),
+        (939_u16, r#"Meizu Technology Co., Ltd."#),
+        (940_u16, r#"Smablo LTD"#),
+        (941_u16, r#"XiQ"#),
+        (942_u16, r#"Allswell Inc."#),
+        (943_u16, r#"Comm-N-Sense Corp DBA Verigo"#),
+        (944_u16, r#"VIBRADORM GmbH"#),
+        (945_u16, r#"Otodata Wireless Network Inc."#),
+        (946_u16, r#"Propagation Systems Limited"#),
+        (947_u16, r#"Midwest Instruments & Controls"#),
+        (948_u16, r#"Alpha Nodus, inc."#),
+        (949_u16, r#"petPOMM, Inc"#),
+        (950_u16, r#"Mattel"#),
+        (951_u16, r#"Airbly Inc."#),
+        (952_u16, r#"A-Safe Limited"#),
+        (953_u16, r#"FREDERIQUE CONSTANT SA"#),
+        (954_u16, r#"Maxscend Microelectronics Company Limited"#),
+        (955_u16, r#"Abbott"#),
+        (956_u16, r#"ASB Bank Ltd"#),
+        (957_u16, r#"amadas"#),
+        (958_u16, r#"Applied Science, Inc."#),
+        (959_u16, r#"iLumi Solutions Inc."#),
+        (960_u16, r#"Arch Systems Inc."#),
+        (961_u16, r#"Ember Technologies, Inc."#),
+        (962_u16, r#"Snapchat Inc"#),
+        (963_u16, r#"Casambi Technologies Oy"#),
+        (964_u16, r#"Pico Technology Inc."#),
+        (965_u16, r#"St. Jude Medical, Inc."#),
+        (966_u16, r#"Intricon"#),
+        (967_u16, r#"Structural Health Systems, Inc."#),
+        (968_u16, r#"Avvel International"#),
+        (969_u16, r#"Gallagher Group"#),
+        (970_u16, r#"In2things Automation Pvt. Ltd."#),
+        (971_u16, r#"SYSDEV Srl"#),
+        (972_u16, r#"Vonkil Technologies Ltd"#),
+        (973_u16, r#"Wynd Technologies, Inc."#),
+        (974_u16, r#"CONTRINEX S.A."#),
+        (975_u16, r#"MIRA, Inc."#),
+        (976_u16, r#"Watteam Ltd"#),
+        (977_u16, r#"Density Inc."#),
+        (978_u16, r#"IOT Pot India Private Limited"#),
+        (979_u16, r#"Sigma Connectivity AB"#),
+        (980_u16, r#"PEG PEREGO SPA"#),
+        (981_u16, r#"Wyzelink Systems Inc."#),
+        (982_u16, r#"Yota Devices LTD"#),
+        (983_u16, r#"FINSECUR"#),
+        (984_u16, r#"Zen-Me Labs Ltd"#),
+        (985_u16, r#"3IWare Co., Ltd."#),
+        (986_u16, r#"EnOcean GmbH"#),
+        (987_u16, r#"Instabeat, Inc"#),
+        (988_u16, r#"Nima Labs"#),
+        (989_u16, r#"Andreas Stihl AG & Co. KG"#),
+        (990_u16, r#"Nathan Rhoades LLC"#),
+        (991_u16, r#"Grob Technologies, LLC"#),
+        (992_u16, r#"Actions (Zhuhai) Technology Co., Limited"#),
+        (993_u16, r#"SPD Development Company Ltd"#),
+        (994_u16, r#"Sensoan Oy"#),
+        (995_u16, r#"Qualcomm Life Inc"#),
+        (996_u16, r#"Chip-ing AG"#),
+        (997_u16, r#"ffly4u"#),
+        (998_u16, r#"IoT Instruments Oy"#),
+        (999_u16, r#"TRUE Fitness Technology"#),
+        (1000_u16, r#"Reiner Kartengeraete GmbH & Co. KG."#),
+        (1001_u16, r#"SHENZHEN LEMONJOY TECHNOLOGY CO., LTD."#),
+        (1002_u16, r#"Hello Inc."#),
+        (1003_u16, r#"Evollve Inc."#),
+        (1004_u16, r#"Jigowatts Inc."#),
+        (1005_u16, r#"BASIC MICRO.COM,INC."#),
+        (1006_u16, r#"CUBE TECHNOLOGIES"#),
+        (1007_u16, r#"foolography GmbH"#),
+        (1008_u16, r#"CLINK"#),
+        (1009_u16, r#"Hestan Smart Cooking Inc."#),
+        (1010_u16, r#"WindowMaster A/S"#),
+        (1011_u16, r#"Flowscape AB"#),
+        (1012_u16, r#"PAL Technologies Ltd"#),
+        (1013_u16, r#"WHERE, Inc."#),
+        (1014_u16, r#"Iton Technology Corp."#),
+        (1015_u16, r#"Owl Labs Inc."#),
+        (1016_u16, r#"Rockford Corp."#),
+        (1017_u16, r#"Becon Technologies Co.,Ltd."#),
+        (1018_u16, r#"Vyassoft Technologies Inc"#),
+        (1019_u16, r#"Nox Medical"#),
+        (1020_u16, r#"Kimberly-Clark"#),
+        (1021_u16, r#"Trimble Navigation Ltd."#),
+        (1022_u16, r#"Littelfuse"#),
+        (1023_u16, r#"Withings"#),
+        (1024_u16, r#"i-developer IT Beratung UG"#),
+        (1025_u16, r#"Relations Inc."#),
+        (1026_u16, r#"Sears Holdings Corporation"#),
+        (1027_u16, r#"Gantner Electronic GmbH"#),
+        (1028_u16, r#"Authomate Inc"#),
+        (1029_u16, r#"Vertex International, Inc."#),
+        (1030_u16, r#"Airtago"#),
+        (1031_u16, r#"Swiss Audio SA"#),
+        (1032_u16, r#"ToGetHome Inc."#),
+        (1033_u16, r#"AXIS"#),
+        (1034_u16, r#"Openmatics"#),
+        (1035_u16, r#"Jana Care Inc."#),
+        (1036_u16, r#"Senix Corporation"#),
+        (1037_u16, r#"NorthStar Battery Company, LLC"#),
+        (1038_u16, r#"SKF (U.K.) Limited"#),
+        (1039_u16, r#"CO-AX Technology, Inc."#),
+        (1040_u16, r#"Fender Musical Instruments"#),
+        (1041_u16, r#"Luidia Inc"#),
+        (1042_u16, r#"SEFAM"#),
+        (1043_u16, r#"Wireless Cables Inc"#),
+        (1044_u16, r#"Lightning Protection International Pty Ltd"#),
+        (1045_u16, r#"Uber Technologies Inc"#),
+        (1046_u16, r#"SODA GmbH"#),
+        (1047_u16, r#"Fatigue Science"#),
+        (1048_u16, r#"Reserved"#),
+        (1049_u16, r#"Novalogy LTD"#),
+        (1050_u16, r#"Friday Labs Limited"#),
+        (1051_u16, r#"OrthoAccel Technologies"#),
+        (1052_u16, r#"WaterGuru, Inc."#),
+        (1053_u16, r#"Benning Elektrotechnik und Elektronik GmbH & Co. KG"#),
+        (1054_u16, r#"Dell Computer Corporation"#),
+        (1055_u16, r#"Kopin Corporation"#),
+        (1056_u16, r#"TecBakery GmbH"#),
+        (1057_u16, r#"Backbone Labs, Inc."#),
+        (1058_u16, r#"DELSEY SA"#),
+        (1059_u16, r#"Chargifi Limited"#),
+        (1060_u16, r#"Trainesense Ltd."#),
+        (1061_u16, r#"Unify Software and Solutions GmbH & Co. KG"#),
+        (1062_u16, r#"Husqvarna AB"#),
+        (1063_u16, r#"Focus fleet and fuel management inc"#),
+        (1064_u16, r#"SmallLoop, LLC"#),
+        (1065_u16, r#"Prolon Inc."#),
+        (1066_u16, r#"BD Medical"#),
+        (1067_u16, r#"iMicroMed Incorporated"#),
+        (1068_u16, r#"Ticto N.V."#),
+        (1069_u16, r#"Meshtech AS"#),
+        (1070_u16, r#"MemCachier Inc."#),
+        (1071_u16, r#"Danfoss A/S"#),
+        (1072_u16, r#"SnapStyk Inc."#),
+        (1073_u16, r#"Amway Corporation"#),
+        (1074_u16, r#"Silk Labs, Inc."#),
+        (1075_u16, r#"Pillsy Inc."#),
+        (1076_u16, r#"Hatch Baby, Inc."#),
+        (1077_u16, r#"Blocks Wearables Ltd."#),
+        (1078_u16, r#"Drayson Technologies (Europe) Limited"#),
+        (1079_u16, r#"eBest IOT Inc."#),
+        (1080_u16, r#"Helvar Ltd"#),
+        (1081_u16, r#"Radiance Technologies"#),
+        (1082_u16, r#"Nuheara Limited"#),
+        (1083_u16, r#"Appside co., ltd."#),
+        (1084_u16, r#"DeLaval"#),
+        (1085_u16, r#"Coiler Corporation"#),
+        (1086_u16, r#"Thermomedics, Inc."#),
+        (1087_u16, r#"Tentacle Sync GmbH"#),
+        (1088_u16, r#"Valencell, Inc."#),
+        (1089_u16, r#"iProtoXi Oy"#),
+        (1090_u16, r#"SECOM CO., LTD."#),
+        (1091_u16, r#"Tucker International LLC"#),
+        (1092_u16, r#"Metanate Limited"#),
+        (1093_u16, r#"Kobian Canada Inc."#),
+        (1094_u16, r#"NETGEAR, Inc."#),
+        (1095_u16, r#"Fabtronics Australia Pty Ltd"#),
+        (1096_u16, r#"Grand Centrix GmbH"#),
+        (1097_u16, r#"1UP USA.com llc"#),
+        (1098_u16, r#"SHIMANO INC."#),
+        (1099_u16, r#"Nain Inc."#),
+        (1100_u16, r#"LifeStyle Lock, LLC"#),
+        (1101_u16, r#"VEGA Grieshaber KG"#),
+        (1102_u16, r#"Xtrava Inc."#),
+        (1103_u16, r#"TTS Tooltechnic Systems AG & Co. KG"#),
+        (1104_u16, r#"Teenage Engineering AB"#),
+        (1105_u16, r#"Tunstall Nordic AB"#),
+        (1106_u16, r#"Svep Design Center AB"#),
+        (1107_u16, r#"Qorvo Utrecht B.V. formerly GreenPeak Technologies BV"#),
+        (1108_u16, r#"Sphinx Electronics GmbH & Co KG"#),
+        (1109_u16, r#"Atomation"#),
+        (1110_u16, r#"Nemik Consulting Inc"#),
+        (1111_u16, r#"RF INNOVATION"#),
+        (1112_u16, r#"Mini Solution Co., Ltd."#),
+        (1113_u16, r#"Lumenetix, Inc"#),
+        (1114_u16, r#"2048450 Ontario Inc"#),
+        (1115_u16, r#"SPACEEK LTD"#),
+        (1116_u16, r#"Delta T Corporation"#),
+        (1117_u16, r#"Boston Scientific Corporation"#),
+        (1118_u16, r#"Nuviz, Inc."#),
+        (1119_u16, r#"Real Time Automation, Inc."#),
+        (1120_u16, r#"Kolibree"#),
+        (1121_u16, r#"vhf elektronik GmbH"#),
+        (1122_u16, r#"Bonsai Systems GmbH"#),
+        (1123_u16, r#"Fathom Systems Inc."#),
+        (1124_u16, r#"Bellman & Symfon"#),
+        (1125_u16, r#"International Forte Group LLC"#),
+        (1126_u16, r#"CycleLabs Solutions inc."#),
+        (1127_u16, r#"Codenex Oy"#),
+        (1128_u16, r#"Kynesim Ltd"#),
+        (1129_u16, r#"Palago AB"#),
+        (1130_u16, r#"INSIGMA INC."#),
+        (1131_u16, r#"PMD Solutions"#),
+        (1132_u16, r#"Qingdao Realtime Technology Co., Ltd."#),
+        (1133_u16, r#"BEGA Gantenbrink-Leuchten KG"#),
+        (1134_u16, r#"Pambor Ltd."#),
+        (1135_u16, r#"Develco Products A/S"#),
+        (1136_u16, r#"iDesign s.r.l."#),
+        (1137_u16, r#"TiVo Corp"#),
+        (1138_u16, r#"Control-J Pty Ltd"#),
+        (1139_u16, r#"Steelcase, Inc."#),
+        (1140_u16, r#"iApartment co., ltd."#),
+        (1141_u16, r#"Icom inc."#),
+        (1142_u16, r#"Oxstren Wearable Technologies Private Limited"#),
+        (1143_u16, r#"Blue Spark Technologies"#),
+        (1144_u16, r#"FarSite Communications Limited"#),
+        (1145_u16, r#"mywerk system GmbH"#),
+        (1146_u16, r#"Sinosun Technology Co., Ltd."#),
+        (1147_u16, r#"MIYOSHI ELECTRONICS CORPORATION"#),
+        (1148_u16, r#"POWERMAT LTD"#),
+        (1149_u16, r#"Occly LLC"#),
+        (1150_u16, r#"OurHub Dev IvS"#),
+        (1151_u16, r#"Pro-Mark, Inc."#),
+        (1152_u16, r#"Dynometrics Inc."#),
+        (1153_u16, r#"Quintrax Limited"#),
+        (1154_u16, r#"POS Tuning Udo Vosshenrich GmbH & Co. KG"#),
+        (1155_u16, r#"Multi Care Systems B.V."#),
+        (1156_u16, r#"Revol Technologies Inc"#),
+        (1157_u16, r#"SKIDATA AG"#),
+        (1158_u16, r#"DEV TECNOLOGIA INDUSTRIA, COMERCIO E MANUTENCAO DE EQUIPAMENTOS LTDA. - ME"#),
+        (1159_u16, r#"Centrica Connected Home"#),
+        (1160_u16, r#"Automotive Data Solutions Inc"#),
+        (1161_u16, r#"Igarashi Engineering"#),
+        (1162_u16, r#"Taelek Oy"#),
+        (1163_u16, r#"CP Electronics Limited"#),
+        (1164_u16, r#"Vectronix AG"#),
+        (1165_u16, r#"S-Labs Sp. z o.o."#),
+        (1166_u16, r#"Companion Medical, Inc."#),
+        (1167_u16, r#"BlueKitchen GmbH"#),
+        (1168_u16, r#"Matting AB"#),
+        (1169_u16, r#"SOREX - Wireless Solutions GmbH"#),
+        (1170_u16, r#"ADC Technology, Inc."#),
+        (1171_u16, r#"Lynxemi Pte Ltd"#),
+        (1172_u16, r#"SENNHEISER electronic GmbH & Co. KG"#),
+        (1173_u16, r#"LMT Mercer Group, Inc"#),
+        (1174_u16, r#"Polymorphic Labs LLC"#),
+        (1175_u16, r#"Cochlear Limited"#),
+        (1176_u16, r#"METER Group, Inc. USA"#),
+        (1177_u16, r#"Ruuvi Innovations Ltd."#),
+        (1178_u16, r#"Situne AS"#),
+        (1179_u16, r#"nVisti, LLC"#),
+        (1180_u16, r#"DyOcean"#),
+        (1181_u16, r#"Uhlmann & Zacher GmbH"#),
+        (1182_u16, r#"AND!XOR LLC"#),
+        (1183_u16, r#"tictote AB"#),
+        (1184_u16, r#"Vypin, LLC"#),
+        (1185_u16, r#"PNI Sensor Corporation"#),
+        (1186_u16, r#"ovrEngineered, LLC"#),
+        (1187_u16, r#"GT-tronics HK Ltd"#),
+        (1188_u16, r#"Herbert Waldmann GmbH & Co. KG"#),
+        (1189_u16, r#"Guangzhou FiiO Electronics Technology Co.,Ltd"#),
+        (1190_u16, r#"Vinetech Co., Ltd"#),
+        (1191_u16, r#"Dallas Logic Corporation"#),
+        (1192_u16, r#"BioTex, Inc."#),
+        (1193_u16, r#"DISCOVERY SOUND TECHNOLOGY, LLC"#),
+        (1194_u16, r#"LINKIO SAS"#),
+        (1195_u16, r#"Harbortronics, Inc."#),
+        (1196_u16, r#"Undagrid B.V."#),
+        (1197_u16, r#"Shure Inc"#),
+        (1198_u16, r#"ERM Electronic Systems LTD"#),
+        (1199_u16, r#"BIOROWER Handelsagentur GmbH"#),
+        (1200_u16, r#"Weba Sport und Med. Artikel GmbH"#),
+        (1201_u16, r#"Kartographers Technologies Pvt. Ltd."#),
+        (1202_u16, r#"The Shadow on the Moon"#),
+        (1203_u16, r#"mobike (Hong Kong) Limited"#),
+        (1204_u16, r#"Inuheat Group AB"#),
+        (1205_u16, r#"Swiftronix AB"#),
+        (1206_u16, r#"Diagnoptics Technologies"#),
+        (1207_u16, r#"Analog Devices, Inc."#),
+        (1208_u16, r#"Soraa Inc."#),
+        (1209_u16, r#"CSR Building Products Limited"#),
+        (1210_u16, r#"Crestron Electronics, Inc."#),
+        (1211_u16, r#"Neatebox Ltd"#),
+        (1212_u16, r#"Draegerwerk AG & Co. KGaA"#),
+        (1213_u16, r#"AlbynMedical"#),
+        (1214_u16, r#"Averos FZCO"#),
+        (1215_u16, r#"VIT Initiative, LLC"#),
+        (1216_u16, r#"Statsports International"#),
+        (1217_u16, r#"Sospitas, s.r.o."#),
+        (1218_u16, r#"Dmet Products Corp."#),
+        (1219_u16, r#"Mantracourt Electronics Limited"#),
+        (1220_u16, r#"TeAM Hutchins AB"#),
+        (1221_u16, r#"Seibert Williams Glass, LLC"#),
+        (1222_u16, r#"Insta GmbH"#),
+        (1223_u16, r#"Svantek Sp. z o.o."#),
+        (1224_u16, r#"Shanghai Flyco Electrical Appliance Co., Ltd."#),
+        (1225_u16, r#"Thornwave Labs Inc"#),
+        (1226_u16, r#"Steiner-Optik GmbH"#),
+        (1227_u16, r#"Novo Nordisk A/S"#),
+        (1228_u16, r#"Enflux Inc."#),
+        (1229_u16, r#"Safetech Products LLC"#),
+        (1230_u16, r#"GOOOLED S.R.L."#),
+        (1231_u16, r#"DOM Sicherheitstechnik GmbH & Co. KG"#),
+        (1232_u16, r#"Olympus Corporation"#),
+        (1233_u16, r#"KTS GmbH"#),
+        (1234_u16, r#"Anloq Technologies Inc."#),
+        (1235_u16, r#"Queercon, Inc"#),
+        (1236_u16, r#"5th Element Ltd"#),
+        (1237_u16, r#"Gooee Limited"#),
+        (1238_u16, r#"LUGLOC LLC"#),
+        (1239_u16, r#"Blincam, Inc."#),
+        (1240_u16, r#"FUJIFILM Corporation"#),
+        (1241_u16, r#"RandMcNally"#),
+        (1242_u16, r#"Franceschi Marina snc"#),
+        (1243_u16, r#"Engineered Audio, LLC."#),
+        (1244_u16, r#"IOTTIVE (OPC) PRIVATE LIMITED"#),
+        (1245_u16, r#"4MOD Technology"#),
+        (1246_u16, r#"Lutron Electronics Co., Inc."#),
+        (1247_u16, r#"Emerson"#),
+        (1248_u16, r#"Guardtec, Inc."#),
+        (1249_u16, r#"REACTEC LIMITED"#),
+        (1250_u16, r#"EllieGrid"#),
+        (1251_u16, r#"Under Armour"#),
+        (1252_u16, r#"Woodenshark"#),
+        (1253_u16, r#"Avack Oy"#),
+        (1254_u16, r#"Smart Solution Technology, Inc."#),
+        (1255_u16, r#"REHABTRONICS INC."#),
+        (1256_u16, r#"STABILO International"#),
+        (1257_u16, r#"Busch Jaeger Elektro GmbH"#),
+        (1258_u16, r#"Pacific Bioscience Laboratories, Inc"#),
+        (1259_u16, r#"Bird Home Automation GmbH"#),
+        (1260_u16, r#"Motorola Solutions"#),
+        (1261_u16, r#"R9 Technology, Inc."#),
+        (1262_u16, r#"Auxivia"#),
+        (1263_u16, r#"DaisyWorks, Inc"#),
+        (1264_u16, r#"Kosi Limited"#),
+        (1265_u16, r#"Theben AG"#),
+        (1266_u16, r#"InDreamer Techsol Private Limited"#),
+        (1267_u16, r#"Cerevast Medical"#),
+        (1268_u16, r#"ZanCompute Inc."#),
+        (1269_u16, r#"Pirelli Tyre S.P.A."#),
+        (1270_u16, r#"McLear Limited"#),
+        (1271_u16, r#"Shenzhen Huiding Technology Co.,Ltd."#),
+        (1272_u16, r#"Convergence Systems Limited"#),
+        (1273_u16, r#"Interactio"#),
+        (1274_u16, r#"Androtec GmbH"#),
+        (1275_u16, r#"Benchmark Drives GmbH & Co. KG"#),
+        (1276_u16, r#"SwingLync L. L. C."#),
+        (1277_u16, r#"Tapkey GmbH"#),
+        (1278_u16, r#"Woosim Systems Inc."#),
+        (1279_u16, r#"Microsemi Corporation"#),
+        (1280_u16, r#"Wiliot LTD."#),
+        (1281_u16, r#"Polaris IND"#),
+        (1282_u16, r#"Specifi-Kali LLC"#),
+        (1283_u16, r#"Locoroll, Inc"#),
+        (1284_u16, r#"PHYPLUS Inc"#),
+        (1285_u16, r#"Inplay Technologies LLC"#),
+        (1286_u16, r#"Hager"#),
+        (1287_u16, r#"Yellowcog"#),
+        (1288_u16, r#"Axes System sp. z o. o."#),
+        (1289_u16, r#"myLIFTER Inc."#),
+        (1290_u16, r#"Shake-on B.V."#),
+        (1291_u16, r#"Vibrissa Inc."#),
+        (1292_u16, r#"OSRAM GmbH"#),
+        (1293_u16, r#"TRSystems GmbH"#),
+        (1294_u16, r#"Yichip Microelectronics (Hangzhou) Co.,Ltd."#),
+        (1295_u16, r#"Foundation Engineering LLC"#),
+        (1296_u16, r#"UNI-ELECTRONICS, INC."#),
+        (1297_u16, r#"Brookfield Equinox LLC"#),
+        (1298_u16, r#"Soprod SA"#),
+        (1299_u16, r#"9974091 Canada Inc."#),
+        (1300_u16, r#"FIBRO GmbH"#),
+        (1301_u16, r#"RB Controls Co., Ltd."#),
+        (1302_u16, r#"Footmarks"#),
+        (1303_u16, r#"Amtronic Sverige AB (formerly Amcore AB)"#),
+        (1304_u16, r#"MAMORIO.inc"#),
+        (1305_u16, r#"Tyto Life LLC"#),
+        (1306_u16, r#"Leica Camera AG"#),
+        (1307_u16, r#"Angee Technologies Ltd."#),
+        (1308_u16, r#"EDPS"#),
+        (1309_u16, r#"OFF Line Co., Ltd."#),
+        (1310_u16, r#"Detect Blue Limited"#),
+        (1311_u16, r#"Setec Pty Ltd"#),
+        (1312_u16, r#"Target Corporation"#),
+        (1313_u16, r#"IAI Corporation"#),
+        (1314_u16, r#"NS Tech, Inc."#),
+        (1315_u16, r#"MTG Co., Ltd."#),
+        (1316_u16, r#"Hangzhou iMagic Technology Co., Ltd"#),
+        (1317_u16, r#"HONGKONG NANO IC TECHNOLOGIES  CO., LIMITED"#),
+        (1318_u16, r#"Honeywell International Inc."#),
+        (1319_u16, r#"Albrecht JUNG"#),
+        (1320_u16, r#"Lunera Lighting Inc."#),
+        (1321_u16, r#"Lumen UAB"#),
+        (1322_u16, r#"Keynes Controls Ltd"#),
+        (1323_u16, r#"Novartis AG"#),
+        (1324_u16, r#"Geosatis SA"#),
+        (1325_u16, r#"EXFO, Inc."#),
+        (1326_u16, r#"LEDVANCE GmbH"#),
+        (1327_u16, r#"Center ID Corp."#),
+        (1328_u16, r#"Adolene, Inc."#),
+        (1329_u16, r#"D&M Holdings Inc."#),
+        (1330_u16, r#"CRESCO Wireless, Inc."#),
+        (1331_u16, r#"Nura Operations Pty Ltd"#),
+        (1332_u16, r#"Frontiergadget, Inc."#),
+        (1333_u16, r#"Smart Component Technologies Limited"#),
+        (1334_u16, r#"ZTR Control Systems LLC"#),
+        (1335_u16, r#"MetaLogics Corporation"#),
+        (1336_u16, r#"Medela AG"#),
+        (1337_u16, r#"OPPLE Lighting Co., Ltd"#),
+        (1338_u16, r#"Savitech Corp.,"#),
+        (1339_u16, r#"prodigy"#),
+        (1340_u16, r#"Screenovate Technologies Ltd"#),
+        (1341_u16, r#"TESA SA"#),
+        (1342_u16, r#"CLIM8 LIMITED"#),
+        (1343_u16, r#"Silergy Corp"#),
+        (1344_u16, r#"SilverPlus, Inc"#),
+        (1345_u16, r#"Sharknet srl"#),
+        (1346_u16, r#"Mist Systems, Inc."#),
+        (1347_u16, r#"MIWA LOCK CO.,Ltd"#),
+        (1348_u16, r#"OrthoSensor, Inc."#),
+        (1349_u16, r#"Candy Hoover Group s.r.l"#),
+        (1350_u16, r#"Apexar Technologies S.A."#),
+        (1351_u16, r#"LOGICDATA d.o.o."#),
+        (1352_u16, r#"Knick Elektronische Messgeraete GmbH & Co. KG"#),
+        (1353_u16, r#"Smart Technologies and Investment Limited"#),
+        (1354_u16, r#"Linough Inc."#),
+        (1355_u16, r#"Advanced Electronic Designs, Inc."#),
+        (1356_u16, r#"Carefree Scott Fetzer Co Inc"#),
+        (1357_u16, r#"Sensome"#),
+        (1358_u16, r#"FORTRONIK storitve d.o.o."#),
+        (1359_u16, r#"Sinnoz"#),
+        (1360_u16, r#"Versa Networks, Inc."#),
+        (1361_u16, r#"Sylero"#),
+        (1362_u16, r#"Avempace SARL"#),
+        (1363_u16, r#"Nintendo Co., Ltd."#),
+        (1364_u16, r#"National Instruments"#),
+        (1365_u16, r#"KROHNE Messtechnik GmbH"#),
+        (1366_u16, r#"Otodynamics Ltd"#),
+        (1367_u16, r#"Arwin Technology Limited"#),
+        (1368_u16, r#"benegear, inc."#),
+        (1369_u16, r#"Newcon Optik"#),
+        (1370_u16, r#"CANDY HOUSE, Inc."#),
+        (1371_u16, r#"FRANKLIN TECHNOLOGY INC"#),
+        (1372_u16, r#"Lely"#),
+        (1373_u16, r#"Valve Corporation"#),
+        (1374_u16, r#"Hekatron Vertriebs GmbH"#),
+        (1375_u16, r#"PROTECH S.A.S. DI GIRARDI ANDREA & C."#),
+        (1376_u16, r#"Sarita CareTech APS (formerly Sarita CareTech IVS)"#),
+        (1377_u16, r#"Finder S.p.A."#),
+        (1378_u16, r#"Thalmic Labs Inc."#),
+        (1379_u16, r#"Steinel Vertrieb GmbH"#),
+        (1380_u16, r#"Beghelli Spa"#),
+        (1381_u16, r#"Beijing Smartspace Technologies Inc."#),
+        (1382_u16, r#"CORE TRANSPORT TECHNOLOGIES NZ LIMITED"#),
+        (1383_u16, r#"Xiamen Everesports Goods Co., Ltd"#),
+        (1384_u16, r#"Bodyport Inc."#),
+        (1385_u16, r#"Audionics System, INC."#),
+        (1386_u16, r#"Flipnavi Co.,Ltd."#),
+        (1387_u16, r#"Rion Co., Ltd."#),
+        (1388_u16, r#"Long Range Systems, LLC"#),
+        (1389_u16, r#"Redmond Industrial Group LLC"#),
+        (1390_u16, r#"VIZPIN INC."#),
+        (1391_u16, r#"BikeFinder AS"#),
+        (1392_u16, r#"Consumer Sleep Solutions LLC"#),
+        (1393_u16, r#"PSIKICK, INC."#),
+        (1394_u16, r#"AntTail.com"#),
+        (1395_u16, r#"Lighting Science Group Corp."#),
+        (1396_u16, r#"AFFORDABLE ELECTRONICS INC"#),
+        (1397_u16, r#"Integral Memroy Plc"#),
+        (1398_u16, r#"Globalstar, Inc."#),
+        (1399_u16, r#"True Wearables, Inc."#),
+        (1400_u16, r#"Wellington Drive Technologies Ltd"#),
+        (1401_u16, r#"Ensemble Tech Private Limited"#),
+        (1402_u16, r#"OMNI Remotes"#),
+        (1403_u16, r#"Duracell U.S. Operations Inc."#),
+        (1404_u16, r#"Toor Technologies LLC"#),
+        (1405_u16, r#"Instinct Performance"#),
+        (1406_u16, r#"Beco, Inc"#),
+        (1407_u16, r#"Scuf Gaming International, LLC"#),
+        (1408_u16, r#"ARANZ Medical Limited"#),
+        (1409_u16, r#"LYS TECHNOLOGIES LTD"#),
+        (1410_u16, r#"Breakwall Analytics, LLC"#),
+        (1411_u16, r#"Code Blue Communications"#),
+        (1412_u16, r#"Gira Giersiepen GmbH & Co. KG"#),
+        (1413_u16, r#"Hearing Lab Technology"#),
+        (1414_u16, r#"LEGRAND"#),
+        (1415_u16, r#"Derichs GmbH"#),
+        (1416_u16, r#"ALT-TEKNIK LLC"#),
+        (1417_u16, r#"Star Technologies"#),
+        (1418_u16, r#"START TODAY CO.,LTD."#),
+        (1419_u16, r#"Maxim Integrated Products"#),
+        (1420_u16, r#"MERCK Kommanditgesellschaft auf Aktien"#),
+        (1421_u16, r#"Jungheinrich Aktiengesellschaft"#),
+        (1422_u16, r#"Oculus VR, LLC"#),
+        (1423_u16, r#"HENDON SEMICONDUCTORS PTY LTD"#),
+        (1424_u16, r#"Pur3 Ltd"#),
+        (1425_u16, r#"Viasat Group S.p.A."#),
+        (1426_u16, r#"IZITHERM"#),
+        (1427_u16, r#"Spaulding Clinical Research"#),
+        (1428_u16, r#"Kohler Company"#),
+        (1429_u16, r#"Inor Process AB"#),
+        (1430_u16, r#"My Smart Blinds"#),
+        (1431_u16, r#"RadioPulse Inc"#),
+        (1432_u16, r#"rapitag GmbH"#),
+        (1433_u16, r#"Lazlo326, LLC."#),
+        (1434_u16, r#"Teledyne Lecroy, Inc."#),
+        (1435_u16, r#"Dataflow Systems Limited"#),
+        (1436_u16, r#"Macrogiga Electronics"#),
+        (1437_u16, r#"Tandem Diabetes Care"#),
+        (1438_u16, r#"Polycom, Inc."#),
+        (1439_u16, r#"Fisher & Paykel Healthcare"#),
+        (1440_u16, r#"RCP Software Oy"#),
+        (1441_u16, r#"Shanghai Xiaoyi Technology Co.,Ltd."#),
+        (1442_u16, r#"ADHERIUM(NZ) LIMITED"#),
+        (1443_u16, r#"Axiomware Systems Incorporated"#),
+        (1444_u16, r#"O. E. M. Controls, Inc."#),
+        (1445_u16, r#"Kiiroo BV"#),
+        (1446_u16, r#"Telecon Mobile Limited"#),
+        (1447_u16, r#"Sonos Inc"#),
+        (1448_u16, r#"Tom Allebrandi Consulting"#),
+        (1449_u16, r#"Monidor"#),
+        (1450_u16, r#"Tramex Limited"#),
+        (1451_u16, r#"Nofence AS"#),
+        (1452_u16, r#"GoerTek Dynaudio Co., Ltd."#),
+        (1453_u16, r#"INIA"#),
+        (1454_u16, r#"CARMATE MFG.CO.,LTD"#),
+        (1455_u16, r#"OV LOOP, INC. (formerly ONvocal)"#),
+        (1456_u16, r#"NewTec GmbH"#),
+        (1457_u16, r#"Medallion Instrumentation Systems"#),
+        (1458_u16, r#"CAREL INDUSTRIES S.P.A."#),
+        (1459_u16, r#"Parabit Systems, Inc."#),
+        (1460_u16, r#"White Horse Scientific ltd"#),
+        (1461_u16, r#"verisilicon"#),
+        (1462_u16, r#"Elecs Industry Co.,Ltd."#),
+        (1463_u16, r#"Beijing Pinecone Electronics Co.,Ltd."#),
+        (1464_u16, r#"Ambystoma Labs Inc."#),
+        (1465_u16, r#"Suzhou Pairlink Network Technology"#),
+        (1466_u16, r#"igloohome"#),
+        (1467_u16, r#"Oxford Metrics plc"#),
+        (1468_u16, r#"Leviton Mfg. Co., Inc."#),
+        (1469_u16, r#"ULC Robotics Inc."#),
+        (1470_u16, r#"RFID Global by Softwork SrL"#),
+        (1471_u16, r#"Real-World-Systems Corporation"#),
+        (1472_u16, r#"Nalu Medical, Inc."#),
+        (1473_u16, r#"P.I.Engineering"#),
+        (1474_u16, r#"Grote Industries"#),
+        (1475_u16, r#"Runtime, Inc."#),
+        (1476_u16, r#"Codecoup sp. z o.o. sp. k."#),
+        (1477_u16, r#"SELVE GmbH & Co. KG"#),
+        (1478_u16, r#"Smart Animal Training Systems, LLC"#),
+        (1479_u16, r#"Lippert Components, INC"#),
+        (1480_u16, r#"SOMFY SAS"#),
+        (1481_u16, r#"TBS Electronics B.V."#),
+        (1482_u16, r#"MHL Custom Inc"#),
+        (1483_u16, r#"LucentWear LLC"#),
+        (1484_u16, r#"WATTS ELECTRONICS"#),
+        (1485_u16, r#"RJ Brands LLC"#),
+        (1486_u16, r#"V-ZUG Ltd"#),
+        (1487_u16, r#"Biowatch SA"#),
+        (1488_u16, r#"Anova Applied Electronics"#),
+        (1489_u16, r#"Lindab AB"#),
+        (1490_u16, r#"frogblue TECHNOLOGY GmbH"#),
+        (1491_u16, r#"Acurable Limited"#),
+        (1492_u16, r#"LAMPLIGHT Co., Ltd."#),
+        (1493_u16, r#"TEGAM, Inc."#),
+        (1494_u16, r#"Zhuhai Jieli technology Co.,Ltd"#),
+        (1495_u16, r#"modum.io AG"#),
+        (1496_u16, r#"Farm Jenny LLC"#),
+        (1497_u16, r#"Toyo Electronics Corporation"#),
+        (1498_u16, r#"Applied Neural Research Corp"#),
+        (1499_u16, r#"Avid Identification Systems, Inc."#),
+        (1500_u16, r#"Petronics Inc."#),
+        (1501_u16, r#"essentim GmbH"#),
+        (1502_u16, r#"QT Medical INC."#),
+        (1503_u16, r#"VIRTUALCLINIC.DIRECT LIMITED"#),
+        (1504_u16, r#"Viper Design LLC"#),
+        (1505_u16, r#"Human, Incorporated"#),
+        (1506_u16, r#"stAPPtronics GmbH"#),
+        (1507_u16, r#"Elemental Machines, Inc."#),
+        (1508_u16, r#"Taiyo Yuden Co., Ltd"#),
+        (1509_u16, r#"INEO ENERGY& SYSTEMS"#),
+        (1510_u16, r#"Motion Instruments Inc."#),
+        (1511_u16, r#"PressurePro"#),
+        (1512_u16, r#"COWBOY"#),
+        (1513_u16, r#"iconmobile GmbH"#),
+        (1514_u16, r#"ACS-Control-System GmbH"#),
+        (1515_u16, r#"Bayerische Motoren Werke AG"#),
+        (1516_u16, r#"Gycom Svenska AB"#),
+        (1517_u16, r#"Fuji Xerox Co., Ltd"#),
+        (1518_u16, r#"Glide Inc."#),
+        (1519_u16, r#"SIKOM AS"#),
+        (1520_u16, r#"beken"#),
+        (1521_u16, r#"The Linux Foundation"#),
+        (1522_u16, r#"Try and E CO.,LTD."#),
+        (1523_u16, r#"SeeScan"#),
+        (1524_u16, r#"Clearity, LLC"#),
+        (1525_u16, r#"GS TAG"#),
+        (1526_u16, r#"DPTechnics"#),
+        (1527_u16, r#"TRACMO, INC."#),
+        (1528_u16, r#"Anki Inc."#),
+        (1529_u16, r#"Hagleitner Hygiene International GmbH"#),
+        (1530_u16, r#"Konami Sports Life Co., Ltd."#),
+        (1531_u16, r#"Arblet Inc."#),
+        (1532_u16, r#"Masbando GmbH"#),
+        (1533_u16, r#"Innoseis"#),
+        (1534_u16, r#"Niko nv"#),
+        (1535_u16, r#"Wellnomics Ltd"#),
+        (1536_u16, r#"iRobot Corporation"#),
+        (1537_u16, r#"Schrader Electronics"#),
+        (1538_u16, r#"Geberit International AG"#),
+        (1539_u16, r#"Fourth Evolution Inc"#),
+        (1540_u16, r#"Cell2Jack LLC"#),
+        (1541_u16, r#"FMW electronic Futterer u. Maier-Wolf OHG"#),
+        (1542_u16, r#"John Deere"#),
+        (1543_u16, r#"Rookery Technology Ltd"#),
+        (1544_u16, r#"KeySafe-Cloud"#),
+        (1545_u16, r#"BUCHI Labortechnik AG"#),
+        (1546_u16, r#"IQAir AG"#),
+        (1547_u16, r#"Triax Technologies Inc"#),
+        (1548_u16, r#"Vuzix Corporation"#),
+        (1549_u16, r#"TDK Corporation"#),
+        (1550_u16, r#"Blueair AB"#),
+        (1551_u16, r#"Signify Netherlands"#),
+        (1552_u16, r#"ADH GUARDIAN USA LLC"#),
+        (1553_u16, r#"Beurer GmbH"#),
+        (1554_u16, r#"Playfinity AS"#),
+        (1555_u16, r#"Hans Dinslage GmbH"#),
+        (1556_u16, r#"OnAsset Intelligence, Inc."#),
+        (1557_u16, r#"INTER ACTION Corporation"#),
+        (1558_u16, r#"OS42 UG (haftungsbeschraenkt)"#),
+        (1559_u16, r#"WIZCONNECTED COMPANY LIMITED"#),
+        (1560_u16, r#"Audio-Technica Corporation"#),
+        (1561_u16, r#"Six Guys Labs, s.r.o."#),
+        (1562_u16, r#"R.W. Beckett Corporation"#),
+        (1563_u16, r#"silex technology, inc."#),
+        (1564_u16, r#"Univations Limited"#),
+        (1565_u16, r#"SENS Innovation ApS"#),
+        (1566_u16, r#"Diamond Kinetics, Inc."#),
+        (1567_u16, r#"Phrame Inc."#),
+        (1568_u16, r#"Forciot Oy"#),
+        (1569_u16, r#"Noordung d.o.o."#),
+        (1570_u16, r#"Beam Labs, LLC"#),
+        (1571_u16, r#"Philadelphia Scientific (U.K.) Limited"#),
+        (1572_u16, r#"Biovotion AG"#),
+        (1573_u16, r#"Square Panda, Inc."#),
+        (1574_u16, r#"Amplifico"#),
+        (1575_u16, r#"WEG S.A."#),
+        (1576_u16, r#"Ensto Oy"#),
+        (1577_u16, r#"PHONEPE PVT LTD"#),
+        (1578_u16, r#"Lunatico Astronomia SL"#),
+        (1579_u16, r#"MinebeaMitsumi Inc."#),
+        (1580_u16, r#"ASPion GmbH"#),
+        (1581_u16, r#"Vossloh-Schwabe Deutschland GmbH"#),
+        (1582_u16, r#"Procept"#),
+        (1583_u16, r#"ONKYO Corporation"#),
+        (1584_u16, r#"Asthrea D.O.O."#),
+        (1585_u16, r#"Fortiori Design LLC"#),
+        (1586_u16, r#"Hugo Muller GmbH & Co KG"#),
+        (1587_u16, r#"Wangi Lai PLT"#),
+        (1588_u16, r#"Fanstel Corp"#),
+        (1589_u16, r#"Crookwood"#),
+        (1590_u16, r#"ELECTRONICA INTEGRAL DE SONIDO S.A."#),
+        (1591_u16, r#"GiP Innovation Tools GmbH"#),
+        (1592_u16, r#"LX SOLUTIONS PTY LIMITED"#),
+        (1593_u16, r#"Shenzhen Minew Technologies Co., Ltd."#),
+        (1594_u16, r#"Prolojik Limited"#),
+        (1595_u16, r#"Kromek Group Plc"#),
+        (1596_u16, r#"Contec Medical Systems Co., Ltd."#),
+        (1597_u16, r#"Xradio Technology Co.,Ltd."#),
+        (1598_u16, r#"The Indoor Lab, LLC"#),
+        (1599_u16, r#"LDL TECHNOLOGY"#),
+        (1600_u16, r#"Parkifi"#),
+        (1601_u16, r#"Revenue Collection Systems FRANCE SAS"#),
+        (1602_u16, r#"Bluetrum Technology Co.,Ltd"#),
+        (1603_u16, r#"makita corporation"#),
+        (1604_u16, r#"Apogee Instruments"#),
+        (1605_u16, r#"BM3"#),
+        (1606_u16, r#"SGV Group Holding GmbH & Co. KG"#),
+        (1607_u16, r#"MED-EL"#),
+        (1608_u16, r#"Ultune Technologies"#),
+        (1609_u16, r#"Ryeex Technology Co.,Ltd."#),
+        (1610_u16, r#"Open Research Institute, Inc."#),
+        (1611_u16, r#"Scale-Tec, Ltd"#),
+        (1612_u16, r#"Zumtobel Group AG"#),
+        (1613_u16, r#"iLOQ Oy"#),
+        (1614_u16, r#"KRUXWorks Technologies Private Limited"#),
+        (1615_u16, r#"Digital Matter Pty Ltd"#),
+        (1616_u16, r#"Coravin, Inc."#),
+        (1617_u16, r#"Stasis Labs, Inc."#),
+        (1618_u16, r#"ITZ Innovations- und Technologiezentrum GmbH"#),
+        (1619_u16, r#"Meggitt SA"#),
+        (1620_u16, r#"Ledlenser GmbH & Co. KG"#),
+        (1621_u16, r#"Renishaw PLC"#),
+        (1622_u16, r#"ZhuHai AdvanPro Technology Company Limited"#),
+        (1623_u16, r#"Meshtronix Limited"#),
+        (1624_u16, r#"Payex Norge AS"#),
+        (1625_u16, r#"UnSeen Technologies Oy"#),
+        (1626_u16, r#"Zound Industries International AB"#),
+        (1627_u16, r#"Sesam Solutions BV"#),
+        (1628_u16, r#"PixArt Imaging Inc."#),
+        (1629_u16, r#"Panduit Corp."#),
+        (1630_u16, r#"Alo AB"#),
+        (1631_u16, r#"Ricoh Company Ltd"#),
+        (1632_u16, r#"RTC Industries, Inc."#),
+        (1633_u16, r#"Mode Lighting Limited"#),
+        (1634_u16, r#"Particle Industries, Inc."#),
+        (1635_u16, r#"Advanced Telemetry Systems, Inc."#),
+        (1636_u16, r#"RHA TECHNOLOGIES LTD"#),
+        (1637_u16, r#"Pure International Limited"#),
+        (1638_u16, r#"WTO Werkzeug-Einrichtungen GmbH"#),
+        (1639_u16, r#"Spark Technology Labs Inc."#),
+        (1640_u16, r#"Bleb Technology srl"#),
+        (1641_u16, r#"Livanova USA, Inc."#),
+        (1642_u16, r#"Brady Worldwide Inc."#),
+        (1643_u16, r#"DewertOkin GmbH"#),
+        (1644_u16, r#"Ztove ApS"#),
+        (1645_u16, r#"Venso EcoSolutions AB"#),
+        (1646_u16, r#"Eurotronik Kranj d.o.o."#),
+        (1647_u16, r#"Hug Technology Ltd"#),
+        (1648_u16, r#"Gema Switzerland GmbH"#),
+        (1649_u16, r#"Buzz Products Ltd."#),
+        (1650_u16, r#"Kopi"#),
+        (1651_u16, r#"Innova Ideas Limited"#),
+        (1652_u16, r#"BeSpoon"#),
+        (1653_u16, r#"Deco Enterprises, Inc."#),
+        (1654_u16, r#"Expai Solutions Private Limited"#),
+        (1655_u16, r#"Innovation First, Inc."#),
+        (1656_u16, r#"SABIK Offshore GmbH"#),
+        (1657_u16, r#"4iiii Innovations Inc."#),
+        (1658_u16, r#"The Energy Conservatory, Inc."#),
+        (1659_u16, r#"I.FARM, INC."#),
+        (1660_u16, r#"Tile, Inc."#),
+        (1661_u16, r#"Form Athletica Inc."#),
+        (1662_u16, r#"MbientLab Inc"#),
+        (1663_u16, r#"NETGRID S.N.C. DI BISSOLI MATTEO, CAMPOREALE SIMONE, TOGNETTI FEDERICO"#),
+        (1664_u16, r#"Mannkind Corporation"#),
+        (1665_u16, r#"Trade FIDES a.s."#),
+        (1666_u16, r#"Photron Limited"#),
+        (1667_u16, r#"Eltako GmbH"#),
+        (1668_u16, r#"Dermalapps, LLC"#),
+        (1669_u16, r#"Greenwald Industries"#),
+        (1670_u16, r#"inQs Co., Ltd."#),
+        (1671_u16, r#"Cherry GmbH"#),
+        (1672_u16, r#"Amsted Digital Solutions Inc."#),
+        (1673_u16, r#"Tacx b.v."#),
+        (1674_u16, r#"Raytac Corporation"#),
+        (1675_u16, r#"Jiangsu Teranovo Tech Co., Ltd."#),
+        (1676_u16, r#"Changzhou Sound Dragon Electronics and Acoustics Co., Ltd"#),
+        (1677_u16, r#"JetBeep Inc."#),
+        (1678_u16, r#"Razer Inc."#),
+        (1679_u16, r#"JRM Group Limited"#),
+        (1680_u16, r#"Eccrine Systems, Inc."#),
+        (1681_u16, r#"Curie Point AB"#),
+        (1682_u16, r#"Georg Fischer AG"#),
+        (1683_u16, r#"Hach - Danaher"#),
+        (1684_u16, r#"T&A Laboratories LLC"#),
+        (1685_u16, r#"Koki Holdings Co., Ltd."#),
+        (1686_u16, r#"Gunakar Private Limited"#),
+        (1687_u16, r#"Stemco Products Inc"#),
+        (1688_u16, r#"Wood IT Security, LLC"#),
+        (1689_u16, r#"RandomLab SAS"#),
+        (1690_u16, r#"Adero, Inc. (formerly as TrackR, Inc.)"#),
+        (1691_u16, r#"Dragonchip Limited"#),
+        (1692_u16, r#"Noomi AB"#),
+        (1693_u16, r#"Vakaros LLC"#),
+        (1694_u16, r#"Delta Electronics, Inc."#),
+        (1695_u16, r#"FlowMotion Technologies AS"#),
+        (1696_u16, r#"OBIQ Location Technology Inc."#),
+        (1697_u16, r#"Cardo Systems, Ltd"#),
+        (1698_u16, r#"Globalworx GmbH"#),
+        (1699_u16, r#"Nymbus, LLC"#),
+        (1700_u16, r#"Sanyo Techno Solutions Tottori Co., Ltd."#),
+        (1701_u16, r#"TEKZITEL PTY LTD"#),
+        (1702_u16, r#"Roambee Corporation"#),
+        (1703_u16, r#"Chipsea Technologies (ShenZhen) Corp."#),
+        (1704_u16, r#"GD Midea Air-Conditioning Equipment Co., Ltd."#),
+        (1705_u16, r#"Soundmax Electronics Limited"#),
+        (1706_u16, r#"Produal Oy"#),
+        (1707_u16, r#"HMS Industrial Networks AB"#),
+        (1708_u16, r#"Ingchips Technology Co., Ltd."#),
+        (1709_u16, r#"InnovaSea Systems Inc."#),
+        (1710_u16, r#"SenseQ Inc."#),
+        (1711_u16, r#"Shoof Technologies"#),
+        (1712_u16, r#"BRK Brands, Inc."#),
+        (1713_u16, r#"SimpliSafe, Inc."#),
+        (1714_u16, r#"Tussock Innovation 2013 Limited"#),
+        (1715_u16, r#"The Hablab ApS"#),
+        (1716_u16, r#"Sencilion Oy"#),
+        (1717_u16, r#"Wabilogic Ltd."#),
+        (1718_u16, r#"Sociometric Solutions, Inc."#),
+        (1719_u16, r#"iCOGNIZE GmbH"#),
+        (1720_u16, r#"ShadeCraft, Inc"#),
+        (1721_u16, r#"Beflex Inc."#),
+        (1722_u16, r#"Beaconzone Ltd"#),
+        (1723_u16, r#"Leaftronix Analogic Solutions Private Limited"#),
+        (1724_u16, r#"TWS Srl"#),
+        (1725_u16, r#"ABB Oy"#),
+        (1726_u16, r#"HitSeed Oy"#),
+        (1727_u16, r#"Delcom Products Inc."#),
+        (1728_u16, r#"CAME S.p.A."#),
+        (1729_u16, r#"Alarm.com Holdings, Inc"#),
+        (1730_u16, r#"Measurlogic Inc."#),
+        (1731_u16, r#"King I Electronics.Co.,Ltd"#),
+        (1732_u16, r#"Dream Labs GmbH"#),
+        (1733_u16, r#"Urban Compass, Inc"#),
+        (1734_u16, r#"Simm Tronic Limited"#),
+        (1735_u16, r#"Somatix Inc"#),
+        (1736_u16, r#"Storz & Bickel GmbH & Co. KG"#),
+        (1737_u16, r#"MYLAPS B.V."#),
+        (1738_u16, r#"Shenzhen Zhongguang Infotech Technology Development Co., Ltd"#),
+        (1739_u16, r#"Dyeware, LLC"#),
+        (1740_u16, r#"Dongguan SmartAction Technology Co.,Ltd."#),
+        (1741_u16, r#"DIG Corporation"#),
+        (1742_u16, r#"FIOR & GENTZ"#),
+        (1743_u16, r#"Belparts N.V."#),
+        (1744_u16, r#"Etekcity Corporation"#),
+        (1745_u16, r#"Meyer Sound Laboratories, Incorporated"#),
+        (1746_u16, r#"CeoTronics AG"#),
+        (1747_u16, r#"TriTeq Lock and Security, LLC"#),
+        (1748_u16, r#"DYNAKODE TECHNOLOGY PRIVATE LIMITED"#),
+        (1749_u16, r#"Sensirion AG"#),
+        (1750_u16, r#"JCT Healthcare Pty Ltd"#),
+        (1751_u16, r#"FUBA Automotive Electronics GmbH"#),
+        (1752_u16, r#"AW Company"#),
+        (1753_u16, r#"Shanghai Mountain View Silicon Co.,Ltd."#),
+        (1754_u16, r#"Zliide Technologies ApS"#),
+        (1755_u16, r#"Automatic Labs, Inc."#),
+        (1756_u16, r#"Industrial Network Controls, LLC"#),
+        (1757_u16, r#"Intellithings Ltd."#),
+        (1758_u16, r#"Navcast, Inc."#),
+        (1759_u16, r#"Hubbell Lighting, Inc."#),
+        (1760_u16, r#"Avaya "#),
+        (1761_u16, r#"Milestone AV Technologies LLC"#),
+        (1762_u16, r#"Alango Technologies Ltd"#),
+        (1763_u16, r#"Spinlock Ltd"#),
+        (1764_u16, r#"Aluna"#),
+        (1765_u16, r#"OPTEX CO.,LTD."#),
+        (1766_u16, r#"NIHON DENGYO KOUSAKU"#),
+        (1767_u16, r#"VELUX A/S"#),
+        (1768_u16, r#"Almendo Technologies GmbH"#),
+        (1769_u16, r#"Zmartfun Electronics, Inc."#),
+        (1770_u16, r#"SafeLine Sweden AB"#),
+        (1771_u16, r#"Houston Radar LLC"#),
+        (1772_u16, r#"Sigur"#),
+        (1773_u16, r#"J Neades Ltd"#),
+        (1774_u16, r#"Avantis Systems Limited"#),
+        (1775_u16, r#"ALCARE Co., Ltd."#),
+        (1776_u16, r#"Chargy Technologies, SL"#),
+        (1777_u16, r#"Shibutani Co., Ltd."#),
+        (1778_u16, r#"Trapper Data AB"#),
+        (1779_u16, r#"Alfred International Inc."#),
+        (1780_u16, r#"Near Field Solutions Ltd"#),
+        (1781_u16, r#"Vigil Technologies Inc."#),
+        (1782_u16, r#"Vitulo Plus BV"#),
+        (1783_u16, r#"WILKA Schliesstechnik GmbH"#),
+        (1784_u16, r#"BodyPlus Technology Co.,Ltd"#),
+        (1785_u16, r#"happybrush GmbH"#),
+        (1786_u16, r#"Enequi AB"#),
+        (1787_u16, r#"Sartorius AG"#),
+        (1788_u16, r#"Tom Communication Industrial Co.,Ltd."#),
+        (1789_u16, r#"ESS Embedded System Solutions Inc."#),
+        (1790_u16, r#"Mahr GmbH"#),
+        (1791_u16, r#"Redpine Signals Inc"#),
+        (1792_u16, r#"TraqFreq LLC"#),
+        (1793_u16, r#"PAFERS TECH"#),
+        (1794_u16, r#"Akciju sabiedriba "SAF TEHNIKA""#),
+        (1795_u16, r#"Beijing Jingdong Century Trading Co., Ltd."#),
+        (1796_u16, r#"JBX Designs Inc."#),
+        (1797_u16, r#"AB Electrolux"#),
+        (1798_u16, r#"Wernher von Braun Center for ASdvanced Research"#),
+        (1799_u16, r#"Essity Hygiene and Health Aktiebolag"#),
+        (1800_u16, r#"Be Interactive Co., Ltd"#),
+        (1801_u16, r#"Carewear Corp."#),
+        (1802_u16, r#"Huf Hülsbeck & Fürst GmbH & Co. KG"#),
+        (1803_u16, r#"Element Products, Inc."#),
+        (1804_u16, r#"Beijing Winner Microelectronics Co.,Ltd"#),
+        (1805_u16, r#"SmartSnugg Pty Ltd"#),
+        (1806_u16, r#"FiveCo Sarl"#),
+        (1807_u16, r#"California Things Inc."#),
+        (1808_u16, r#"Audiodo AB"#),
+        (1809_u16, r#"ABAX AS"#),
+        (1810_u16, r#"Bull Group Company Limited"#),
+        (1811_u16, r#"Respiri Limited"#),
+        (1812_u16, r#"MindPeace Safety LLC"#),
+        (1813_u16, r#"Vgyan Solutions"#),
+        (1814_u16, r#"Altonics"#),
+        (1815_u16, r#"iQsquare BV"#),
+        (1816_u16, r#"IDIBAIX enginneering"#),
+        (1817_u16, r#"ECSG"#),
+        (1818_u16, r#"REVSMART WEARABLE HK CO LTD"#),
+        (1819_u16, r#"Precor"#),
+        (1820_u16, r#"F5 Sports, Inc"#),
+        (1821_u16, r#"exoTIC Systems"#),
+        (1822_u16, r#"DONGGUAN HELE ELECTRONICS CO., LTD"#),
+        (1823_u16, r#"Dongguan Liesheng Electronic Co.Ltd"#),
+        (1824_u16, r#"Oculeve, Inc."#),
+        (1825_u16, r#"Clover Network, Inc."#),
+        (1826_u16, r#"Xiamen Eholder Electronics Co.Ltd"#),
+        (1827_u16, r#"Ford Motor Company"#),
+        (1828_u16, r#"Guangzhou SuperSound Information Technology Co.,Ltd"#),
+        (1829_u16, r#"Tedee Sp. z o.o."#),
+        (1830_u16, r#"PHC Corporation"#),
+        (1831_u16, r#"STALKIT AS"#),
+        (1832_u16, r#"Eli Lilly and Company"#),
+        (1833_u16, r#"SwaraLink Technologies"#),
+        (1834_u16, r#"JMR embedded systems GmbH"#),
+        (1835_u16, r#"Bitkey Inc."#),
+        (1836_u16, r#"GWA Hygiene GmbH"#),
+        (1837_u16, r#"Safera Oy"#),
+        (1838_u16, r#"Open Platform Systems LLC"#),
+        (1839_u16, r#"OnePlus Electronics (Shenzhen) Co., Ltd."#),
+        (1840_u16, r#"Wildlife Acoustics, Inc."#),
+        (1841_u16, r#"ABLIC Inc."#),
+        (1842_u16, r#"Dairy Tech, Inc."#),
+        (1843_u16, r#"Iguanavation, Inc."#),
+        (1844_u16, r#"DiUS Computing Pty Ltd"#),
+        (1845_u16, r#"UpRight Technologies LTD"#),
+        (1846_u16, r#"FrancisFund, LLC"#),
+        (1847_u16, r#"LLC Navitek"#),
+        (1848_u16, r#"Glass Security Pte Ltd"#),
+        (1849_u16, r#"Jiangsu Qinheng Co., Ltd."#),
+        (1850_u16, r#"Chandler Systems Inc."#),
+        (1851_u16, r#"Fantini Cosmi s.p.a."#),
+        (1852_u16, r#"Acubit ApS"#),
+        (1853_u16, r#"Beijing Hao Heng Tian Tech Co., Ltd."#),
+        (1854_u16, r#"Bluepack S.R.L."#),
+        (1855_u16, r#"Beijing Unisoc Technologies Co., Ltd."#),
+        (1856_u16, r#"HITIQ LIMITED"#),
+        (1857_u16, r#"MAC SRL"#),
+        (1858_u16, r#"DML LLC"#),
+        (1859_u16, r#"Sanofi"#),
+        (1860_u16, r#"SOCOMEC"#),
+        (1861_u16, r#"WIZNOVA, Inc."#),
+        (1862_u16, r#"Seitec Elektronik GmbH"#),
+        (1863_u16, r#"OR Technologies Pty Ltd"#),
+        (1864_u16, r#"GuangZhou KuGou Computer Technology Co.Ltd"#),
+        (1865_u16, r#"DIAODIAO (Beijing) Technology Co., Ltd."#),
+        (1866_u16, r#"Illusory Studios LLC"#),
+        (1867_u16, r#"Sarvavid Software Solutions LLP"#),
+        (1868_u16, r#"iopool s.a."#),
+        (1869_u16, r#"Amtech Systems, LLC"#),
+        (1870_u16, r#"EAGLE DETECTION SA"#),
+        (1871_u16, r#"MEDIATECH S.R.L."#),
+        (1872_u16, r#"Hamilton Professional Services of Canada Incorporated"#),
+        (1873_u16, r#"Changsha JEMO IC Design Co.,Ltd"#),
+        (1874_u16, r#"Elatec GmbH"#),
+        (1875_u16, r#"JLG Industries, Inc."#),
+        (1876_u16, r#"Michael Parkin"#),
+        (1877_u16, r#"Brother Industries, Ltd"#),
+        (1878_u16, r#"Lumens For Less, Inc"#),
+        (1879_u16, r#"ELA Innovation"#),
+        (1880_u16, r#"umanSense AB"#),
+        (1881_u16, r#"Shanghai InGeek Cyber Security Co., Ltd."#),
+        (1882_u16, r#"HARMAN CO.,LTD."#),
+        (1883_u16, r#"Smart Sensor Devices AB"#),
+        (1884_u16, r#"Antitronics Inc."#),
+        (1885_u16, r#"RHOMBUS SYSTEMS, INC."#),
+        (1886_u16, r#"Katerra Inc."#),
+        (1887_u16, r#"Remote Solution Co., LTD."#),
+        (1888_u16, r#"Vimar SpA"#),
+        (1889_u16, r#"Mantis Tech LLC"#),
+        (1890_u16, r#"TerOpta Ltd"#),
+        (1891_u16, r#"PIKOLIN S.L."#),
+        (1892_u16, r#"WWZN Information Technology Company Limited"#),
+        (1893_u16, r#"Voxx International"#),
+        (1894_u16, r#"ART AND PROGRAM, INC."#),
+        (1895_u16, r#"NITTO DENKO ASIA TECHNICAL CENTRE PTE. LTD."#),
+        (1896_u16, r#"Peloton Interactive Inc."#),
+        (1897_u16, r#"Force Impact Technologies"#),
+        (1898_u16, r#"Dmac Mobile Developments, LLC"#),
+        (1899_u16, r#"Engineered Medical Technologies"#),
+        (1900_u16, r#"Noodle Technology inc"#),
+        (1901_u16, r#"Graesslin GmbH"#),
+        (1902_u16, r#"WuQi technologies, Inc."#),
+        (1903_u16, r#"Successful Endeavours Pty Ltd"#),
+        (1904_u16, r#"InnoCon Medical ApS"#),
+        (1905_u16, r#"Corvex Connected Safety"#),
+        (1906_u16, r#"Thirdwayv Inc."#),
+        (1907_u16, r#"Echoflex Solutions Inc."#),
+        (1908_u16, r#"C-MAX Asia Limited"#),
+        (1909_u16, r#"4eBusiness GmbH"#),
+        (1910_u16, r#"Cyber Transport Control GmbH"#),
+        (1911_u16, r#"Cue"#),
+        (1912_u16, r#"KOAMTAC INC."#),
+        (1913_u16, r#"Loopshore Oy"#),
+        (1914_u16, r#"Niruha Systems Private Limited"#),
+        (1915_u16, r#"AmaterZ, Inc."#),
+        (1916_u16, r#"radius co., ltd."#),
+        (1917_u16, r#"Sensority, s.r.o."#),
+        (1918_u16, r#"Sparkage Inc."#),
+        (1919_u16, r#"Glenview Software Corporation"#),
+        (1920_u16, r#"Finch Technologies Ltd."#),
+        (1921_u16, r#"Qingping Technology (Beijing) Co., Ltd."#),
+        (1922_u16, r#"DeviceDrive AS"#),
+        (1923_u16, r#"ESEMBER LIMITED LIABILITY COMPANY"#),
+        (1924_u16, r#"audifon GmbH & Co. KG"#),
+        (1925_u16, r#"O2 Micro, Inc."#),
+        (1926_u16, r#"HLP Controls Pty Limited"#),
+        (1927_u16, r#"Pangaea Solution"#),
+        (1928_u16, r#"BubblyNet, LLC"#),
+        (1930_u16, r#"The Wildflower Foundation"#),
+        (1931_u16, r#"Optikam Tech Inc."#),
+        (1932_u16, r#"MINIBREW HOLDING B.V"#),
+        (1933_u16, r#"Cybex GmbH"#),
+        (1934_u16, r#"FUJIMIC NIIGATA, INC."#),
+        (1935_u16, r#"Hanna Instruments, Inc."#),
+        (1936_u16, r#"KOMPAN A/S"#),
+        (1937_u16, r#"Scosche Industries, Inc."#),
+        (1938_u16, r#"Provo Craft"#),
+        (1939_u16, r#"AEV spol. s r.o."#),
+        (1940_u16, r#"The Coca-Cola Company"#),
+        (1941_u16, r#"GASTEC CORPORATION"#),
+        (1942_u16, r#"StarLeaf Ltd"#),
+        (1943_u16, r#"Water-i.d. GmbH"#),
+        (1944_u16, r#"HoloKit, Inc."#),
+        (1945_u16, r#"PlantChoir Inc."#),
+        (1946_u16, r#"GuangDong Oppo Mobile Telecommunications Corp., Ltd."#),
+        (1947_u16, r#"CST ELECTRONICS (PROPRIETARY) LIMITED"#),
+        (1948_u16, r#"Sky UK Limited"#),
+        (1949_u16, r#"Digibale Pty Ltd"#),
+        (1950_u16, r#"Smartloxx GmbH"#),
+        (1951_u16, r#"Pune Scientific LLP"#),
+        (1952_u16, r#"Regent Beleuchtungskorper AG"#),
+        (1953_u16, r#"Apollo Neuroscience, Inc."#),
+        (1954_u16, r#"Roku, Inc."#),
+        (1955_u16, r#"Comcast Cable"#),
+        (1956_u16, r#"Xiamen Mage Information Technology Co., Ltd."#),
+        (1957_u16, r#"RAB Lighting, Inc."#),
+        (1958_u16, r#"Musen Connect, Inc."#),
+        (1959_u16, r#"Zume, Inc."#),
+        (1960_u16, r#"conbee GmbH"#),
+        (1961_u16, r#"Bruel & Kjaer Sound & Vibration"#),
+        (1962_u16, r#"The Kroger Co."#),
+        (1963_u16, r#"Granite River Solutions, Inc."#),
+        (1964_u16, r#"LoupeDeck Oy"#),
+        (1965_u16, r#"New H3C Technologies Co.,Ltd"#),
+        (1966_u16, r#"Aurea Solucoes Tecnologicas Ltda."#),
+        (1967_u16, r#"Hong Kong Bouffalo Lab Limited"#),
+        (1968_u16, r#"GV Concepts Inc."#),
+        (1969_u16, r#"Thomas Dynamics, LLC"#),
+        (1970_u16, r#"Moeco IOT Inc."#),
+        (1971_u16, r#"2N TELEKOMUNIKACE a.s."#),
+        (1972_u16, r#"Hormann KG Antriebstechnik"#),
+        (1973_u16, r#"CRONO CHIP, S.L."#),
+        (1974_u16, r#"Soundbrenner Limited"#),
+        (1975_u16, r#"ETABLISSEMENTS GEORGES RENAULT"#),
+        (1976_u16, r#"iSwip"#),
+        (1977_u16, r#"Epona Biotec Limited"#),
+        (1978_u16, r#"Battery-Biz Inc."#),
+        (1979_u16, r#"EPIC S.R.L."#),
+        (1980_u16, r#"KD CIRCUITS LLC"#),
+        (1981_u16, r#"Genedrive Diagnostics Ltd"#),
+        (1982_u16, r#"Axentia Technologies AB"#),
+        (1983_u16, r#"REGULA Ltd."#),
+        (1984_u16, r#"Biral AG"#),
+        (1985_u16, r#"A.W. Chesterton Company"#),
+        (1986_u16, r#"Radinn AB"#),
+        (1987_u16, r#"CIMTechniques, Inc."#),
+        (1988_u16, r#"Johnson Health Tech NA"#),
+        (1989_u16, r#"June Life, Inc."#),
+        (1990_u16, r#"Bluenetics GmbH"#),
+        (1991_u16, r#"iaconicDesign Inc."#),
+        (1992_u16, r#"WRLDS Creations AB"#),
+        (1993_u16, r#"Skullcandy, Inc."#),
+        (1994_u16, r#"Modul-System HH AB"#),
+        (1995_u16, r#"West Pharmaceutical Services, Inc."#),
+        (1996_u16, r#"Barnacle Systems Inc."#),
+        (1997_u16, r#"Smart Wave Technologies Canada Inc"#),
+        (1998_u16, r#"Shanghai Top-Chip Microelectronics Tech. Co., LTD"#),
+        (1999_u16, r#"NeoSensory, Inc."#),
+        (2000_u16, r#"Hangzhou Tuya Information  Technology Co., Ltd"#),
+        (2001_u16, r#"Shanghai Panchip Microelectronics Co., Ltd"#),
+        (2002_u16, r#"React Accessibility Limited"#),
+        (2003_u16, r#"LIVNEX Co.,Ltd."#),
+        (2004_u16, r#"Kano Computing Limited"#),
+        (2005_u16, r#"hoots classic GmbH"#),
+        (2006_u16, r#"ecobee Inc."#),
+        (2007_u16, r#"Nanjing Qinheng Microelectronics Co., Ltd"#),
+        (2008_u16, r#"SOLUTIONS AMBRA INC."#),
+        (2009_u16, r#"Micro-Design, Inc."#),
+        (2010_u16, r#"STARLITE Co., Ltd."#),
+        (2011_u16, r#"Remedee Labs"#),
+        (2012_u16, r#"ThingOS GmbH"#),
+        (2013_u16, r#"Linear Circuits"#),
+        (2014_u16, r#"Unlimited Engineering SL"#),
+        (2015_u16, r#"Snap-on Incorporated"#),
+        (2016_u16, r#"Edifier International Limited"#),
+        (2017_u16, r#"Lucie Labs"#),
+        (2018_u16, r#"Alfred Kaercher SE & Co. KG"#),
+        (2019_u16, r#"Audiowise Technology Inc."#),
+        (2020_u16, r#"Geeksme S.L."#),
+        (2021_u16, r#"Minut, Inc."#),
+        (2022_u16, r#"Autogrow Systems Limited"#),
+        (2023_u16, r#"Komfort IQ, Inc."#),
+        (2024_u16, r#"Packetcraft, Inc."#),
+        (2025_u16, r#"Häfele GmbH & Co KG"#),
+        (2026_u16, r#"ShapeLog, Inc."#),
+        (2027_u16, r#"NOVABASE S.R.L."#),
+        (2028_u16, r#"Frecce LLC"#),
+        (2029_u16, r#"Joule IQ, INC."#),
+        (2030_u16, r#"KidzTek LLC"#),
+        (2031_u16, r#"Aktiebolaget Sandvik Coromant"#),
+        (2032_u16, r#"e-moola.com Pty Ltd"#),
+        (2033_u16, r#"GSM Innovations Pty Ltd"#),
+        (2034_u16, r#"SERENE GROUP, INC"#),
+        (2035_u16, r#"DIGISINE ENERGYTECH CO. LTD."#),
+        (2036_u16, r#"MEDIRLAB Orvosbiologiai Fejleszto Korlatolt Felelossegu Tarsasag"#),
+        (2037_u16, r#"Byton North America Corporation"#),
+        (2038_u16, r#"Shenzhen TonliScience and Technology Development Co.,Ltd"#),
+        (2039_u16, r#"Cesar Systems Ltd."#),
+        (2040_u16, r#"quip NYC Inc."#),
+        (2041_u16, r#"Direct Communication Solutions, Inc."#),
+        (2042_u16, r#"Klipsch Group, Inc."#),
+        (2043_u16, r#"Access Co., Ltd"#),
+        (2044_u16, r#"Renault SA"#),
+        (2045_u16, r#"JSK CO., LTD."#),
+        (2046_u16, r#"BIROTA"#),
+        (2047_u16, r#"maxon motor ltd."#),
+        (2048_u16, r#"Optek"#),
+        (2049_u16, r#"CRONUS ELECTRONICS LTD"#),
+        (2050_u16, r#"NantSound, Inc."#),
+        (2051_u16, r#"Domintell s.a."#),
+        (2052_u16, r#"Andon Health Co.,Ltd"#),
+        (2053_u16, r#"Urbanminded Ltd"#),
+        (2054_u16, r#"TYRI Sweden AB"#),
+        (2055_u16, r#"ECD Electronic Components GmbH Dresden"#),
+        (2056_u16, r#"SISTEMAS KERN, SOCIEDAD ANÓMINA"#),
+        (2057_u16, r#"Trulli Audio"#),
+        (2058_u16, r#"Altaneos"#),
+        (2059_u16, r#"Nanoleaf Canada Limited"#),
+        (2060_u16, r#"Ingy B.V."#),
+        (2061_u16, r#"Azbil Co."#),
+        (2062_u16, r#"TATTCOM LLC"#),
+        (2063_u16, r#"Paradox Engineering SA"#),
+        (2064_u16, r#"LECO Corporation"#),
+        (2065_u16, r#"Becker Antriebe GmbH"#),
+        (2066_u16, r#"Mstream Technologies., Inc."#),
+        (2067_u16, r#"Flextronics International USA Inc."#),
+        (2068_u16, r#"Ossur hf."#),
+        (2069_u16, r#"SKC Inc"#),
+        (2070_u16, r#"SPICA SYSTEMS LLC"#),
+        (2071_u16, r#"Wangs Alliance Corporation"#),
+        (2072_u16, r#"tatwah SA"#),
+        (2073_u16, r#"Hunter Douglas Inc"#),
+        (2074_u16, r#"Shenzhen Conex"#),
+        (2075_u16, r#"DIM3"#),
+        (2076_u16, r#"Bobrick Washroom Equipment, Inc."#),
+        (2077_u16, r#"Potrykus Holdings and Development LLC"#),
+        (2078_u16, r#"iNFORM Technology GmbH"#),
+        (2079_u16, r#"eSenseLab LTD"#),
+        (2080_u16, r#"Brilliant Home Technology, Inc."#),
+        (2081_u16, r#"INOVA Geophysical, Inc."#),
+        (2082_u16, r#"adafruit industries"#),
+        (2083_u16, r#"Nexite Ltd"#),
+        (2084_u16, r#"8Power Limited"#),
+        (2085_u16, r#"CME PTE. LTD."#),
+        (2086_u16, r#"Hyundai Motor Company"#),
+        (2087_u16, r#"Kickmaker"#),
+        (2088_u16, r#"Shanghai Suisheng Information Technology Co., Ltd."#),
+        (2089_u16, r#"HEXAGON"#),
+        (2090_u16, r#"Mitutoyo Corporation"#),
+        (2091_u16, r#"shenzhen fitcare electronics Co.,Ltd"#),
+        (2092_u16, r#"INGICS TECHNOLOGY CO., LTD."#),
+        (2093_u16, r#"INCUS PERFORMANCE LTD."#),
+        (2094_u16, r#"ABB S.p.A."#),
+        (2095_u16, r#"Blippit AB"#),
+        (2096_u16, r#"Core Health and Fitness LLC"#),
+        (2097_u16, r#"Foxble, LLC"#),
+        (2098_u16, r#"Intermotive,Inc."#),
+        (2099_u16, r#"Conneqtech B.V."#),
+        (2100_u16, r#"RIKEN KEIKI CO., LTD.,"#),
+        (2101_u16, r#"Canopy Growth Corporation"#),
+        (2102_u16, r#"Bitwards Oy"#),
+        (2103_u16, r#"vivo Mobile Communication Co., Ltd."#),
+        (2104_u16, r#"Etymotic Research, Inc."#),
+        (2105_u16, r#"A puissance 3"#),
+        (2106_u16, r#"BPW Bergische Achsen Kommanditgesellschaft"#),
+        (2107_u16, r#"Piaggio Fast Forward"#),
+        (2108_u16, r#"BeerTech LTD"#),
+        (2109_u16, r#"Tokenize, Inc."#),
+        (2110_u16, r#"Zorachka LTD"#),
+        (2111_u16, r#"D-Link Corp."#),
+        (2112_u16, r#"Down Range Systems LLC"#),
+        (2113_u16, r#"General Luminaire (Shanghai) Co., Ltd."#),
+        (2114_u16, r#"Tangshan HongJia electronic technology co., LTD."#),
+        (2115_u16, r#"FRAGRANCE DELIVERY TECHNOLOGIES LTD"#),
+        (2116_u16, r#"Pepperl + Fuchs GmbH"#),
+        (2117_u16, r#"Dometic Corporation"#),
+        (2118_u16, r#"USound GmbH"#),
+        (2119_u16, r#"DNANUDGE LIMITED"#),
+        (2120_u16, r#"JUJU JOINTS CANADA CORP."#),
+        (2121_u16, r#"Dopple Technologies B.V."#),
+        (2122_u16, r#"ARCOM"#),
+        (2123_u16, r#"Biotechware SRL"#),
+        (2124_u16, r#"ORSO Inc."#),
+        (2125_u16, r#"SafePort"#),
+        (2126_u16, r#"Carol Cole Company"#),
+        (2127_u16, r#"Embedded Fitness B.V."#),
+        (2128_u16, r#"Yealink (Xiamen) Network Technology Co.,LTD"#),
+        (2129_u16, r#"Subeca, Inc."#),
+        (2130_u16, r#"Cognosos, Inc."#),
+        (2131_u16, r#"Pektron Group Limited"#),
+        (2132_u16, r#"Tap Sound System"#),
+        (2133_u16, r#"Helios Hockey, Inc."#),
+        (2134_u16, r#"Canopy Growth Corporation"#),
+        (2135_u16, r#"Parsyl Inc"#),
+        (2136_u16, r#"SOUNDBOKS"#),
+        (2137_u16, r#"BlueUp"#),
+        (2138_u16, r#"DAKATECH"#),
+        (2139_u16, r#"RICOH ELECTRONIC DEVICES CO., LTD."#),
+        (2140_u16, r#"ACOS CO.,LTD."#),
+        (2141_u16, r#"Guilin Zhishen Information Technology Co.,Ltd."#),
+        (2142_u16, r#"Krog Systems LLC"#),
+        (2143_u16, r#"COMPEGPS TEAM,SOCIEDAD LIMITADA"#),
+        (2144_u16, r#"Alflex Products B.V."#),
+        (2145_u16, r#"SmartSensor Labs Ltd"#),
+        (2146_u16, r#"SmartDrive Inc."#),
+        (2147_u16, r#"Yo-tronics Technology Co., Ltd."#),
+        (2148_u16, r#"Rafaelmicro"#),
+        (2149_u16, r#"Emergency Lighting Products Limited"#),
+        (2150_u16, r#"LAONZ Co.,Ltd"#),
+        (2151_u16, r#"Western Digital Techologies, Inc."#),
+        (2152_u16, r#"WIOsense GmbH & Co. KG"#),
+        (2153_u16, r#"EVVA Sicherheitstechnologie GmbH"#),
+        (2154_u16, r#"Odic Incorporated"#),
+        (2155_u16, r#"Pacific Track, LLC"#),
+        (2156_u16, r#"Revvo Technologies, Inc."#),
+        (2157_u16, r#"Biometrika d.o.o."#),
+        (2158_u16, r#"Vorwerk Elektrowerke GmbH & Co. KG"#),
+        (2159_u16, r#"Trackunit A/S"#),
+        (2160_u16, r#"Wyze Labs, Inc"#),
+        (2161_u16, r#"Dension Elektronikai Kft. (formerly: Dension Audio Systems Ltd.)"#),
+        (2162_u16, r#"11 Health & Technologies Limited"#),
+        (2163_u16, r#"Innophase Incorporated"#),
+        (2164_u16, r#"Treegreen Limited"#),
+        (2165_u16, r#"Berner International LLC"#),
+        (2166_u16, r#"SmartResQ ApS"#),
+        (2167_u16, r#"Tome, Inc."#),
+        (2168_u16, r#"The Chamberlain Group, Inc."#),
+        (2169_u16, r#"MIZUNO Corporation"#),
+        (2170_u16, r#"ZRF, LLC"#),
+        (2171_u16, r#"BYSTAMP"#),
+        (2172_u16, r#"Crosscan GmbH"#),
+        (2173_u16, r#"Konftel AB"#),
+        (2174_u16, r#"1bar.net Limited"#),
+        (2175_u16, r#"Phillips Connect Technologies LLC"#),
+        (2176_u16, r#"imagiLabs AB"#),
+        (2177_u16, r#"Optalert"#),
+        (2178_u16, r#"PSYONIC, Inc."#),
+        (2179_u16, r#"Wintersteiger AG"#),
+        (2180_u16, r#"Controlid Industria, Comercio de Hardware e Servicos de Tecnologia Ltda"#),
+        (2181_u16, r#"LEVOLOR, INC."#),
+        (2182_u16, r#"Xsens Technologies B.V."#),
+        (2183_u16, r#"Hydro-Gear Limited Partnership"#),
+        (2184_u16, r#"EnPointe Fencing Pty Ltd"#),
+        (2185_u16, r#"XANTHIO"#),
+        (2186_u16, r#"sclak s.r.l."#),
+        (2187_u16, r#"Tricorder Arraay Technologies LLC"#),
+        (2188_u16, r#"GB Solution co.,Ltd"#),
+        (2189_u16, r#"Soliton Systems K.K."#),
+        (2190_u16, r#"GIGA-TMS INC"#),
+        (2191_u16, r#"Tait International Limited"#),
+        (2192_u16, r#"NICHIEI INTEC CO., LTD."#),
+        (2193_u16, r#"SmartWireless GmbH & Co. KG"#),
+        (2194_u16, r#"Ingenieurbuero Birnfeld UG (haftungsbeschraenkt)"#),
+        (2195_u16, r#"Maytronics Ltd"#),
+        (2196_u16, r#"EPIFIT"#),
+        (2197_u16, r#"Gimer medical"#),
+        (2198_u16, r#"Nokian Renkaat Oyj"#),
+        (2199_u16, r#"Current Lighting Solutions LLC"#),
+        (2200_u16, r#"Sensibo, Inc."#),
+        (2201_u16, r#"SFS unimarket AG"#),
+        (2202_u16, r#"Private limited company "Teltonika""#),
+        (2203_u16, r#"Saucon Technologies"#),
+        (2204_u16, r#"Embedded Devices Co. Company"#),
+        (2205_u16, r#"J-J.A.D.E. Enterprise LLC"#),
+        (2206_u16, r#"i-SENS, inc."#),
+        (2207_u16, r#"Witschi Electronic Ltd"#),
+        (2208_u16, r#"Aclara Technologies LLC"#),
+        (2209_u16, r#"EXEO TECH CORPORATION"#),
+        (2210_u16, r#"Epic Systems Co., Ltd."#),
+        (2211_u16, r#"Hoffmann SE"#),
+        (2212_u16, r#"Realme Chongqing Mobile Telecommunications Corp., Ltd."#),
+        (2213_u16, r#"UMEHEAL Ltd"#),
+        (2214_u16, r#"Intelligenceworks Inc."#),
+        (2215_u16, r#"TGR 1.618 Limited"#),
+        (2216_u16, r#"Shanghai Kfcube Inc"#),
+        (2217_u16, r#"Fraunhofer IIS"#),
+        (2218_u16, r#"SZ DJI TECHNOLOGY CO.,LTD"#),
+        (2219_u16, r#"Coburn Technology, LLC"#),
+        (2220_u16, r#"Topre Corporation"#),
+        (2221_u16, r#"Kayamatics Limited"#),
+        (2222_u16, r#"Moticon ReGo AG"#),
+        (2223_u16, r#"Polidea Sp. z o.o."#),
+        (2224_u16, r#"Trivedi Advanced Technologies LLC"#),
+        (2225_u16, r#"CORE|vision BV"#),
+        (2226_u16, r#"PF SCHWEISSTECHNOLOGIE GMBH"#),
+        (2227_u16, r#"IONIQ Skincare GmbH & Co. KG"#),
+        (2228_u16, r#"Sengled Co., Ltd."#),
+        (2229_u16, r#"TransferFi"#),
+        (2230_u16, r#"Boehringer Ingelheim Vetmedica GmbH"#),
+        (2231_u16, r#"ABB Inc"#),
+        (2232_u16, r#"Check Technology Solutions LLC"#),
+        (2233_u16, r#"U-Shin Ltd."#),
+        (2234_u16, r#"HYPER ICE, INC."#),
+        (2235_u16, r#"Tokai-rika co.,ltd."#),
+        (2236_u16, r#"Prevayl Limited"#),
+        (2237_u16, r#"bf1systems limited"#),
+        (2238_u16, r#"ubisys technologies GmbH"#),
+        (2239_u16, r#"SIRC Co., Ltd."#),
+        (2240_u16, r#"Accent Advanced Systems SLU"#),
+        (2241_u16, r#"Rayden.Earth LTD"#),
+        (2242_u16, r#"Lindinvent AB"#),
+        (2243_u16, r#"CHIPOLO d.o.o."#),
+        (2244_u16, r#"CellAssist, LLC"#),
+        (2245_u16, r#"J. Wagner GmbH"#),
+        (2246_u16, r#"Integra Optics Inc"#),
+        (2247_u16, r#"Monadnock Systems Ltd."#),
+        (2248_u16, r#"Liteboxer Technologies Inc."#),
+        (2249_u16, r#"Noventa AG"#),
+        (2250_u16, r#"Nubia Technology Co.,Ltd."#),
+        (2251_u16, r#"JT INNOVATIONS LIMITED"#),
+        (2252_u16, r#"TGM TECHNOLOGY CO., LTD."#),
+        (2253_u16, r#"ifly"#),
+        (2254_u16, r#"ZIMI CORPORATION"#),
+        (2255_u16, r#"betternotstealmybike UG (with limited liability)"#),
+        (2256_u16, r#"ESTOM Infotech Kft."#),
+        (2257_u16, r#"Sensovium Inc."#),
+        (2258_u16, r#"Virscient Limited"#),
+        (2259_u16, r#"Novel Bits, LLC"#),
+        (2260_u16, r#"ADATA Technology Co., LTD."#),
+        (2261_u16, r#"KEYes"#),
+        (2262_u16, r#"Nome Oy"#),
+        (2263_u16, r#"Inovonics Corp"#),
+        (2264_u16, r#"WARES"#),
+        (2265_u16, r#"Pointr Labs Limited"#),
+        (2266_u16, r#"Miridia Technology Incorporated"#),
+        (2267_u16, r#"Tertium Technology"#),
+        (2268_u16, r#"SHENZHEN AUKEY E BUSINESS CO., LTD"#),
+        (2269_u16, r#"code-Q"#),
+        (2270_u16, r#"Tyco Electronics Corporation a TE Connectivity Ltd Company"#),
+        (2271_u16, r#"IRIS OHYAMA CO.,LTD."#),
+        (2272_u16, r#"Philia Technology"#),
+        (2273_u16, r#"KOZO KEIKAKU ENGINEERING Inc."#),
+        (2274_u16, r#"Shenzhen Simo Technology co. LTD"#),
+        (2275_u16, r#"Republic Wireless, Inc."#),
+        (2276_u16, r#"Rashidov ltd"#),
+        (2277_u16, r#"Crowd Connected Ltd"#),
+        (2278_u16, r#"Eneso Tecnologia de Adaptacion S.L."#),
+        (2279_u16, r#"Barrot Technology Limited"#),
+        (2280_u16, r#"Naonext"#),
+        (2281_u16, r#"Taiwan Intelligent Home Corp."#),
+        (2282_u16, r#"COWBELL ENGINEERING CO.,LTD."#),
+        (2283_u16, r#"Beijing Big Moment Technology Co., Ltd."#),
+        (2284_u16, r#"Denso Corporation"#),
+        (2285_u16, r#"IMI Hydronic Engineering International SA"#),
+        (2286_u16, r#"ASKEY"#),
+        (2287_u16, r#"Cumulus Digital Systems, Inc"#),
+        (2288_u16, r#"Joovv, Inc."#),
+        (2289_u16, r#"The L.S. Starrett Company"#),
+        (2290_u16, r#"Microoled"#),
+        (2291_u16, r#"PSP - Pauli Services & Products GmbH"#),
+        (2292_u16, r#"Kodimo Technologies Company Limited"#),
+        (2293_u16, r#"Tymtix Technologies Private Limited"#),
+        (2294_u16, r#"Dermal Photonics Corporation"#),
+        (2295_u16, r#"MTD Products Inc & Affiliates"#),
+        (2296_u16, r#"instagrid GmbH"#),
+        (2297_u16, r#"Spacelabs Medical Inc."#),
+        (2298_u16, r#"Troo Corporation"#),
+        (2299_u16, r#"Darkglass Electronics Oy"#),
+        (2300_u16, r#"Hill-Rom"#),
+        (2301_u16, r#"BioIntelliSense, Inc."#),
+        (2302_u16, r#"Ketronixs Sdn Bhd"#),
+        (2303_u16, r#"Plastimold Products, Inc"#),
+        (2304_u16, r#"Beijing Zizai Technology Co., LTD."#),
+        (2305_u16, r#"Lucimed"#),
+        (2306_u16, r#"TSC Auto-ID Technology Co., Ltd."#),
+        (2307_u16, r#"DATAMARS, Inc."#),
+        (2308_u16, r#"SUNCORPORATION"#),
+        (2309_u16, r#"Yandex Services AG"#),
+        (2310_u16, r#"Scope Logistical Solutions"#),
+        (2311_u16, r#"User Hello, LLC"#),
+        (2312_u16, r#"Pinpoint Innovations Limited"#),
+        (2313_u16, r#"70mai Co.,Ltd."#),
+        (2314_u16, r#"Zhuhai Hoksi Technology CO.,LTD"#),
+        (2315_u16, r#"EMBR labs, INC"#),
+        (2316_u16, r#"Radiawave Technologies Co.,Ltd."#),
+        (2317_u16, r#"IOT Invent GmbH"#),
+        (2318_u16, r#"OPTIMUSIOT TECH LLP"#),
+        (2319_u16, r#"VC Inc."#),
+        (2320_u16, r#"ASR Microelectronics (Shanghai) Co., Ltd."#),
+        (2321_u16, r#"Douglas Lighting Controls Inc."#),
+        (2322_u16, r#"Nerbio Medical Software Platforms Inc"#),
+        (2323_u16, r#"Braveheart Wireless, Inc."#),
+        (2324_u16, r#"INEO-SENSE"#),
+        (2325_u16, r#"Honda Motor Co., Ltd."#),
+        (2326_u16, r#"Ambient Sensors LLC"#),
+        (2327_u16, r#"ASR Microelectronics(ShenZhen)Co., Ltd."#),
+        (2328_u16, r#"Technosphere Labs Pvt. Ltd."#),
+        (2329_u16, r#"NO SMD LIMITED"#),
+        (2330_u16, r#"Albertronic BV"#),
+        (2331_u16, r#"Luminostics, Inc."#),
+        (2332_u16, r#"Oblamatik AG"#),
+        (2333_u16, r#"Innokind, Inc."#),
+        (2334_u16, r#"Melbot Studios, Sociedad Limitada"#),
+        (2335_u16, r#"Myzee Technology"#),
+        (2336_u16, r#"Omnisense Limited"#),
+        (2337_u16, r#"KAHA PTE. LTD."#),
+        (2338_u16, r#"Shanghai MXCHIP Information Technology Co., Ltd."#),
+        (2339_u16, r#"JSB TECH PTE LTD"#),
+        (2340_u16, r#"Fundacion Tecnalia Research and Innovation"#),
+        (2341_u16, r#"Yukai Engineering Inc."#),
+        (2342_u16, r#"Gooligum Technologies Pty Ltd"#),
+        (2343_u16, r#"ROOQ GmbH"#),
+        (2344_u16, r#"AiRISTA"#),
+        (2345_u16, r#"Qingdao Haier Technology Co., Ltd."#),
+        (2346_u16, r#"Sappl Verwaltungs- und Betriebs GmbH"#),
+        (2347_u16, r#"TekHome"#),
+        (2348_u16, r#"PCI Private Limited"#),
+        (2349_u16, r#"Leggett & Platt, Incorporated"#),
+        (2350_u16, r#"PS GmbH"#),
+        (2351_u16, r#"C.O.B.O. SpA"#),
+        (2352_u16, r#"James Walker RotaBolt Limited"#),
+        (2353_u16, r#"BREATHINGS Co., Ltd."#),
+        (2354_u16, r#"BarVision, LLC"#),
+        (2355_u16, r#"SRAM"#),
+        (2356_u16, r#"KiteSpring Inc."#),
+        (2357_u16, r#"Reconnect, Inc."#),
+        (2358_u16, r#"Elekon AG"#),
+        (2359_u16, r#"RealThingks GmbH"#),
+        (2360_u16, r#"Henway Technologies, LTD."#),
+        (2361_u16, r#"ASTEM Co.,Ltd."#),
+        (2362_u16, r#"LinkedSemi Microelectronics (Xiamen) Co., Ltd"#),
+        (2363_u16, r#"ENSESO LLC"#),
+        (2364_u16, r#"Xenoma Inc."#),
+        (2365_u16, r#"Adolf Wuerth GmbH & Co KG"#),
+        (2366_u16, r#"Catalyft Labs, Inc."#),
+        (2367_u16, r#"JEPICO Corporation"#),
+        (2368_u16, r#"Hero Workout GmbH"#),
+        (2369_u16, r#"Rivian Automotive, LLC"#),
+        (2370_u16, r#"TRANSSION HOLDINGS LIMITED"#),
+        (2371_u16, r#"Inovonics Corp."#),
+        (2372_u16, r#"Agitron d.o.o."#),
+        (2373_u16, r#"Globe (Jiangsu) Co., Ltd"#),
+        (2374_u16, r#"AMC International Alfa Metalcraft Corporation AG"#),
+        (2375_u16, r#"First Light Technologies Ltd."#),
+        (2376_u16, r#"Wearable Link Limited"#),
+        (2377_u16, r#"Metronom Health Europe"#),
+        (2378_u16, r#"Zwift, Inc."#),
+        (2379_u16, r#"Kindeva Drug Delivery L.P."#),
+        (2380_u16, r#"GimmiSys GmbH"#),
+        (2381_u16, r#"tkLABS INC."#),
+        (2382_u16, r#"PassiveBolt, Inc."#),
+        (2383_u16, r#"Limited Liability Company "Mikrotikls""#),
+        (2384_u16, r#"Capetech"#),
+        (2385_u16, r#"PPRS"#),
+        (2386_u16, r#"Apptricity Corporation"#),
+        (2387_u16, r#"LogiLube, LLC"#),
+        (2388_u16, r#"Julbo"#),
+        (2389_u16, r#"Breville Group"#),
+        (2390_u16, r#"Kerlink"#),
+        (2391_u16, r#"Ohsung Electronics"#),
+        (2392_u16, r#"ZTE Corporation"#),
+        (2393_u16, r#"HerdDogg, Inc"#),
+        (2394_u16, r#"Selekt Bilgisayar, lletisim Urunleri lnsaat Sanayi ve Ticaret Limited Sirketi"#),
+        (2395_u16, r#"Lismore Instruments Limited"#),
+        (2396_u16, r#"LogiLube, LLC"#),
+        (2397_u16, r#"ETC"#),
+        (2398_u16, r#"BioEchoNet inc."#),
+        (2399_u16, r#"NUANCE HEARING LTD"#),
+        (2400_u16, r#"Sena Technologies Inc."#),
+        (2401_u16, r#"Linkura AB"#),
+        (2402_u16, r#"GL Solutions K.K."#),
+        (2403_u16, r#"Moonbird BV"#),
+        (2404_u16, r#"Countrymate Technology Limited"#),
+        (2405_u16, r#"Asahi Kasei Corporation"#),
+        (2406_u16, r#"PointGuard, LLC"#),
+        (2407_u16, r#"Neo Materials and Consulting Inc."#),
+        (2408_u16, r#"Actev Motors, Inc."#),
+        (2409_u16, r#"Woan Technology (Shenzhen) Co., Ltd."#),
+        (2410_u16, r#"dricos, Inc."#),
+        (2411_u16, r#"Guide ID B.V."#),
+        (2412_u16, r#"9374-7319 Quebec inc"#),
+        (2413_u16, r#"Gunwerks, LLC"#),
+        (2414_u16, r#"Band Industries, inc."#),
+        (2415_u16, r#"Lund Motion Products, Inc."#),
+        (2416_u16, r#"IBA Dosimetry GmbH"#),
+        (2417_u16, r#"GA"#),
+        (2418_u16, r#"Closed Joint Stock Company "Zavod Flometr" ("Zavod Flometr" CJSC)"#),
+        (2419_u16, r#"Popit Oy"#),
+        (2420_u16, r#"ABEYE"#),
+        (2421_u16, r#"BlueIOT(Beijing) Technology Co.,Ltd"#),
+        (2422_u16, r#"Fauna Audio GmbH"#),
+        (2423_u16, r#"TOYOTA motor corporation"#),
+        (2424_u16, r#"ZifferEins GmbH & Co. KG"#),
+        (2425_u16, r#"BIOTRONIK SE & Co. KG"#),
+        (2426_u16, r#"CORE CORPORATION"#),
+        (2427_u16, r#"CTEK Sweden AB"#),
+        (2428_u16, r#"Thorley Industries, LLC"#),
+        (2429_u16, r#"CLB B.V."#),
+        (2430_u16, r#"SonicSensory Inc"#),
+        (2431_u16, r#"ISEMAR S.R.L."#),
+        (2432_u16, r#"DEKRA TESTING AND CERTIFICATION, S.A.U."#),
+        (2433_u16, r#"Bernard Krone Holding SE & Co.KG"#),
+        (2434_u16, r#"ELPRO-BUCHS AG"#),
+        (2435_u16, r#"Feedback Sports LLC"#),
+        (2436_u16, r#"TeraTron GmbH"#),
+        (2437_u16, r#"Lumos Health Inc."#),
+        (2438_u16, r#"Cello Hill, LLC"#),
+        (2439_u16, r#"TSE BRAKES, INC."#),
+        (2440_u16, r#"BHM-Tech Produktionsgesellschaft m.b.H"#),
+        (2441_u16, r#"WIKA Alexander Wiegand SE & Co.KG"#),
+        (2442_u16, r#"Biovigil"#),
+        (2443_u16, r#"Mequonic Engineering, S.L."#),
+        (2444_u16, r#"bGrid B.V."#),
+        (2445_u16, r#"C3-WIRELESS, LLC"#),
+        (2446_u16, r#"ADVEEZ"#),
+        (2447_u16, r#"Aktiebolaget Regin"#),
+        (2448_u16, r#"Anton Paar GmbH"#),
+        (2449_u16, r#"Telenor ASA"#),
+        (2450_u16, r#"Big Kaiser Precision Tooling Ltd"#),
+        (2451_u16, r#"Absolute Audio Labs B.V."#),
+        (2452_u16, r#"VT42 Pty Ltd"#),
+        (2453_u16, r#"Bronkhorst High-Tech B.V."#),
+        (2454_u16, r#"C. & E. Fein GmbH"#),
+        (2455_u16, r#"NextMind"#),
+        (2456_u16, r#"Pixie Dust Technologies, Inc."#),
+        (2457_u16, r#"eTactica ehf"#),
+        (2458_u16, r#"New Audio LLC"#),
+        (2459_u16, r#"Sendum Wireless Corporation"#),
+        (2460_u16, r#"deister electronic GmbH"#),
+        (2461_u16, r#"YKK AP Inc."#),
+        (2462_u16, r#"Step One Limited"#),
+        (2463_u16, r#"Koya Medical, Inc."#),
+        (2464_u16, r#"Proof Diagnostics, Inc."#),
+        (2465_u16, r#"VOS Systems, LLC"#),
+        (2466_u16, r#"ENGAGENOW DATA SCIENCES PRIVATE LIMITED"#),
+        (2467_u16, r#"ARDUINO SA"#),
+        (2468_u16, r#"KUMHO ELECTRICS, INC"#),
+        (2469_u16, r#"Security Enhancement Systems, LLC"#),
+        (2470_u16, r#"BEIJING ELECTRIC VEHICLE CO.,LTD"#),
+        (2471_u16, r#"Paybuddy ApS"#),
+        (2472_u16, r#"KHN Solutions Inc"#),
+        (2473_u16, r#"Nippon Ceramic Co.,Ltd."#),
+        (2474_u16, r#"PHOTODYNAMIC INCORPORATED"#),
+        (2475_u16, r#"DashLogic, Inc."#),
+        (2476_u16, r#"Ambiq"#),
+        (2477_u16, r#"Narhwall Inc."#),
+        (2478_u16, r#"Pozyx NV"#),
+        (2479_u16, r#"ifLink Open Community"#),
+        (2480_u16, r#"Deublin Company, LLC"#),
+        (2481_u16, r#"BLINQY"#),
+        (2482_u16, r#"DYPHI"#),
+        (2483_u16, r#"BlueX Microelectronics Corp Ltd."#),
+        (2484_u16, r#"PentaLock Aps."#),
+        (2485_u16, r#"AUTEC Gesellschaft fuer Automationstechnik mbH"#),
+        (2486_u16, r#"Pegasus Technologies, Inc."#),
+        (2487_u16, r#"Bout Labs, LLC"#),
+        (2488_u16, r#"PlayerData Limited"#),
+        (2489_u16, r#"SAVOY ELECTRONIC LIGHTING"#),
+        (2490_u16, r#"Elimo Engineering Ltd"#),
+        (2491_u16, r#"SkyStream Corporation"#),
+        (2492_u16, r#"Aerosens LLC"#),
+        (2493_u16, r#"Centre Suisse d'Electronique et de Microtechnique SA"#),
+        (2494_u16, r#"Vessel Ltd."#),
+        (2495_u16, r#"Span.IO, Inc."#),
+        (2496_u16, r#"AnotherBrain inc."#),
+        (2497_u16, r#"Rosewill"#),
+        (2498_u16, r#"Universal Audio, Inc."#),
+        (2499_u16, r#"JAPAN TOBACCO INC."#),
+        (2500_u16, r#"UVISIO"#),
+        (2501_u16, r#"HungYi Microelectronics Co.,Ltd."#),
+        (2502_u16, r#"Honor Device Co., Ltd."#),
+        (2503_u16, r#"Combustion, LLC"#),
+        (2504_u16, r#"XUNTONG"#),
+        (2505_u16, r#"CrowdGlow Ltd"#),
+        (2506_u16, r#"Mobitrace"#),
+        (2507_u16, r#"Hx Engineering, LLC"#),
+        (2508_u16, r#"Senso4s d.o.o."#),
+        (2509_u16, r#"Blyott"#),
+        (2510_u16, r#"Julius Blum GmbH"#),
+        (2511_u16, r#"BlueStreak IoT, LLC"#),
+        (2512_u16, r#"Chess Wise B.V."#),
+        (2513_u16, r#"ABLEPAY TECHNOLOGIES AS"#),
+        (2514_u16, r#"Temperature Sensitive Solutions Systems Sweden AB"#),
+        (2515_u16, r#"HeartHero, inc."#),
+        (2516_u16, r#"ORBIS Inc."#),
+        (2517_u16, r#"GEAR RADIO ELECTRONICS CORP."#),
+        (2518_u16, r#"EAR TEKNIK ISITME VE ODIOMETRI CIHAZLARI SANAYI VE TICARET ANONIM SIRKETI"#),
+        (2519_u16, r#"Coyotta"#),
+        (2520_u16, r#"Synergy Tecnologia em Sistemas Ltda"#),
+        (2521_u16, r#"VivoSensMedical GmbH"#),
+        (2522_u16, r#"Nagravision SA"#),
+        (2523_u16, r#"Bionic Avionics Inc."#),
+        (2524_u16, r#"AON2 Ltd."#),
+        (2525_u16, r#"Innoware Development AB"#),
+        (2526_u16, r#"JLD Technology Solutions, LLC"#),
+        (2527_u16, r#"Magnus Technology Sdn Bhd"#),
+        (2528_u16, r#"Preddio Technologies Inc."#),
+        (2529_u16, r#"Tag-N-Trac Inc"#),
+        (2530_u16, r#"Wuhan Linptech Co.,Ltd."#),
+        (2531_u16, r#"Friday Home Aps"#),
+        (2532_u16, r#"CPS AS"#),
+        (2533_u16, r#"Mobilogix"#),
+        (2534_u16, r#"Masonite Corporation"#),
+        (2535_u16, r#"Kabushikigaisha HANERON"#),
+        (2536_u16, r#"Melange Systems Pvt. Ltd."#),
+        (2537_u16, r#"LumenRadio AB"#),
+        (2538_u16, r#"Athlos Oy"#),
+        (2539_u16, r#"KEAN ELECTRONICS PTY LTD"#),
+        (2540_u16, r#"Yukon advanced optics worldwide, UAB"#),
+        (2541_u16, r#"Sibel Inc."#),
+        (2542_u16, r#"OJMAR SA"#),
+        (2543_u16, r#"Steinel Solutions AG"#),
+        (2544_u16, r#"WatchGas B.V."#),
+        (2545_u16, r#"OM Digital Solutions Corporation"#),
+        (2546_u16, r#"Audeara Pty Ltd"#),
+        (2547_u16, r#"Beijing Zero Zero Infinity Technology Co.,Ltd."#),
+        (2548_u16, r#"Spectrum Technologies, Inc."#),
+        (2549_u16, r#"OKI Electric Industry Co., Ltd"#),
+        (2550_u16, r#"Mobile Action Technology Inc."#),
+        (2551_u16, r#"SENSATEC Co., Ltd."#),
+        (2552_u16, r#"R.O. S.R.L."#),
+        (2553_u16, r#"Hangzhou Yaguan Technology Co. LTD"#),
+        (2554_u16, r#"Listen Technologies Corporation"#),
+        (2555_u16, r#"TOITU CO., LTD."#),
+        (2556_u16, r#"Confidex"#),
+        (2557_u16, r#"Keep Technologies, Inc."#),
+        (2558_u16, r#"Lichtvision Engineering GmbH"#),
+        (2559_u16, r#"AIRSTAR"#),
+        (2560_u16, r#"Ampler Bikes OU"#),
+        (2561_u16, r#"Cleveron AS"#),
+        (2562_u16, r#"Ayxon-Dynamics GmbH"#),
+        (2563_u16, r#"donutrobotics Co., Ltd."#),
+        (2564_u16, r#"Flosonics Medical"#),
+        (2565_u16, r#"Southwire Company, LLC"#),
+        (2566_u16, r#"Shanghai wuqi microelectronics Co.,Ltd"#),
+        (2567_u16, r#"Reflow Pty Ltd"#),
+        (2568_u16, r#"Oras Oy"#),
+        (2569_u16, r#"ECCT"#),
+        (2570_u16, r#"Volan Technology Inc."#),
+        (2571_u16, r#"SIANA Systems"#),
+        (2572_u16, r#"Shanghai Yidian Intelligent Technology Co., Ltd."#),
+        (2573_u16, r#"Blue Peacock GmbH"#),
+        (2574_u16, r#"Roland Corporation"#),
+        (2575_u16, r#"LIXIL Corporation"#),
+        (2576_u16, r#"SUBARU Corporation"#),
+        (2577_u16, r#"Sensolus"#),
+        (2578_u16, r#"Dyson Technology Limited"#),
+        (2579_u16, r#"Tec4med LifeScience GmbH"#),
+        (2580_u16, r#"CROXEL, INC."#),
+        (2581_u16, r#"Syng Inc"#),
+        (2582_u16, r#"RIDE VISION LTD"#),
+        (2583_u16, r#"Plume Design Inc"#),
+        (2584_u16, r#"Cambridge Animal Technologies Ltd"#),
+        (2585_u16, r#"Maxell, Ltd."#),
+        (2586_u16, r#"Link Labs, Inc."#),
+        (2587_u16, r#"Embrava Pty Ltd"#),
+        (2588_u16, r#"INPEAK S.C."#),
+        (2589_u16, r#"API-K"#),
+        (2590_u16, r#"CombiQ AB"#),
+        (2591_u16, r#"DeVilbiss Healthcare LLC"#),
+        (2592_u16, r#"Jiangxi Innotech Technology Co., Ltd"#),
+        (2593_u16, r#"Apollogic Sp. z o.o."#),
+        (2594_u16, r#"DAIICHIKOSHO CO., LTD."#),
+        (2595_u16, r#"BIXOLON CO.,LTD"#),
+        (2596_u16, r#"Atmosic Technologies, Inc."#),
+        (2597_u16, r#"Eran Financial Services LLC"#),
+        (2598_u16, r#"Louis Vuitton"#),
+        (2599_u16, r#"AYU DEVICES PRIVATE LIMITED"#),
+        (2600_u16, r#"NanoFlex"#),
+        (2601_u16, r#"Worthcloud Technology Co.,Ltd"#),
+        (2602_u16, r#"Yamaha Corporation"#),
+        (2603_u16, r#"PaceBait IVS"#),
+        (2604_u16, r#"Shenzhen H&T Intelligent Control Co., Ltd"#),
+        (2605_u16, r#"Shenzhen Feasycom Technology Co., Ltd."#),
+        (2606_u16, r#"Zuma Array Limited"#),
+        (2607_u16, r#"Instamic, Inc."#),
+        (2608_u16, r#"Air-Weigh"#),
+        (2609_u16, r#"Nevro Corp."#),
+        (2610_u16, r#"Pinnacle Technology, Inc."#),
+        (2611_u16, r#"WMF AG"#),
+        (2612_u16, r#"Luxer Corporation"#),
+        (2613_u16, r#"safectory GmbH"#),
+        (2614_u16, r#"NGK SPARK PLUG CO., LTD."#),
+        (2615_u16, r#"2587702 Ontario Inc."#),
+        (2616_u16, r#"Bouffalo Lab (Nanjing)., Ltd."#),
+        (2617_u16, r#"BLUETICKETING SRL"#),
+        (2618_u16, r#"Incotex Co. Ltd."#),
+        (2619_u16, r#"Galileo Technology Limited"#),
+        (2620_u16, r#"Siteco GmbH"#),
+        (2621_u16, r#"DELABIE"#),
+        (2622_u16, r#"Hefei Yunlian Semiconductor Co., Ltd"#),
+        (2623_u16, r#"Shenzhen Yopeak Optoelectronics Technology Co., Ltd."#),
+        (2624_u16, r#"GEWISS S.p.A."#),
+        (2625_u16, r#"OPEX Corporation"#),
+        (2626_u16, r#"Motionalysis, Inc."#),
+        (2627_u16, r#"Busch Systems International Inc."#),
+        (2628_u16, r#"Novidan, Inc."#),
+        (2629_u16, r#"3SI Security Systems, Inc"#),
+        (2630_u16, r#"Beijing HC-Infinite Technology Limited"#),
+        (2631_u16, r#"The Wand Company Ltd"#),
+        (2632_u16, r#"JRC Mobility Inc."#),
+        (2633_u16, r#"Venture Research Inc."#),
+        (2634_u16, r#"Map Large, Inc."#),
+        (2635_u16, r#"MistyWest Energy and Transport Ltd."#),
+        (2636_u16, r#"SiFli Technologies (shanghai) Inc."#),
+        (2637_u16, r#"Lockn Technologies Private Limited"#),
+        (2638_u16, r#"Toytec Corporation"#),
+        (2639_u16, r#"VANMOOF Global Holding B.V."#),
+        (2640_u16, r#"Nextscape Inc."#),
+        (2641_u16, r#"CSIRO"#),
+        (2642_u16, r#"Follow Sense Europe B.V."#),
+        (2643_u16, r#"KKM COMPANY LIMITED"#),
+        (2644_u16, r#"SQL Technologies Corp."#),
+        (2645_u16, r#"Inugo Systems Limited"#),
+        (2646_u16, r#"ambie"#),
+        (2647_u16, r#"Meizhou Guo Wei Electronics Co., Ltd"#),
+        (2648_u16, r#"Indigo Diabetes"#),
+        (2649_u16, r#"TourBuilt, LLC"#),
+        (2650_u16, r#"Sontheim Industrie Elektronik GmbH"#),
+        (2651_u16, r#"LEGIC Identsystems AG"#),
+        (2652_u16, r#"Innovative Design Labs Inc."#),
+        (2653_u16, r#"MG Energy Systems B.V."#),
+        (2654_u16, r#"LaceClips llc"#),
+        (2655_u16, r#"stryker"#),
+        (2656_u16, r#"DATANG SEMICONDUCTOR TECHNOLOGY CO.,LTD"#),
+        (2657_u16, r#"Smart Parks B.V."#),
+        (2658_u16, r#"MOKO TECHNOLOGY Ltd"#),
+        (2659_u16, r#"Gremsy JSC"#),
+        (2660_u16, r#"Geopal system A/S"#),
+        (2661_u16, r#"Lytx, INC."#),
+        (2662_u16, r#"JUSTMORPH PTE. LTD."#),
+        (2663_u16, r#"Beijing SuperHexa Century Technology CO. Ltd"#),
+        (2664_u16, r#"Focus Ingenieria SRL"#),
+        (2665_u16, r#"HAPPIEST BABY, INC."#),
+        (2666_u16, r#"Scribble Design Inc."#),
+        (2667_u16, r#"Olympic Ophthalmics, Inc."#),
+        (2668_u16, r#"Pokkels"#),
+        (2669_u16, r#"KUUKANJYOKIN Co.,Ltd."#),
+        (2670_u16, r#"Pac Sane Limited"#),
+        (2671_u16, r#"Warner Bros."#),
+        (2672_u16, r#"Ooma"#),
+        (2673_u16, r#"Senquip Pty Ltd"#),
+        (2674_u16, r#"Jumo GmbH & Co. KG"#),
+        (2675_u16, r#"Innohome Oy"#),
+        (2676_u16, r#"MICROSON S.A."#),
+        (2677_u16, r#"Delta Cycle Corporation"#),
+        (2678_u16, r#"Synaptics Incorporated"#),
+        (2679_u16, r#"JMD PACIFIC PTE. LTD."#),
+        (2680_u16, r#"Shenzhen Sunricher Technology Limited"#),
+        (2681_u16, r#"Webasto SE"#),
+        (2682_u16, r#"Emlid Limited"#),
+        (2683_u16, r#"UniqAir Oy"#),
+        (2684_u16, r#"WAFERLOCK"#),
+        (2685_u16, r#"Freedman Electronics Pty Ltd"#),
+        (2686_u16, r#"Keba AG"#),
+        (2687_u16, r#"Intuity Medical"#),
+    ]
+    .into_iter()
+    .map(|(id, name)| (Uuid16::from_be_bytes(id.to_be_bytes()), name))
+    .collect();
+}
diff --git a/rust/src/wrapper/assigned_numbers/mod.rs b/rust/src/wrapper/assigned_numbers/mod.rs
new file mode 100644
index 0000000..2584718
--- /dev/null
+++ b/rust/src/wrapper/assigned_numbers/mod.rs
@@ -0,0 +1,21 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Assigned numbers from the Bluetooth spec.
+
+mod company_ids;
+mod services;
+
+pub use company_ids::COMPANY_IDS;
+pub use services::SERVICE_IDS;
diff --git a/rust/src/wrapper/assigned_numbers/services.rs b/rust/src/wrapper/assigned_numbers/services.rs
new file mode 100644
index 0000000..da1c992
--- /dev/null
+++ b/rust/src/wrapper/assigned_numbers/services.rs
@@ -0,0 +1,82 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Assigned service IDs
+
+use crate::wrapper::core::Uuid16;
+use lazy_static::lazy_static;
+use std::collections;
+
+lazy_static! {
+    /// Assigned service IDs
+    pub static ref SERVICE_IDS: collections::HashMap<Uuid16, &'static str> = [
+        (0x1800_u16, "Generic Access"),
+        (0x1801, "Generic Attribute"),
+        (0x1802, "Immediate Alert"),
+        (0x1803, "Link Loss"),
+        (0x1804, "TX Power"),
+        (0x1805, "Current Time"),
+        (0x1806, "Reference Time Update"),
+        (0x1807, "Next DST Change"),
+        (0x1808, "Glucose"),
+        (0x1809, "Health Thermometer"),
+        (0x180A, "Device Information"),
+        (0x180D, "Heart Rate"),
+        (0x180E, "Phone Alert Status"),
+        (0x180F, "Battery"),
+        (0x1810, "Blood Pressure"),
+        (0x1811, "Alert Notification"),
+        (0x1812, "Human Interface Device"),
+        (0x1813, "Scan Parameters"),
+        (0x1814, "Running Speed and Cadence"),
+        (0x1815, "Automation IO"),
+        (0x1816, "Cycling Speed and Cadence"),
+        (0x1818, "Cycling Power"),
+        (0x1819, "Location and Navigation"),
+        (0x181A, "Environmental Sensing"),
+        (0x181B, "Body Composition"),
+        (0x181C, "User Data"),
+        (0x181D, "Weight Scale"),
+        (0x181E, "Bond Management"),
+        (0x181F, "Continuous Glucose Monitoring"),
+        (0x1820, "Internet Protocol Support"),
+        (0x1821, "Indoor Positioning"),
+        (0x1822, "Pulse Oximeter"),
+        (0x1823, "HTTP Proxy"),
+        (0x1824, "Transport Discovery"),
+        (0x1825, "Object Transfer"),
+        (0x1826, "Fitness Machine"),
+        (0x1827, "Mesh Provisioning"),
+        (0x1828, "Mesh Proxy"),
+        (0x1829, "Reconnection Configuration"),
+        (0x183A, "Insulin Delivery"),
+        (0x183B, "Binary Sensor"),
+        (0x183C, "Emergency Configuration"),
+        (0x183E, "Physical Activity Monitor"),
+        (0x1843, "Audio Input Control"),
+        (0x1844, "Volume Control"),
+        (0x1845, "Volume Offset Control"),
+        (0x1846, "Coordinated Set Identification Service"),
+        (0x1847, "Device Time"),
+        (0x1848, "Media Control Service"),
+        (0x1849, "Generic Media Control Service"),
+        (0x184A, "Constant Tone Extension"),
+        (0x184B, "Telephone Bearer Service"),
+        (0x184C, "Generic Telephone Bearer Service"),
+        (0x184D, "Microphone Control"),
+    ]
+    .into_iter()
+    .map(|(num, name)| (Uuid16::from_le_bytes(num.to_le_bytes()), name))
+    .collect();
+}
diff --git a/rust/src/wrapper/core.rs b/rust/src/wrapper/core.rs
new file mode 100644
index 0000000..bb171d1
--- /dev/null
+++ b/rust/src/wrapper/core.rs
@@ -0,0 +1,196 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Core types
+
+use crate::adv::CommonDataTypeCode;
+use lazy_static::lazy_static;
+use nom::{bytes, combinator};
+use pyo3::{intern, PyObject, PyResult, Python};
+use std::fmt;
+
+lazy_static! {
+    static ref BASE_UUID: [u8; 16] = hex::decode("0000000000001000800000805F9B34FB")
+        .unwrap()
+        .try_into()
+        .unwrap();
+}
+
+/// A type code and data pair from an advertisement
+pub type AdvertisementDataUnit = (CommonDataTypeCode, Vec<u8>);
+
+/// Contents of an advertisement
+pub struct AdvertisingData(pub(crate) PyObject);
+
+impl AdvertisingData {
+    /// Data units in the advertisement contents
+    pub fn data_units(&self) -> PyResult<Vec<AdvertisementDataUnit>> {
+        Python::with_gil(|py| {
+            let list = self.0.getattr(py, intern!(py, "ad_structures"))?;
+
+            list.as_ref(py)
+                .iter()?
+                .collect::<Result<Vec<_>, _>>()?
+                .into_iter()
+                .map(|tuple| {
+                    let type_code = tuple
+                        .call_method1(intern!(py, "__getitem__"), (0,))?
+                        .extract::<u8>()?
+                        .into();
+                    let data = tuple
+                        .call_method1(intern!(py, "__getitem__"), (1,))?
+                        .extract::<Vec<u8>>()?;
+                    Ok((type_code, data))
+                })
+                .collect::<Result<Vec<_>, _>>()
+        })
+    }
+}
+
+/// 16-bit UUID
+#[derive(PartialEq, Eq, Hash, Clone, Copy)]
+pub struct Uuid16 {
+    /// Big-endian bytes
+    uuid: [u8; 2],
+}
+
+impl Uuid16 {
+    /// Construct a UUID from little-endian bytes
+    pub fn from_le_bytes(mut bytes: [u8; 2]) -> Self {
+        bytes.reverse();
+        Self::from_be_bytes(bytes)
+    }
+
+    /// Construct a UUID from big-endian bytes
+    pub fn from_be_bytes(bytes: [u8; 2]) -> Self {
+        Self { uuid: bytes }
+    }
+
+    /// The UUID in big-endian bytes form
+    pub fn as_be_bytes(&self) -> [u8; 2] {
+        self.uuid
+    }
+
+    /// The UUID in little-endian bytes form
+    pub fn as_le_bytes(&self) -> [u8; 2] {
+        let mut uuid = self.uuid;
+        uuid.reverse();
+        uuid
+    }
+
+    pub(crate) fn parse_le(input: &[u8]) -> nom::IResult<&[u8], Self> {
+        combinator::map_res(bytes::complete::take(2_usize), |b: &[u8]| {
+            b.try_into().map(|mut uuid: [u8; 2]| {
+                uuid.reverse();
+                Self { uuid }
+            })
+        })(input)
+    }
+}
+
+impl fmt::Debug for Uuid16 {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "UUID-16:{}", hex::encode_upper(self.uuid))
+    }
+}
+
+/// 32-bit UUID
+#[derive(PartialEq, Eq, Hash)]
+pub struct Uuid32 {
+    /// Big-endian bytes
+    uuid: [u8; 4],
+}
+
+impl Uuid32 {
+    /// The UUID in big-endian bytes form
+    pub fn as_bytes(&self) -> [u8; 4] {
+        self.uuid
+    }
+
+    pub(crate) fn parse(input: &[u8]) -> nom::IResult<&[u8], Self> {
+        combinator::map_res(bytes::complete::take(4_usize), |b: &[u8]| {
+            b.try_into().map(|mut uuid: [u8; 4]| {
+                uuid.reverse();
+                Self { uuid }
+            })
+        })(input)
+    }
+}
+
+impl fmt::Debug for Uuid32 {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "UUID-32:{}", hex::encode_upper(self.uuid))
+    }
+}
+
+impl From<Uuid16> for Uuid32 {
+    fn from(value: Uuid16) -> Self {
+        let mut uuid = [0; 4];
+        uuid[2..].copy_from_slice(&value.uuid);
+        Self { uuid }
+    }
+}
+
+/// 128-bit UUID
+#[derive(PartialEq, Eq, Hash)]
+pub struct Uuid128 {
+    /// Big-endian bytes
+    uuid: [u8; 16],
+}
+
+impl Uuid128 {
+    /// The UUID in big-endian bytes form
+    pub fn as_bytes(&self) -> [u8; 16] {
+        self.uuid
+    }
+
+    pub(crate) fn parse_le(input: &[u8]) -> nom::IResult<&[u8], Self> {
+        combinator::map_res(bytes::complete::take(16_usize), |b: &[u8]| {
+            b.try_into().map(|mut uuid: [u8; 16]| {
+                uuid.reverse();
+                Self { uuid }
+            })
+        })(input)
+    }
+}
+
+impl fmt::Debug for Uuid128 {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "{}-{}-{}-{}-{}",
+            hex::encode_upper(&self.uuid[..4]),
+            hex::encode_upper(&self.uuid[4..6]),
+            hex::encode_upper(&self.uuid[6..8]),
+            hex::encode_upper(&self.uuid[8..10]),
+            hex::encode_upper(&self.uuid[10..])
+        )
+    }
+}
+
+impl From<Uuid16> for Uuid128 {
+    fn from(value: Uuid16) -> Self {
+        let mut uuid = *BASE_UUID;
+        uuid[2..4].copy_from_slice(&value.uuid);
+        Self { uuid }
+    }
+}
+
+impl From<Uuid32> for Uuid128 {
+    fn from(value: Uuid32) -> Self {
+        let mut uuid = *BASE_UUID;
+        uuid[..4].copy_from_slice(&value.uuid);
+        Self { uuid }
+    }
+}
diff --git a/rust/src/wrapper/device.rs b/rust/src/wrapper/device.rs
new file mode 100644
index 0000000..be5e4fa
--- /dev/null
+++ b/rust/src/wrapper/device.rs
@@ -0,0 +1,371 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Devices and connections to them
+
+use crate::{
+    adv::AdvertisementDataBuilder,
+    wrapper::{
+        core::AdvertisingData,
+        gatt_client::{ProfileServiceProxy, ServiceProxy},
+        hci::{Address, HciErrorCode},
+        host::Host,
+        l2cap::LeConnectionOrientedChannel,
+        transport::{Sink, Source},
+        ClosureCallback, PyDictExt, PyObjectExt,
+    },
+};
+use pyo3::{
+    intern,
+    types::{PyDict, PyModule},
+    IntoPy, PyObject, PyResult, Python, ToPyObject,
+};
+use pyo3_asyncio::tokio::into_future;
+use std::path;
+
+/// A device that can send/receive HCI frames.
+#[derive(Clone)]
+pub struct Device(PyObject);
+
+impl Device {
+    /// Create a Device per the provided file configured to communicate with a controller through an HCI source/sink
+    pub fn from_config_file_with_hci(
+        device_config: &path::Path,
+        source: Source,
+        sink: Sink,
+    ) -> PyResult<Self> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.device"))?
+                .getattr(intern!(py, "Device"))?
+                .call_method1(
+                    intern!(py, "from_config_file_with_hci"),
+                    (device_config, source.0, sink.0),
+                )
+                .map(|any| Self(any.into()))
+        })
+    }
+
+    /// Create a Device configured to communicate with a controller through an HCI source/sink
+    pub fn with_hci(name: &str, address: &str, source: Source, sink: Sink) -> PyResult<Self> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.device"))?
+                .getattr(intern!(py, "Device"))?
+                .call_method1(intern!(py, "with_hci"), (name, address, source.0, sink.0))
+                .map(|any| Self(any.into()))
+        })
+    }
+
+    /// Turn the device on
+    pub async fn power_on(&self) -> PyResult<()> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "power_on"))
+                .and_then(|coroutine| into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Connect to a peer
+    pub async fn connect(&self, peer_addr: &str) -> PyResult<Connection> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method1(py, intern!(py, "connect"), (peer_addr,))
+                .and_then(|coroutine| into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(Connection)
+    }
+
+    /// Register a callback to be called for each incoming connection.
+    pub fn on_connection(
+        &mut self,
+        callback: impl Fn(Python, Connection) -> PyResult<()> + Send + 'static,
+    ) -> PyResult<()> {
+        let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+            callback(py, Connection(args.get_item(0)?.into()))
+        });
+
+        Python::with_gil(|py| {
+            self.0
+                .call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
+        })
+        .map(|_| ())
+    }
+
+    /// Start scanning
+    pub async fn start_scanning(&self, filter_duplicates: bool) -> PyResult<()> {
+        Python::with_gil(|py| {
+            let kwargs = PyDict::new(py);
+            kwargs.set_item("filter_duplicates", filter_duplicates)?;
+            self.0
+                .call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
+                .and_then(|coroutine| into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Register a callback to be called for each advertisement
+    pub fn on_advertisement(
+        &mut self,
+        callback: impl Fn(Python, Advertisement) -> PyResult<()> + Send + 'static,
+    ) -> PyResult<()> {
+        let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+            callback(py, Advertisement(args.get_item(0)?.into()))
+        });
+
+        Python::with_gil(|py| {
+            self.0
+                .call_method1(py, intern!(py, "add_listener"), ("advertisement", boxed))
+        })
+        .map(|_| ())
+    }
+
+    /// Set the advertisement data to be used when [Device::start_advertising] is called.
+    pub fn set_advertising_data(&mut self, adv_data: AdvertisementDataBuilder) -> PyResult<()> {
+        Python::with_gil(|py| {
+            self.0.setattr(
+                py,
+                intern!(py, "advertising_data"),
+                adv_data.into_bytes().as_slice(),
+            )
+        })
+        .map(|_| ())
+    }
+
+    /// Returns the host used by the device, if any
+    pub fn host(&mut self) -> PyResult<Option<Host>> {
+        Python::with_gil(|py| {
+            self.0
+                .getattr(py, intern!(py, "host"))
+                .map(|obj| obj.into_option(Host::from))
+        })
+    }
+
+    /// Start advertising the data set with [Device.set_advertisement].
+    pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
+        Python::with_gil(|py| {
+            let kwargs = PyDict::new(py);
+            kwargs.set_item("auto_restart", auto_restart)?;
+
+            self.0
+                .call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
+                .and_then(|coroutine| into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Stop advertising.
+    pub async fn stop_advertising(&mut self) -> PyResult<()> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "stop_advertising"))
+                .and_then(|coroutine| into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Registers an L2CAP connection oriented channel server. When a client connects to the server,
+    /// the `server` callback is passed a handle to the established channel. When optional arguments
+    /// are not specified, the Python module specifies the defaults.
+    pub fn register_l2cap_channel_server(
+        &mut self,
+        psm: u16,
+        server: impl Fn(Python, LeConnectionOrientedChannel) -> PyResult<()> + Send + 'static,
+        max_credits: Option<u16>,
+        mtu: Option<u16>,
+        mps: Option<u16>,
+    ) -> PyResult<()> {
+        Python::with_gil(|py| {
+            let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+                server(
+                    py,
+                    LeConnectionOrientedChannel::from(args.get_item(0)?.into()),
+                )
+            });
+
+            let kwargs = PyDict::new(py);
+            kwargs.set_item("psm", psm)?;
+            kwargs.set_item("server", boxed.into_py(py))?;
+            kwargs.set_opt_item("max_credits", max_credits)?;
+            kwargs.set_opt_item("mtu", mtu)?;
+            kwargs.set_opt_item("mps", mps)?;
+            self.0.call_method(
+                py,
+                intern!(py, "register_l2cap_channel_server"),
+                (),
+                Some(kwargs),
+            )
+        })?;
+        Ok(())
+    }
+}
+
+/// A connection to a remote device.
+pub struct Connection(PyObject);
+
+impl Connection {
+    /// Open an L2CAP channel using this connection. When optional arguments are not specified, the
+    /// Python module specifies the defaults.
+    pub async fn open_l2cap_channel(
+        &mut self,
+        psm: u16,
+        max_credits: Option<u16>,
+        mtu: Option<u16>,
+        mps: Option<u16>,
+    ) -> PyResult<LeConnectionOrientedChannel> {
+        Python::with_gil(|py| {
+            let kwargs = PyDict::new(py);
+            kwargs.set_item("psm", psm)?;
+            kwargs.set_opt_item("max_credits", max_credits)?;
+            kwargs.set_opt_item("mtu", mtu)?;
+            kwargs.set_opt_item("mps", mps)?;
+            self.0
+                .call_method(py, intern!(py, "open_l2cap_channel"), (), Some(kwargs))
+                .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(LeConnectionOrientedChannel::from)
+    }
+
+    /// Disconnect from device with provided reason. When optional arguments are not specified, the
+    /// Python module specifies the defaults.
+    pub async fn disconnect(&mut self, reason: Option<HciErrorCode>) -> PyResult<()> {
+        Python::with_gil(|py| {
+            let kwargs = PyDict::new(py);
+            kwargs.set_opt_item("reason", reason)?;
+            self.0
+                .call_method(py, intern!(py, "disconnect"), (), Some(kwargs))
+                .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Register a callback to be called on disconnection.
+    pub fn on_disconnection(
+        &mut self,
+        callback: impl Fn(Python, HciErrorCode) -> PyResult<()> + Send + 'static,
+    ) -> PyResult<()> {
+        let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+            callback(py, args.get_item(0)?.extract()?)
+        });
+
+        Python::with_gil(|py| {
+            self.0
+                .call_method1(py, intern!(py, "add_listener"), ("disconnection", boxed))
+        })
+        .map(|_| ())
+    }
+
+    /// Returns some information about the connection as a [String].
+    pub fn debug_string(&self) -> PyResult<String> {
+        Python::with_gil(|py| {
+            let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
+            str_obj.gil_ref(py).extract()
+        })
+    }
+}
+
+/// The other end of a connection
+pub struct Peer(PyObject);
+
+impl Peer {
+    /// Wrap a [Connection] in a Peer
+    pub fn new(conn: Connection) -> PyResult<Self> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.device"))?
+                .getattr(intern!(py, "Peer"))?
+                .call1((conn.0,))
+                .map(|obj| Self(obj.into()))
+        })
+    }
+
+    /// Populates the peer's cache of services.
+    ///
+    /// Returns the discovered services.
+    pub async fn discover_services(&mut self) -> PyResult<Vec<ServiceProxy>> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "discover_services"))
+                .and_then(|coroutine| into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .and_then(|list| {
+            Python::with_gil(|py| {
+                list.as_ref(py)
+                    .iter()?
+                    .map(|r| r.map(|h| ServiceProxy(h.to_object(py))))
+                    .collect()
+            })
+        })
+    }
+
+    /// Returns a snapshot of the Services currently in the peer's cache
+    pub fn services(&self) -> PyResult<Vec<ServiceProxy>> {
+        Python::with_gil(|py| {
+            self.0
+                .getattr(py, intern!(py, "services"))?
+                .as_ref(py)
+                .iter()?
+                .map(|r| r.map(|h| ServiceProxy(h.to_object(py))))
+                .collect()
+        })
+    }
+
+    /// Build a [ProfileServiceProxy] for the specified type.
+    /// [Peer::discover_services] or some other means of populating the Peer's service cache must be
+    /// called first, or the required service won't be found.
+    pub fn create_service_proxy<P: ProfileServiceProxy>(&self) -> PyResult<Option<P>> {
+        Python::with_gil(|py| {
+            let module = py.import(P::PROXY_CLASS_MODULE)?;
+            let class = module.getattr(P::PROXY_CLASS_NAME)?;
+            self.0
+                .call_method1(py, intern!(py, "create_service_proxy"), (class,))
+                .map(|obj| obj.into_option(P::wrap))
+        })
+    }
+}
+
+/// A BLE advertisement
+pub struct Advertisement(PyObject);
+
+impl Advertisement {
+    /// Address that sent the advertisement
+    pub fn address(&self) -> PyResult<Address> {
+        Python::with_gil(|py| self.0.getattr(py, intern!(py, "address")).map(Address))
+    }
+
+    /// Returns true if the advertisement is connectable
+    pub fn is_connectable(&self) -> PyResult<bool> {
+        Python::with_gil(|py| {
+            self.0
+                .getattr(py, intern!(py, "is_connectable"))?
+                .extract::<bool>(py)
+        })
+    }
+
+    /// RSSI of the advertisement
+    pub fn rssi(&self) -> PyResult<i8> {
+        Python::with_gil(|py| self.0.getattr(py, intern!(py, "rssi"))?.extract::<i8>(py))
+    }
+
+    /// Data in the advertisement
+    pub fn data(&self) -> PyResult<AdvertisingData> {
+        Python::with_gil(|py| self.0.getattr(py, intern!(py, "data")).map(AdvertisingData))
+    }
+}
diff --git a/rust/src/wrapper/drivers/mod.rs b/rust/src/wrapper/drivers/mod.rs
new file mode 100644
index 0000000..ff38ac1
--- /dev/null
+++ b/rust/src/wrapper/drivers/mod.rs
@@ -0,0 +1,17 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Device drivers
+
+pub mod rtk;
diff --git a/rust/src/wrapper/drivers/rtk.rs b/rust/src/wrapper/drivers/rtk.rs
new file mode 100644
index 0000000..1f629d1
--- /dev/null
+++ b/rust/src/wrapper/drivers/rtk.rs
@@ -0,0 +1,141 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Drivers for Realtek controllers
+
+use crate::wrapper::{host::Host, PyObjectExt};
+use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
+use pyo3_asyncio::tokio::into_future;
+
+pub use crate::internal::drivers::rtk::{Firmware, Patch};
+
+/// Driver for a Realtek controller
+pub struct Driver(PyObject);
+
+impl Driver {
+    /// Locate the driver for the provided host.
+    pub async fn for_host(host: &Host, force: bool) -> PyResult<Option<Self>> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+                .getattr(intern!(py, "Driver"))?
+                .call_method1(intern!(py, "for_host"), (&host.obj, force))
+                .and_then(into_future)
+        })?
+        .await
+        .map(|obj| obj.into_option(Self))
+    }
+
+    /// Check if the host has a known driver.
+    pub async fn check(host: &Host) -> PyResult<bool> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+                .getattr(intern!(py, "Driver"))?
+                .call_method1(intern!(py, "check"), (&host.obj,))
+                .and_then(|obj| obj.extract::<bool>())
+        })
+    }
+
+    /// Find the [DriverInfo] for the host, if one matches
+    pub async fn driver_info_for_host(host: &Host) -> PyResult<Option<DriverInfo>> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+                .getattr(intern!(py, "Driver"))?
+                .call_method1(intern!(py, "driver_info_for_host"), (&host.obj,))
+                .and_then(into_future)
+        })?
+        .await
+        .map(|obj| obj.into_option(DriverInfo))
+    }
+
+    /// Send a command to the device to drop firmware
+    pub async fn drop_firmware(host: &mut Host) -> PyResult<()> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+                .getattr(intern!(py, "Driver"))?
+                .call_method1(intern!(py, "drop_firmware"), (&host.obj,))
+                .and_then(into_future)
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Load firmware onto the device.
+    pub async fn download_firmware(&mut self) -> PyResult<()> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "download_firmware"))
+                .and_then(|coroutine| into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+}
+
+/// Metadata about a known driver & applicable device
+pub struct DriverInfo(PyObject);
+
+impl DriverInfo {
+    /// Returns a list of all drivers that Bumble knows how to handle.
+    pub fn all_drivers() -> PyResult<Vec<DriverInfo>> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+                .getattr(intern!(py, "Driver"))?
+                .getattr(intern!(py, "DRIVER_INFOS"))?
+                .iter()?
+                .map(|r| r.map(|h| DriverInfo(h.to_object(py))))
+                .collect::<PyResult<Vec<_>>>()
+        })
+    }
+
+    /// The firmware file name to load from the filesystem, e.g. `foo_fw.bin`.
+    pub fn firmware_name(&self) -> PyResult<String> {
+        Python::with_gil(|py| {
+            self.0
+                .getattr(py, intern!(py, "fw_name"))?
+                .as_ref(py)
+                .extract::<String>()
+        })
+    }
+
+    /// The config file name, if any, to load from the filesystem, e.g. `foo_config.bin`.
+    pub fn config_name(&self) -> PyResult<Option<String>> {
+        Python::with_gil(|py| {
+            let obj = self.0.getattr(py, intern!(py, "config_name"))?;
+            let handle = obj.as_ref(py);
+
+            if handle.is_none() {
+                Ok(None)
+            } else {
+                handle
+                    .extract::<String>()
+                    .map(|s| if s.is_empty() { None } else { Some(s) })
+            }
+        })
+    }
+
+    /// Whether or not config is required.
+    pub fn config_needed(&self) -> PyResult<bool> {
+        Python::with_gil(|py| {
+            self.0
+                .getattr(py, intern!(py, "config_needed"))?
+                .as_ref(py)
+                .extract::<bool>()
+        })
+    }
+
+    /// ROM id
+    pub fn rom(&self) -> PyResult<u32> {
+        Python::with_gil(|py| self.0.getattr(py, intern!(py, "rom"))?.as_ref(py).extract())
+    }
+}
diff --git a/rust/src/wrapper/gatt_client.rs b/rust/src/wrapper/gatt_client.rs
new file mode 100644
index 0000000..aff1cb2
--- /dev/null
+++ b/rust/src/wrapper/gatt_client.rs
@@ -0,0 +1,79 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! GATT client support
+
+use crate::wrapper::ClosureCallback;
+use pyo3::types::PyTuple;
+use pyo3::{intern, PyObject, PyResult, Python};
+
+/// A GATT service on a remote device
+pub struct ServiceProxy(pub(crate) PyObject);
+
+impl ServiceProxy {
+    /// Discover the characteristics in this service.
+    ///
+    /// Populates an internal cache of characteristics in this service.
+    pub async fn discover_characteristics(&mut self) -> PyResult<()> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "discover_characteristics"))
+                .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+}
+
+/// A GATT characteristic on a remote device
+pub struct CharacteristicProxy(pub(crate) PyObject);
+
+impl CharacteristicProxy {
+    /// Subscribe to changes to the characteristic, executing `callback` for each new value
+    pub async fn subscribe(
+        &mut self,
+        callback: impl Fn(Python, &PyTuple) -> PyResult<()> + Send + 'static,
+    ) -> PyResult<()> {
+        let boxed = ClosureCallback::new(move |py, args, _kwargs| callback(py, args));
+
+        Python::with_gil(|py| {
+            self.0
+                .call_method1(py, intern!(py, "subscribe"), (boxed,))
+                .and_then(|obj| pyo3_asyncio::tokio::into_future(obj.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Read the current value of the characteristic
+    pub async fn read_value(&self) -> PyResult<PyObject> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "read_value"))
+                .and_then(|obj| pyo3_asyncio::tokio::into_future(obj.as_ref(py)))
+        })?
+        .await
+    }
+}
+
+/// Equivalent to the Python `ProfileServiceProxy`.
+pub trait ProfileServiceProxy {
+    /// The module containing the proxy class
+    const PROXY_CLASS_MODULE: &'static str;
+    /// The module class name
+    const PROXY_CLASS_NAME: &'static str;
+
+    /// Wrap a PyObject in the Rust wrapper type
+    fn wrap(obj: PyObject) -> Self;
+}
diff --git a/rust/src/wrapper/hci.rs b/rust/src/wrapper/hci.rs
new file mode 100644
index 0000000..41dcbf3
--- /dev/null
+++ b/rust/src/wrapper/hci.rs
@@ -0,0 +1,145 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! HCI
+
+use itertools::Itertools as _;
+use pyo3::{
+    exceptions::PyException, intern, types::PyModule, FromPyObject, PyAny, PyErr, PyObject,
+    PyResult, Python, ToPyObject,
+};
+
+/// HCI error code.
+pub struct HciErrorCode(u8);
+
+impl<'source> FromPyObject<'source> for HciErrorCode {
+    fn extract(ob: &'source PyAny) -> PyResult<Self> {
+        Ok(HciErrorCode(ob.extract()?))
+    }
+}
+
+impl ToPyObject for HciErrorCode {
+    fn to_object(&self, py: Python<'_>) -> PyObject {
+        self.0.to_object(py)
+    }
+}
+
+/// Provides helpers for interacting with HCI
+pub struct HciConstant;
+
+impl HciConstant {
+    /// Human-readable error name
+    pub fn error_name(status: HciErrorCode) -> PyResult<String> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.hci"))?
+                .getattr(intern!(py, "HCI_Constant"))?
+                .call_method1(intern!(py, "error_name"), (status.0,))?
+                .extract()
+        })
+    }
+}
+
+/// A Bluetooth address
+pub struct Address(pub(crate) PyObject);
+
+impl Address {
+    /// The type of address
+    pub fn address_type(&self) -> PyResult<AddressType> {
+        Python::with_gil(|py| {
+            let addr_type = self
+                .0
+                .getattr(py, intern!(py, "address_type"))?
+                .extract::<u32>(py)?;
+
+            let module = PyModule::import(py, intern!(py, "bumble.hci"))?;
+            let klass = module.getattr(intern!(py, "Address"))?;
+
+            if addr_type
+                == klass
+                    .getattr(intern!(py, "PUBLIC_DEVICE_ADDRESS"))?
+                    .extract::<u32>()?
+            {
+                Ok(AddressType::PublicDevice)
+            } else if addr_type
+                == klass
+                    .getattr(intern!(py, "RANDOM_DEVICE_ADDRESS"))?
+                    .extract::<u32>()?
+            {
+                Ok(AddressType::RandomDevice)
+            } else if addr_type
+                == klass
+                    .getattr(intern!(py, "PUBLIC_IDENTITY_ADDRESS"))?
+                    .extract::<u32>()?
+            {
+                Ok(AddressType::PublicIdentity)
+            } else if addr_type
+                == klass
+                    .getattr(intern!(py, "RANDOM_IDENTITY_ADDRESS"))?
+                    .extract::<u32>()?
+            {
+                Ok(AddressType::RandomIdentity)
+            } else {
+                Err(PyErr::new::<PyException, _>("Invalid address type"))
+            }
+        })
+    }
+
+    /// True if the address is static
+    pub fn is_static(&self) -> PyResult<bool> {
+        Python::with_gil(|py| {
+            self.0
+                .getattr(py, intern!(py, "is_static"))?
+                .extract::<bool>(py)
+        })
+    }
+
+    /// True if the address is resolvable
+    pub fn is_resolvable(&self) -> PyResult<bool> {
+        Python::with_gil(|py| {
+            self.0
+                .getattr(py, intern!(py, "is_resolvable"))?
+                .extract::<bool>(py)
+        })
+    }
+
+    /// Address bytes in _little-endian_ format
+    pub fn as_le_bytes(&self) -> PyResult<Vec<u8>> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "to_bytes"))?
+                .extract::<Vec<u8>>(py)
+        })
+    }
+
+    /// Address bytes as big-endian colon-separated hex
+    pub fn as_hex(&self) -> PyResult<String> {
+        self.as_le_bytes().map(|bytes| {
+            bytes
+                .into_iter()
+                .rev()
+                .map(|byte| hex::encode_upper([byte]))
+                .join(":")
+        })
+    }
+}
+
+/// BT address types
+#[allow(missing_docs)]
+#[derive(PartialEq, Eq, Debug)]
+pub enum AddressType {
+    PublicDevice,
+    RandomDevice,
+    PublicIdentity,
+    RandomIdentity,
+}
diff --git a/rust/src/wrapper/host.rs b/rust/src/wrapper/host.rs
new file mode 100644
index 0000000..ab81450
--- /dev/null
+++ b/rust/src/wrapper/host.rs
@@ -0,0 +1,71 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Host-side types
+
+use crate::wrapper::transport::{Sink, Source};
+use pyo3::{intern, prelude::PyModule, types::PyDict, PyObject, PyResult, Python};
+
+/// Host HCI commands
+pub struct Host {
+    pub(crate) obj: PyObject,
+}
+
+impl Host {
+    /// Create a Host that wraps the provided obj
+    pub(crate) fn from(obj: PyObject) -> Self {
+        Self { obj }
+    }
+
+    /// Create a new Host
+    pub fn new(source: Source, sink: Sink) -> PyResult<Self> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.host"))?
+                .getattr(intern!(py, "Host"))?
+                .call((source.0, sink.0), None)
+                .map(|any| Self { obj: any.into() })
+        })
+    }
+
+    /// Send a reset command and perform other reset tasks.
+    pub async fn reset(&mut self, driver_factory: DriverFactory) -> PyResult<()> {
+        Python::with_gil(|py| {
+            let kwargs = match driver_factory {
+                DriverFactory::None => {
+                    let kw = PyDict::new(py);
+                    kw.set_item("driver_factory", py.None())?;
+                    Some(kw)
+                }
+                DriverFactory::Auto => {
+                    // leave the default in place
+                    None
+                }
+            };
+            self.obj
+                .call_method(py, intern!(py, "reset"), (), kwargs)
+                .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+}
+
+/// Driver factory to use when initializing a host
+#[derive(Debug, Clone)]
+pub enum DriverFactory {
+    /// Do not load drivers
+    None,
+    /// Load appropriate driver, if any is found
+    Auto,
+}
diff --git a/rust/src/wrapper/l2cap.rs b/rust/src/wrapper/l2cap.rs
new file mode 100644
index 0000000..5e0752e
--- /dev/null
+++ b/rust/src/wrapper/l2cap.rs
@@ -0,0 +1,92 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! L2CAP
+
+use crate::wrapper::{ClosureCallback, PyObjectExt};
+use pyo3::{intern, PyObject, PyResult, Python};
+
+/// L2CAP connection-oriented channel
+pub struct LeConnectionOrientedChannel(PyObject);
+
+impl LeConnectionOrientedChannel {
+    /// Create a LeConnectionOrientedChannel that wraps the provided obj.
+    pub(crate) fn from(obj: PyObject) -> Self {
+        Self(obj)
+    }
+
+    /// Queues data to be automatically sent across this channel.
+    pub fn write(&mut self, data: &[u8]) -> PyResult<()> {
+        Python::with_gil(|py| self.0.call_method1(py, intern!(py, "write"), (data,))).map(|_| ())
+    }
+
+    /// Wait for queued data to be sent on this channel.
+    pub async fn drain(&mut self) -> PyResult<()> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "drain"))
+                .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Register a callback to be called when the channel is closed.
+    pub fn on_close(
+        &mut self,
+        callback: impl Fn(Python) -> PyResult<()> + Send + 'static,
+    ) -> PyResult<()> {
+        let boxed = ClosureCallback::new(move |py, _args, _kwargs| callback(py));
+
+        Python::with_gil(|py| {
+            self.0
+                .call_method1(py, intern!(py, "add_listener"), ("close", boxed))
+        })
+        .map(|_| ())
+    }
+
+    /// Register a callback to be called when the channel receives data.
+    pub fn set_sink(
+        &mut self,
+        callback: impl Fn(Python, &[u8]) -> PyResult<()> + Send + 'static,
+    ) -> PyResult<()> {
+        let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+            callback(py, args.get_item(0)?.extract()?)
+        });
+        Python::with_gil(|py| self.0.setattr(py, intern!(py, "sink"), boxed)).map(|_| ())
+    }
+
+    /// Disconnect the l2cap channel.
+    /// Must be called from a thread with a Python event loop, which should be true on
+    /// `tokio::main` and `async_std::main`.
+    ///
+    /// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
+    pub async fn disconnect(&mut self) -> PyResult<()> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "disconnect"))
+                .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Returns some information about the channel as a [String].
+    pub fn debug_string(&self) -> PyResult<String> {
+        Python::with_gil(|py| {
+            let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
+            str_obj.gil_ref(py).extract()
+        })
+    }
+}
diff --git a/rust/src/wrapper/logging.rs b/rust/src/wrapper/logging.rs
new file mode 100644
index 0000000..bd932cb
--- /dev/null
+++ b/rust/src/wrapper/logging.rs
@@ -0,0 +1,41 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Bumble & Python logging
+
+use pyo3::types::PyDict;
+use pyo3::{intern, types::PyModule, PyResult, Python};
+use std::env;
+
+/// Returns the uppercased contents of the `BUMBLE_LOGLEVEL` env var, or `default` if it is not present or not UTF-8.
+///
+/// The result could be passed to [py_logging_basic_config] to configure Python's logging
+/// accordingly.
+pub fn bumble_env_logging_level(default: impl Into<String>) -> String {
+    env::var("BUMBLE_LOGLEVEL")
+        .unwrap_or_else(|_| default.into())
+        .to_ascii_uppercase()
+}
+
+/// Call `logging.basicConfig` with the provided logging level
+pub fn py_logging_basic_config(log_level: impl Into<String>) -> PyResult<()> {
+    Python::with_gil(|py| {
+        let kwargs = PyDict::new(py);
+        kwargs.set_item("level", log_level.into())?;
+
+        PyModule::import(py, intern!(py, "logging"))?
+            .call_method(intern!(py, "basicConfig"), (), Some(kwargs))
+            .map(|_| ())
+    })
+}
diff --git a/rust/src/wrapper/mod.rs b/rust/src/wrapper/mod.rs
new file mode 100644
index 0000000..94ac15a
--- /dev/null
+++ b/rust/src/wrapper/mod.rs
@@ -0,0 +1,121 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Types that wrap the Python API.
+//!
+//! Because mutability, aliasing, etc is all hidden behind Python, the normal Rust rules about
+//! only one mutable reference to one piece of memory, etc, may not hold since using `&mut self`
+//! instead of `&self` is only guided by inspection of the Python source, not the compiler.
+//!
+//! The modules are generally structured to mirror the Python equivalents.
+
+// Re-exported to make it easy for users to depend on the same `PyObject`, etc
+pub use pyo3;
+use pyo3::{
+    prelude::*,
+    types::{PyDict, PyTuple},
+};
+pub use pyo3_asyncio;
+
+pub mod assigned_numbers;
+pub mod core;
+pub mod device;
+pub mod drivers;
+pub mod gatt_client;
+pub mod hci;
+pub mod host;
+pub mod l2cap;
+pub mod logging;
+pub mod profile;
+pub mod transport;
+
+/// Convenience extensions to [PyObject]
+pub trait PyObjectExt: Sized {
+    /// Get a GIL-bound reference
+    fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny;
+
+    /// Extract any [FromPyObject] implementation from this value
+    fn extract_with_gil<T>(&self) -> PyResult<T>
+    where
+        T: for<'a> FromPyObject<'a>,
+    {
+        Python::with_gil(|py| self.gil_ref(py).extract::<T>())
+    }
+
+    /// If the Python object is a Python `None`, return a Rust `None`, otherwise `Some` with the mapped type
+    fn into_option<T>(self, map_obj: impl Fn(Self) -> T) -> Option<T> {
+        Python::with_gil(|py| {
+            if self.gil_ref(py).is_none() {
+                None
+            } else {
+                Some(map_obj(self))
+            }
+        })
+    }
+}
+
+impl PyObjectExt for PyObject {
+    fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny {
+        self.as_ref(py)
+    }
+}
+
+/// Convenience extensions to [PyDict]
+pub trait PyDictExt {
+    /// Set item in dict only if value is Some, otherwise do nothing.
+    fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()>;
+}
+
+impl PyDictExt for PyDict {
+    fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()> {
+        if let Some(value) = value {
+            self.set_item(key, value)?
+        }
+        Ok(())
+    }
+}
+
+/// Wrapper to make Rust closures ([Fn] implementations) callable from Python.
+///
+/// The Python callable form returns a Python `None`.
+#[pyclass(name = "SubscribeCallback")]
+pub(crate) struct ClosureCallback {
+    // can't use generics in a pyclass, so have to box
+    #[allow(clippy::type_complexity)]
+    callback: Box<dyn Fn(Python, &PyTuple, Option<&PyDict>) -> PyResult<()> + Send + 'static>,
+}
+
+impl ClosureCallback {
+    /// Create a new callback around the provided closure
+    pub fn new(
+        callback: impl Fn(Python, &PyTuple, Option<&PyDict>) -> PyResult<()> + Send + 'static,
+    ) -> Self {
+        Self {
+            callback: Box::new(callback),
+        }
+    }
+}
+
+#[pymethods]
+impl ClosureCallback {
+    #[pyo3(signature = (*args, **kwargs))]
+    fn __call__(
+        &self,
+        py: Python<'_>,
+        args: &PyTuple,
+        kwargs: Option<&PyDict>,
+    ) -> PyResult<Py<PyAny>> {
+        (self.callback)(py, args, kwargs).map(|_| py.None())
+    }
+}
diff --git a/rust/src/wrapper/profile.rs b/rust/src/wrapper/profile.rs
new file mode 100644
index 0000000..fc473ff
--- /dev/null
+++ b/rust/src/wrapper/profile.rs
@@ -0,0 +1,44 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! GATT profiles
+
+use crate::wrapper::{
+    gatt_client::{CharacteristicProxy, ProfileServiceProxy},
+    PyObjectExt,
+};
+use pyo3::{intern, PyObject, PyResult, Python};
+
+/// Exposes the battery GATT service
+pub struct BatteryServiceProxy(PyObject);
+
+impl BatteryServiceProxy {
+    /// Get the battery level, if available
+    pub fn battery_level(&self) -> PyResult<Option<CharacteristicProxy>> {
+        Python::with_gil(|py| {
+            self.0
+                .getattr(py, intern!(py, "battery_level"))
+                .map(|level| level.into_option(CharacteristicProxy))
+        })
+    }
+}
+
+impl ProfileServiceProxy for BatteryServiceProxy {
+    const PROXY_CLASS_MODULE: &'static str = "bumble.profiles.battery_service";
+    const PROXY_CLASS_NAME: &'static str = "BatteryServiceProxy";
+
+    fn wrap(obj: PyObject) -> Self {
+        Self(obj)
+    }
+}
diff --git a/rust/src/wrapper/transport.rs b/rust/src/wrapper/transport.rs
new file mode 100644
index 0000000..6c9468d
--- /dev/null
+++ b/rust/src/wrapper/transport.rs
@@ -0,0 +1,72 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! HCI packet transport
+
+use pyo3::{intern, types::PyModule, PyObject, PyResult, Python};
+
+/// A source/sink pair for HCI packet I/O.
+///
+/// See <https://google.github.io/bumble/transports/index.html>.
+pub struct Transport(PyObject);
+
+impl Transport {
+    /// Open a new Transport for the provided spec, e.g. `"usb:0"` or `"android-netsim"`.
+    pub async fn open(transport_spec: impl Into<String>) -> PyResult<Self> {
+        Python::with_gil(|py| {
+            PyModule::import(py, intern!(py, "bumble.transport"))?
+                .call_method1(intern!(py, "open_transport"), (transport_spec.into(),))
+                .and_then(pyo3_asyncio::tokio::into_future)
+        })?
+        .await
+        .map(Self)
+    }
+
+    /// Close the transport.
+    pub async fn close(&mut self) -> PyResult<()> {
+        Python::with_gil(|py| {
+            self.0
+                .call_method0(py, intern!(py, "close"))
+                .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+        })?
+        .await
+        .map(|_| ())
+    }
+
+    /// Returns the source half of the transport.
+    pub fn source(&self) -> PyResult<Source> {
+        Python::with_gil(|py| self.0.getattr(py, intern!(py, "source"))).map(Source)
+    }
+
+    /// Returns the sink half of the transport.
+    pub fn sink(&self) -> PyResult<Sink> {
+        Python::with_gil(|py| self.0.getattr(py, intern!(py, "sink"))).map(Sink)
+    }
+}
+
+impl Drop for Transport {
+    fn drop(&mut self) {
+        // can't await in a Drop impl, but we can at least spawn a task to do it
+        let obj = self.0.clone();
+        tokio::spawn(async move { Self(obj).close().await });
+    }
+}
+
+/// The source side of a [Transport].
+#[derive(Clone)]
+pub struct Source(pub(crate) PyObject);
+
+/// The sink side of a [Transport].
+#[derive(Clone)]
+pub struct Sink(pub(crate) PyObject);
diff --git a/rust/tools/file_header.rs b/rust/tools/file_header.rs
new file mode 100644
index 0000000..fb3286d
--- /dev/null
+++ b/rust/tools/file_header.rs
@@ -0,0 +1,78 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use anyhow::anyhow;
+use clap::Parser as _;
+use file_header::{
+    add_headers_recursively, check_headers_recursively,
+    license::spdx::{YearCopyrightOwnerValue, APACHE_2_0},
+};
+use globset::{Glob, GlobSet, GlobSetBuilder};
+use std::{env, path::PathBuf};
+
+fn main() -> anyhow::Result<()> {
+    let rust_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
+    let ignore_globset = ignore_globset()?;
+    // Note: when adding headers, there is a bug where the line spacing is off for Apache 2.0 (see https://github.com/spdx/license-list-XML/issues/2127)
+    let header = APACHE_2_0.build_header(YearCopyrightOwnerValue::new(2023, "Google LLC".into()));
+
+    let cli = Cli::parse();
+
+    match cli.subcommand {
+        Subcommand::CheckAll => {
+            let result =
+                check_headers_recursively(&rust_dir, |p| !ignore_globset.is_match(p), header, 4)?;
+            if result.has_failure() {
+                return Err(anyhow!(
+                    "The following files do not have headers: {result:?}"
+                ));
+            }
+        }
+        Subcommand::AddAll => {
+            let files_with_new_header =
+                add_headers_recursively(&rust_dir, |p| !ignore_globset.is_match(p), header)?;
+            files_with_new_header
+                .iter()
+                .for_each(|path| println!("Added header to: {path:?}"));
+        }
+    }
+    Ok(())
+}
+
+fn ignore_globset() -> anyhow::Result<GlobSet> {
+    Ok(GlobSetBuilder::new()
+        .add(Glob::new("**/.idea/**")?)
+        .add(Glob::new("**/target/**")?)
+        .add(Glob::new("**/.gitignore")?)
+        .add(Glob::new("**/CHANGELOG.md")?)
+        .add(Glob::new("**/Cargo.lock")?)
+        .add(Glob::new("**/Cargo.toml")?)
+        .add(Glob::new("**/README.md")?)
+        .add(Glob::new("*.bin")?)
+        .build()?)
+}
+
+#[derive(clap::Parser)]
+struct Cli {
+    #[clap(subcommand)]
+    subcommand: Subcommand,
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum Subcommand {
+    /// Checks if a license is present in files that are not in the ignore list.
+    CheckAll,
+    /// Adds a license as needed to files that are not in the ignore list.
+    AddAll,
+}
diff --git a/rust/tools/gen_assigned_numbers.rs b/rust/tools/gen_assigned_numbers.rs
new file mode 100644
index 0000000..b2c525e
--- /dev/null
+++ b/rust/tools/gen_assigned_numbers.rs
@@ -0,0 +1,97 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! This tool generates Rust code with assigned number tables from the equivalent Python.
+
+use pyo3::{
+    intern,
+    types::{PyDict, PyModule},
+    PyResult, Python,
+};
+use std::{collections, env, fs, path};
+
+fn main() -> anyhow::Result<()> {
+    pyo3::prepare_freethreaded_python();
+    let mut dir = path::Path::new(&env::var("CARGO_MANIFEST_DIR")?).to_path_buf();
+    dir.push("src/wrapper/assigned_numbers");
+
+    company_ids(&dir)?;
+
+    Ok(())
+}
+
+fn company_ids(base_dir: &path::Path) -> anyhow::Result<()> {
+    let mut sorted_ids = load_company_ids()?.into_iter().collect::<Vec<_>>();
+    sorted_ids.sort_by_key(|(id, _name)| *id);
+
+    let mut contents = String::new();
+    contents.push_str(LICENSE_HEADER);
+    contents.push_str("\n\n");
+    contents.push_str(
+        "// auto-generated by gen_assigned_numbers, do not edit
+
+use crate::wrapper::core::Uuid16;
+use lazy_static::lazy_static;
+use std::collections;
+
+lazy_static! {
+    /// Assigned company IDs
+    pub static ref COMPANY_IDS: collections::HashMap<Uuid16, &'static str> = [
+",
+    );
+
+    for (id, name) in sorted_ids {
+        contents.push_str(&format!("        ({id}_u16, r#\"{name}\"#),\n"))
+    }
+
+    contents.push_str(
+        "    ]
+    .into_iter()
+    .map(|(id, name)| (Uuid16::from_be_bytes(id.to_be_bytes()), name))
+    .collect();
+}
+",
+    );
+
+    let mut company_ids = base_dir.to_path_buf();
+    company_ids.push("company_ids.rs");
+    fs::write(&company_ids, contents)?;
+
+    Ok(())
+}
+
+fn load_company_ids() -> PyResult<collections::HashMap<u16, String>> {
+    Python::with_gil(|py| {
+        PyModule::import(py, intern!(py, "bumble.company_ids"))?
+            .getattr(intern!(py, "COMPANY_IDENTIFIERS"))?
+            .downcast::<PyDict>()?
+            .into_iter()
+            .map(|(k, v)| Ok((k.extract::<u16>()?, v.str()?.to_str()?.to_string())))
+            .collect::<PyResult<collections::HashMap<_, _>>>()
+    })
+}
+
+const LICENSE_HEADER: &str = r#"// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License."#;
diff --git a/setup.cfg b/setup.cfg
index 45c7264..1ca73c7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -24,28 +24,35 @@
 
 [options]
 python_requires = >=3.8
-packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora
+packages = bumble, bumble.transport, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
 package_dir =
     bumble = bumble
     bumble.apps = apps
-include-package-data = True
+    bumble.tools = tools
+include_package_data = True
 install_requires =
-    appdirs >= 1.4
-    click >= 7.1.2; platform_system!='Emscripten'
-    cryptography == 35; platform_system!='Emscripten'
-    grpcio == 1.51.1; platform_system!='Emscripten'
+    aiohttp ~= 3.8; platform_system!='Emscripten'
+    appdirs >= 1.4; platform_system!='Emscripten'
+    bt-test-interfaces >= 0.0.2; platform_system!='Emscripten'
+    click == 8.1.3; platform_system!='Emscripten'
+    cryptography == 39; platform_system!='Emscripten'
+    # Pyodide bundles a version of cryptography that is built for wasm, which may not match the
+    # versions available on PyPI. Relax the version requirement since it's better than being
+    # completely unable to import the package in case of version mismatch.
+    cryptography >= 39.0; platform_system=='Emscripten'
+    grpcio == 1.57.0; platform_system!='Emscripten'
+    humanize >= 4.6.0; platform_system!='Emscripten'
     libusb1 >= 2.0.1; platform_system!='Emscripten'
     libusb-package == 1.0.26.1; platform_system!='Emscripten'
+    platformdirs == 3.10.0; platform_system!='Emscripten'
     prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
-    protobuf >= 3.12.4
+    prettytable >= 3.6.0; platform_system!='Emscripten'
+    protobuf >= 3.12.4; platform_system!='Emscripten'
     pyee >= 8.2.2
     pyserial-asyncio >= 0.5; platform_system!='Emscripten'
     pyserial >= 3.5; platform_system!='Emscripten'
     pyusb >= 1.2; platform_system!='Emscripten'
     websockets >= 8.1; platform_system!='Emscripten'
-    prettytable >= 3.6.0
-    humanize >= 4.6.0
-    bt-test-interfaces >= 0.0.2
 
 [options.entry_points]
 console_scripts =
@@ -61,7 +68,10 @@
     bumble-usb-probe = bumble.apps.usb_probe:main
     bumble-link-relay = bumble.apps.link_relay.link_relay:main
     bumble-bench = bumble.apps.bench:main
+    bumble-speaker = bumble.apps.speaker.speaker:main
     bumble-pandora-server = bumble.apps.pandora_server:main
+    bumble-rtk-util = bumble.tools.rtk_util:main
+    bumble-rtk-fw-download = bumble.tools.rtk_fw_download:main
 
 [options.package_data]
 * = py.typed, *.pyi
@@ -76,9 +86,9 @@
     coverage >= 6.4
 development =
     black == 22.10
-    grpcio-tools >= 1.51.1
+    grpcio-tools >= 1.57.0
     invoke >= 1.7.3
-    mypy == 1.2.0
+    mypy == 1.5.0
     nox >= 2022
     pylint == 2.15.8
     types-appdirs >= 1.4.3
diff --git a/tasks.py b/tasks.py
index 3a3a01a..6df5a8b 100644
--- a/tasks.py
+++ b/tasks.py
@@ -177,3 +177,33 @@
 project_tasks.add_task(format_code, name="format")
 project_tasks.add_task(check_types, name="check-types")
 project_tasks.add_task(pre_commit)
+
+
+# -----------------------------------------------------------------------------
+# Web
+# -----------------------------------------------------------------------------
+web_tasks = Collection()
+ns.add_collection(web_tasks, name="web")
+
+
+# -----------------------------------------------------------------------------
+@task
+def serve(ctx, port=8000):
+    """
+    Run a simple HTTP server for the examples under the `web` directory.
+    """
+    import http.server
+
+    address = ("", port)
+
+    class Handler(http.server.SimpleHTTPRequestHandler):
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, directory="web", **kwargs)
+
+    server = http.server.HTTPServer(address, Handler)
+    print(f"Now serving on port {port} 🕸️")
+    server.serve_forever()
+
+
+# -----------------------------------------------------------------------------
+web_tasks.add_task(serve)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..1e45f74
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/tests/at_test.py b/tests/at_test.py
new file mode 100644
index 0000000..a0f00dd
--- /dev/null
+++ b/tests/at_test.py
@@ -0,0 +1,35 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from bumble import at
+
+
+def test_tokenize_parameters():
+    assert at.tokenize_parameters(b'1, 2, 3') == [b'1', b',', b'2', b',', b'3']
+    assert at.tokenize_parameters(b'"1, 2, 3"') == [b'1, 2, 3']
+    assert at.tokenize_parameters(b'(1, "2, 3")') == [b'(', b'1', b',', b'2, 3', b')']
+
+
+def test_parse_parameters():
+    assert at.parse_parameters(b'1, 2, 3') == [b'1', b'2', b'3']
+    assert at.parse_parameters(b'1,, 3') == [b'1', b'', b'3']
+    assert at.parse_parameters(b'"1, 2, 3"') == [b'1, 2, 3']
+    assert at.parse_parameters(b'1, (2, (3))') == [b'1', [b'2', [b'3']]]
+    assert at.parse_parameters(b'1, (2, "3, 4"), 5') == [b'1', [b'2', b'3, 4'], b'5']
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    test_tokenize_parameters()
+    test_parse_parameters()
diff --git a/tests/codecs_test.py b/tests/codecs_test.py
new file mode 100644
index 0000000..b8affad
--- /dev/null
+++ b/tests/codecs_test.py
@@ -0,0 +1,67 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import pytest
+from bumble.codecs import AacAudioRtpPacket, BitReader
+
+
+# -----------------------------------------------------------------------------
+def test_reader():
+    reader = BitReader(b'')
+    with pytest.raises(ValueError):
+        reader.read(1)
+
+    reader = BitReader(b'hello')
+    with pytest.raises(ValueError):
+        reader.read(40)
+
+    reader = BitReader(bytes([0xFF]))
+    assert reader.read(1) == 1
+    with pytest.raises(ValueError):
+        reader.read(10)
+
+    reader = BitReader(bytes([0x78]))
+    value = 0
+    for _ in range(8):
+        value = (value << 1) | reader.read(1)
+    assert value == 0x78
+
+    data = bytes([x & 0xFF for x in range(66 * 100)])
+    reader = BitReader(data)
+    value = 0
+    for _ in range(100):
+        for bits in range(1, 33):
+            value = value << bits | reader.read(bits)
+    assert value == int.from_bytes(data, byteorder='big')
+
+
+def test_aac_rtp():
+    # pylint: disable=line-too-long
+    packet_data = bytes.fromhex(
+        '47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c'
+    )
+    packet = AacAudioRtpPacket(packet_data)
+    adts = packet.to_adts()
+    assert adts == bytes.fromhex(
+        'fff1508004fffc2066000198000de120000000000000000000000000000000000000000000001c'
+    )
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    test_reader()
+    test_aac_rtp()
diff --git a/tests/gatt_test.py b/tests/gatt_test.py
index 0652197..d9f6d60 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -803,14 +803,14 @@
 # -----------------------------------------------------------------------------
 def test_char_property_to_string():
     # single
-    assert str(Characteristic.Properties(0x01)) == "Properties.BROADCAST"
-    assert str(Characteristic.Properties.BROADCAST) == "Properties.BROADCAST"
+    assert str(Characteristic.Properties(0x01)) == "BROADCAST"
+    assert str(Characteristic.Properties.BROADCAST) == "BROADCAST"
 
     # double
-    assert str(Characteristic.Properties(0x03)) == "Properties.READ|BROADCAST"
+    assert str(Characteristic.Properties(0x03)) == "BROADCAST|READ"
     assert (
         str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ)
-        == "Properties.READ|BROADCAST"
+        == "BROADCAST|READ"
     )
 
 
@@ -831,6 +831,10 @@
         Characteristic.Properties.from_string("READ,BROADCAST")
         == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
     )
+    assert (
+        Characteristic.Properties.from_string("BROADCAST|READ")
+        == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
+    )
 
 
 # -----------------------------------------------------------------------------
@@ -841,7 +845,7 @@
     assert (
         str(e_info.value)
         == """Characteristic.Properties::from_string() error:
-Expected a string containing any of the keys, separated by commas: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
+Expected a string containing any of the keys, separated by , or |: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
 Got: BROADCAST,HELLO"""
     )
 
@@ -866,13 +870,13 @@
     assert (
         str(server.gatt_server)
         == """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access))
-CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
-Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
-CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
-Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
+CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
+Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
+CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
+Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
 Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
-CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
-Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
+CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
+Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
 Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
     )
 
@@ -887,10 +891,10 @@
 
 
 # -----------------------------------------------------------------------------
-def test_attribute_string_to_permissions():
-    assert Attribute.string_to_permissions('READABLE') == 1
-    assert Attribute.string_to_permissions('WRITEABLE') == 2
-    assert Attribute.string_to_permissions('READABLE,WRITEABLE') == 3
+def test_permissions_from_string():
+    assert Attribute.Permissions.from_string('READABLE') == 1
+    assert Attribute.Permissions.from_string('WRITEABLE') == 2
+    assert Attribute.Permissions.from_string('READABLE,WRITEABLE') == 3
 
 
 # -----------------------------------------------------------------------------
diff --git a/tests/hci_test.py b/tests/hci_test.py
index af68e86..c648592 100644
--- a/tests/hci_test.py
+++ b/tests/hci_test.py
@@ -46,6 +46,7 @@
     HCI_LE_Set_Advertising_Parameters_Command,
     HCI_LE_Set_Default_PHY_Command,
     HCI_LE_Set_Event_Mask_Command,
+    HCI_LE_Set_Extended_Advertising_Enable_Command,
     HCI_LE_Set_Extended_Scan_Parameters_Command,
     HCI_LE_Set_Random_Address_Command,
     HCI_LE_Set_Scan_Enable_Command,
@@ -423,6 +424,25 @@
 
 
 # -----------------------------------------------------------------------------
+def test_HCI_LE_Set_Extended_Advertising_Enable_Command():
+    command = HCI_Packet.from_bytes(
+        bytes.fromhex('0139200e010301050008020600090307000a')
+    )
+    assert command.enable == 1
+    assert command.advertising_handles == [1, 2, 3]
+    assert command.durations == [5, 6, 7]
+    assert command.max_extended_advertising_events == [8, 9, 10]
+
+    command = HCI_LE_Set_Extended_Advertising_Enable_Command(
+        enable=1,
+        advertising_handles=[1, 2, 3],
+        durations=[5, 6, 7],
+        max_extended_advertising_events=[8, 9, 10],
+    )
+    basic_check(command)
+
+
+# -----------------------------------------------------------------------------
 def test_address():
     a = Address('C4:F2:17:1A:1D:BB')
     assert not a.is_public
@@ -478,6 +498,7 @@
     test_HCI_LE_Read_Remote_Features_Command()
     test_HCI_LE_Set_Default_PHY_Command()
     test_HCI_LE_Set_Extended_Scan_Parameters_Command()
+    test_HCI_LE_Set_Extended_Advertising_Enable_Command()
 
 
 # -----------------------------------------------------------------------------
diff --git a/tests/hfp_test.py b/tests/hfp_test.py
new file mode 100644
index 0000000..481d0b7
--- /dev/null
+++ b/tests/hfp_test.py
@@ -0,0 +1,100 @@
+# 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 pytest
+
+from typing import Tuple
+
+from .test_utils import TwoDevices
+from bumble import hfp
+from bumble import rfcomm
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+async def make_hfp_connections(
+    hf_config: hfp.Configuration,
+) -> Tuple[hfp.HfProtocol, hfp.HfpProtocol]:
+    # Setup devices
+    devices = TwoDevices()
+    await devices.setup_connection()
+
+    # Setup RFCOMM channel
+    wait_dlc = asyncio.get_running_loop().create_future()
+    rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(
+        lambda dlc: wait_dlc.set_result(dlc)
+    )
+    assert devices.connections[0]
+    assert devices.connections[1]
+    client_mux = await rfcomm.Client(devices.devices[1], devices.connections[1]).start()
+
+    client_dlc = await client_mux.open_dlc(rfcomm_channel)
+    server_dlc = await wait_dlc
+
+    # Setup HFP connection
+    hf = hfp.HfProtocol(client_dlc, hf_config)
+    ag = hfp.HfpProtocol(server_dlc)
+    return hf, ag
+
+
+# -----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_slc():
+    hf_config = hfp.Configuration(
+        supported_hf_features=[], supported_hf_indicators=[], supported_audio_codecs=[]
+    )
+    hf, ag = await make_hfp_connections(hf_config)
+
+    async def ag_loop():
+        while line := await ag.next_line():
+            if line.startswith('AT+BRSF'):
+                ag.send_response_line('+BRSF: 0')
+            elif line.startswith('AT+CIND=?'):
+                ag.send_response_line(
+                    '+CIND: ("call",(0,1)),("callsetup",(0-3)),("service",(0-1)),'
+                    '("signal",(0-5)),("roam",(0,1)),("battchg",(0-5)),'
+                    '("callheld",(0-2))'
+                )
+            elif line.startswith('AT+CIND?'):
+                ag.send_response_line('+CIND: 0,0,1,4,1,5,0')
+            ag.send_response_line('OK')
+
+    ag_task = asyncio.create_task(ag_loop())
+
+    await hf.initiate_slc()
+    ag_task.cancel()
+
+
+# -----------------------------------------------------------------------------
+async def run():
+    await test_slc()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run())
diff --git a/tests/keystore_test.py b/tests/keystore_test.py
new file mode 100644
index 0000000..2a3d48d
--- /dev/null
+++ b/tests/keystore_test.py
@@ -0,0 +1,189 @@
+# 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 json
+import logging
+import pathlib
+import pytest
+import tempfile
+import os
+
+from bumble.keys import JsonKeyStore, PairingKeys
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Tests
+# -----------------------------------------------------------------------------
+
+JSON1 = """
+        {
+            "my_namespace": {
+                "14:7D:DA:4E:53:A8/P": {
+                    "address_type": 0,
+                    "irk": {
+                        "authenticated": false,
+                        "value": "e7b2543b206e4e46b44f9e51dad22bd1"
+                    },
+                    "link_key": {
+                        "authenticated": false,
+                        "value": "0745dd9691e693d9dca740f7d8dfea75"
+                    },
+                    "ltk": {
+                        "authenticated": false,
+                        "value": "d1897ee10016eb1a08e4e037fd54c683"
+                    }
+                }
+            }
+        }
+        """
+
+JSON2 = """
+        {
+            "my_namespace1": {
+            },
+            "my_namespace2": {
+            }
+        }
+        """
+
+JSON3 = """
+        {
+            "my_namespace1": {
+            },
+            "__DEFAULT__": {
+                "14:7D:DA:4E:53:A8/P": {
+                    "address_type": 0,
+                    "irk": {
+                        "authenticated": false,
+                        "value": "e7b2543b206e4e46b44f9e51dad22bd1"
+                    }
+                }
+            }
+        }
+        """
+
+
+# -----------------------------------------------------------------------------
+@pytest.fixture
+def temporary_file():
+    file = tempfile.NamedTemporaryFile(delete=False)
+    file.close()
+    yield file.name
+    pathlib.Path(file.name).unlink()
+
+
+# -----------------------------------------------------------------------------
+async def test_basic(temporary_file):
+    with open(temporary_file, mode='w', encoding='utf-8') as file:
+        file.write("{}")
+        file.flush()
+
+    keystore = JsonKeyStore('my_namespace', temporary_file)
+
+    keys = await keystore.get_all()
+    assert len(keys) == 0
+
+    keys = PairingKeys()
+    await keystore.update('foo', keys)
+    foo = await keystore.get('foo')
+    assert foo is not None
+    assert foo.ltk is None
+    ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
+    keys.ltk = PairingKeys.Key(ltk)
+    await keystore.update('foo', keys)
+    foo = await keystore.get('foo')
+    assert foo is not None
+    assert foo.ltk is not None
+    assert foo.ltk.value == ltk
+
+    with open(file.name, "r", encoding="utf-8") as json_file:
+        json_data = json.load(json_file)
+        assert 'my_namespace' in json_data
+        assert 'foo' in json_data['my_namespace']
+        assert 'ltk' in json_data['my_namespace']['foo']
+
+
+# -----------------------------------------------------------------------------
+async def test_parsing(temporary_file):
+    with open(temporary_file, mode='w', encoding='utf-8') as file:
+        file.write(JSON1)
+        file.flush()
+
+    keystore = JsonKeyStore('my_namespace', file.name)
+    foo = await keystore.get('14:7D:DA:4E:53:A8/P')
+    assert foo is not None
+    assert foo.ltk.value == bytes.fromhex('d1897ee10016eb1a08e4e037fd54c683')
+
+
+# -----------------------------------------------------------------------------
+async def test_default_namespace(temporary_file):
+    with open(temporary_file, mode='w', encoding='utf-8') as file:
+        file.write(JSON1)
+        file.flush()
+
+    keystore = JsonKeyStore(None, file.name)
+    all_keys = await keystore.get_all()
+    assert len(all_keys) == 1
+    name, keys = all_keys[0]
+    assert name == '14:7D:DA:4E:53:A8/P'
+    assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
+
+    with open(temporary_file, mode='w', encoding='utf-8') as file:
+        file.write(JSON2)
+        file.flush()
+
+    keystore = JsonKeyStore(None, file.name)
+    keys = PairingKeys()
+    ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
+    keys.ltk = PairingKeys.Key(ltk)
+    await keystore.update('foo', keys)
+    with open(file.name, "r", encoding="utf-8") as json_file:
+        json_data = json.load(json_file)
+        assert '__DEFAULT__' in json_data
+        assert 'foo' in json_data['__DEFAULT__']
+        assert 'ltk' in json_data['__DEFAULT__']['foo']
+
+    with open(temporary_file, mode='w', encoding='utf-8') as file:
+        file.write(JSON3)
+        file.flush()
+
+    keystore = JsonKeyStore(None, file.name)
+    all_keys = await keystore.get_all()
+    assert len(all_keys) == 1
+    name, keys = all_keys[0]
+    assert name == '14:7D:DA:4E:53:A8/P'
+    assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
+
+
+# -----------------------------------------------------------------------------
+async def run_tests():
+    await test_basic()
+    await test_parsing()
+    await test_default_namespace()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run_tests())
diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py
index 6f8e181..c6b2340 100644
--- a/tests/l2cap_test.py
+++ b/tests/l2cap_test.py
@@ -21,13 +21,9 @@
 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
+from .test_utils import TwoDevices
 
 
 # -----------------------------------------------------------------------------
@@ -37,60 +33,6 @@
 
 
 # -----------------------------------------------------------------------------
-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
 
 
 # -----------------------------------------------------------------------------
@@ -132,7 +74,8 @@
 # -----------------------------------------------------------------------------
 @pytest.mark.asyncio
 async def test_basic_connection():
-    devices = await setup_connection()
+    devices = TwoDevices()
+    await devices.setup_connection()
     psm = 1234
 
     # Check that if there's no one listening, we can't connect
@@ -184,7 +127,8 @@
 
 # -----------------------------------------------------------------------------
 async def transfer_payload(max_credits, mtu, mps):
-    devices = await setup_connection()
+    devices = TwoDevices()
+    await devices.setup_connection()
 
     received = []
 
@@ -226,7 +170,8 @@
 # -----------------------------------------------------------------------------
 @pytest.mark.asyncio
 async def test_bidirectional_transfer():
-    devices = await setup_connection()
+    devices = TwoDevices()
+    await devices.setup_connection()
 
     client_received = []
     server_received = []
diff --git a/tests/sdp_test.py b/tests/sdp_test.py
index f07b579..090e7b2 100644
--- a/tests/sdp_test.py
+++ b/tests/sdp_test.py
@@ -15,15 +15,30 @@
 # -----------------------------------------------------------------------------
 # Imports
 # -----------------------------------------------------------------------------
-from bumble.core import UUID
-from bumble.sdp import DataElement
+import asyncio
+import logging
+import os
+
+from bumble.core import UUID, BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID
+from bumble.sdp import (
+    DataElement,
+    ServiceAttribute,
+    Client,
+    Server,
+    SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+    SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+    SDP_PUBLIC_BROWSE_ROOT,
+    SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+    SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+)
+from .test_utils import TwoDevices
 
 # -----------------------------------------------------------------------------
 # pylint: disable=invalid-name
 # -----------------------------------------------------------------------------
 
 # -----------------------------------------------------------------------------
-def basic_check(x):
+def basic_check(x: DataElement) -> None:
     serialized = bytes(x)
     if len(serialized) < 500:
         print('Original:', x)
@@ -41,7 +56,7 @@
 
 
 # -----------------------------------------------------------------------------
-def test_data_elements():
+def test_data_elements() -> None:
     e = DataElement(DataElement.NIL, None)
     basic_check(e)
 
@@ -157,5 +172,108 @@
 
 
 # -----------------------------------------------------------------------------
-if __name__ == '__main__':
+def sdp_records():
+    return {
+        0x00010001: [
+            ServiceAttribute(
+                SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+                DataElement.unsigned_integer_32(0x00010001),
+            ),
+            ServiceAttribute(
+                SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+                DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+            ),
+            ServiceAttribute(
+                SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
+                ),
+            ),
+            ServiceAttribute(
+                SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+                DataElement.sequence(
+                    [
+                        DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
+                    ]
+                ),
+            ),
+        ]
+    }
+
+
+# -----------------------------------------------------------------------------
+async def test_service_search():
+    # Setup connections
+    devices = TwoDevices()
+    await devices.setup_connection()
+    assert devices.connections[0]
+    assert devices.connections[1]
+
+    # Register SDP service
+    devices.devices[0].sdp_server.service_records.update(sdp_records())
+
+    # Search for service
+    client = Client(devices.devices[1])
+    await client.connect(devices.connections[1])
+    services = await client.search_services(
+        [UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
+    )
+
+    # Then
+    assert services[0] == 0x00010001
+
+
+# -----------------------------------------------------------------------------
+async def test_service_attribute():
+    # Setup connections
+    devices = TwoDevices()
+    await devices.setup_connection()
+
+    # Register SDP service
+    devices.devices[0].sdp_server.service_records.update(sdp_records())
+
+    # Search for service
+    client = Client(devices.devices[1])
+    await client.connect(devices.connections[1])
+    attributes = await client.get_attributes(
+        0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
+    )
+
+    # Then
+    assert attributes[0].value.value == sdp_records()[0x00010001][0].value.value
+
+
+# -----------------------------------------------------------------------------
+async def test_service_search_attribute():
+    # Setup connections
+    devices = TwoDevices()
+    await devices.setup_connection()
+
+    # Register SDP service
+    devices.devices[0].sdp_server.service_records.update(sdp_records())
+
+    # Search for service
+    client = Client(devices.devices[1])
+    await client.connect(devices.connections[1])
+    attributes = await client.search_attributes(
+        [UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
+    )
+
+    # Then
+    for expect, actual in zip(attributes, sdp_records().values()):
+        assert expect.id == actual.id
+        assert expect.value == actual.value
+
+
+# -----------------------------------------------------------------------------
+async def run():
     test_data_elements()
+    await test_service_attribute()
+    await test_service_search()
+    await test_service_search_attribute()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    asyncio.run(run())
diff --git a/tests/self_test.py b/tests/self_test.py
index 1a1a474..98ce5e8 100644
--- a/tests/self_test.py
+++ b/tests/self_test.py
@@ -21,6 +21,8 @@
 import os
 import pytest
 
+from unittest.mock import MagicMock, patch
+
 from bumble.controller import Controller
 from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
 from bumble.link import LocalLink
@@ -34,6 +36,8 @@
     SMP_CONFIRM_VALUE_FAILED_ERROR,
 )
 from bumble.core import ProtocolError
+from bumble.hci import HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
+from bumble.keys import PairingKeys
 
 
 # -----------------------------------------------------------------------------
@@ -64,13 +68,16 @@
             ),
         ]
 
-        self.paired = [None, None]
+        self.paired = [
+            asyncio.get_event_loop().create_future(),
+            asyncio.get_event_loop().create_future(),
+        ]
 
     def on_connection(self, which, connection):
         self.connections[which] = connection
 
-    def on_paired(self, which, keys):
-        self.paired[which] = keys
+    def on_paired(self, which: int, keys: PairingKeys):
+        self.paired[which].set_result(keys)
 
 
 # -----------------------------------------------------------------------------
@@ -319,8 +326,8 @@
     # Pair
     await two_devices.devices[0].pair(connection)
     assert connection.is_encrypted
-    assert two_devices.paired[0] is not None
-    assert two_devices.paired[1] is not None
+    assert await two_devices.paired[0] is not None
+    assert await two_devices.paired[1] is not None
 
 
 # -----------------------------------------------------------------------------
@@ -474,6 +481,101 @@
 
 
 # -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_self_smp_over_classic():
+    # 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)
+    )
+
+    # Enable Classic connections
+    two_devices.devices[0].classic_enabled = True
+    two_devices.devices[1].classic_enabled = True
+
+    # Start
+    await two_devices.devices[0].power_on()
+    await two_devices.devices[1].power_on()
+
+    # Connect the two devices
+    await asyncio.gather(
+        two_devices.devices[0].connect(
+            two_devices.devices[1].public_address, transport=BT_BR_EDR_TRANSPORT
+        ),
+        two_devices.devices[1].accept(two_devices.devices[0].public_address),
+    )
+
+    # Check the post conditions
+    assert two_devices.connections[0] is not None
+    assert two_devices.connections[1] is not None
+
+    # Mock connection
+    # TODO: Implement Classic SSP and encryption in link relayer
+    LINK_KEY = bytes.fromhex('287ad379dca402530a39f1f43047b835')
+    two_devices.devices[0].on_link_key(
+        two_devices.devices[1].public_address,
+        LINK_KEY,
+        HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
+    )
+    two_devices.devices[1].on_link_key(
+        two_devices.devices[0].public_address,
+        LINK_KEY,
+        HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
+    )
+    two_devices.connections[0].encryption = 1
+    two_devices.connections[1].encryption = 1
+
+    two_devices.connections[0].on(
+        'pairing', lambda keys: two_devices.on_paired(0, keys)
+    )
+    two_devices.connections[1].on(
+        'pairing', lambda keys: two_devices.on_paired(1, keys)
+    )
+
+    # Mock SMP
+    with patch('bumble.smp.Session', spec=True) as MockSmpSession:
+        MockSmpSession.send_pairing_confirm_command = MagicMock()
+        MockSmpSession.send_pairing_dhkey_check_command = MagicMock()
+        MockSmpSession.send_public_key_command = MagicMock()
+        MockSmpSession.send_pairing_random_command = MagicMock()
+
+        # Start CTKD
+        await two_devices.connections[0].pair()
+        await asyncio.gather(*two_devices.paired)
+
+        # Phase 2 commands should not be invoked
+        MockSmpSession.send_pairing_confirm_command.assert_not_called()
+        MockSmpSession.send_pairing_dhkey_check_command.assert_not_called()
+        MockSmpSession.send_public_key_command.assert_not_called()
+        MockSmpSession.send_pairing_random_command.assert_not_called()
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_self_smp_public_address():
+    pairing_config = PairingConfig(
+        mitm=True,
+        sc=True,
+        bonding=True,
+        identity_address_type=PairingConfig.AddressType.PUBLIC,
+        delegate=PairingDelegate(
+            PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
+            PairingDelegate.KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
+            | PairingDelegate.KeyDistribution.DISTRIBUTE_IDENTITY_KEY
+            | PairingDelegate.KeyDistribution.DISTRIBUTE_SIGNING_KEY
+            | PairingDelegate.KeyDistribution.DISTRIBUTE_LINK_KEY,
+        ),
+    )
+
+    await _test_self_smp_with_configs(pairing_config, pairing_config)
+
+
+# -----------------------------------------------------------------------------
 async def run_test_self():
     await test_self_connection()
     await test_self_gatt()
@@ -481,6 +583,8 @@
     await test_self_smp()
     await test_self_smp_reject()
     await test_self_smp_wrong_pin()
+    await test_self_smp_over_classic()
+    await test_self_smp_public_address()
 
 
 # -----------------------------------------------------------------------------
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..f19f18c
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,73 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import List, Optional
+
+from bumble.controller import Controller
+from bumble.link import LocalLink
+from bumble.device import Device, Connection
+from bumble.host import Host
+from bumble.transport import AsyncPipeSink
+from bumble.hci import Address
+
+
+class TwoDevices:
+    connections: List[Optional[Connection]]
+
+    def __init__(self) -> None:
+        self.connections = [None, None]
+
+        self.link = LocalLink()
+        self.controllers = [
+            Controller('C1', link=self.link),
+            Controller('C2', link=self.link),
+        ]
+        self.devices = [
+            Device(
+                address=Address('F0:F1:F2:F3:F4:F5'),
+                host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
+            ),
+            Device(
+                address=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(self) -> None:
+        # Attach listeners
+        self.devices[0].on(
+            'connection', lambda connection: self.on_connection(0, connection)
+        )
+        self.devices[1].on(
+            'connection', lambda connection: self.on_connection(1, connection)
+        )
+
+        # Start
+        await self.devices[0].power_on()
+        await self.devices[1].power_on()
+
+        # Connect the two devices
+        await self.devices[0].connect(self.devices[1].random_address)
+
+        # Check the post conditions
+        assert self.connections[0] is not None
+        assert self.connections[1] is not None
diff --git a/tests/utils_test.py b/tests/utils_test.py
new file mode 100644
index 0000000..d6f5780
--- /dev/null
+++ b/tests/utils_test.py
@@ -0,0 +1,77 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import contextlib
+import logging
+import os
+
+from bumble import utils
+from pyee import EventEmitter
+from unittest.mock import MagicMock
+
+
+def test_on() -> None:
+    emitter = EventEmitter()
+    with contextlib.closing(utils.EventWatcher()) as context:
+        mock = MagicMock()
+        context.on(emitter, 'event', mock)
+
+        emitter.emit('event')
+
+    assert not emitter.listeners('event')
+    assert mock.call_count == 1
+
+
+def test_on_decorator() -> None:
+    emitter = EventEmitter()
+    with contextlib.closing(utils.EventWatcher()) as context:
+        mock = MagicMock()
+
+        @context.on(emitter, 'event')
+        def on_event(*_) -> None:
+            mock()
+
+        emitter.emit('event')
+
+    assert not emitter.listeners('event')
+    assert mock.call_count == 1
+
+
+def test_multiple_handlers() -> None:
+    emitter = EventEmitter()
+    with contextlib.closing(utils.EventWatcher()) as context:
+        mock = MagicMock()
+
+        context.once(emitter, 'a', mock)
+        context.once(emitter, 'b', mock)
+
+        emitter.emit('b', 'b')
+
+    assert not emitter.listeners('a')
+    assert not emitter.listeners('b')
+
+    mock.assert_called_once_with('b')
+
+
+# -----------------------------------------------------------------------------
+def run_tests():
+    test_on()
+    test_on_decorator()
+    test_multiple_handlers()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+    run_tests()
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tools/__init__.py
diff --git a/utils/generate_company_id_list.py b/tools/generate_company_id_list.py
similarity index 100%
rename from utils/generate_company_id_list.py
rename to tools/generate_company_id_list.py
diff --git a/tools/rtk_fw_download.py b/tools/rtk_fw_download.py
new file mode 100644
index 0000000..89c49b2
--- /dev/null
+++ b/tools/rtk_fw_download.py
@@ -0,0 +1,153 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import logging
+import pathlib
+import urllib.request
+import urllib.error
+
+import click
+
+from bumble.colors import color
+from bumble.drivers import rtk
+from bumble.tools import rtk_util
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+LINUX_KERNEL_GIT_SOURCE = (
+    "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt",
+    False,
+)
+REALTEK_OPENSOURCE_SOURCE = (
+    "https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT",
+    True,
+)
+LINUX_FROM_SCRATCH_SOURCE = (
+    "https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt",
+    False,
+)
+
+# -----------------------------------------------------------------------------
+# Functions
+# -----------------------------------------------------------------------------
+def download_file(base_url, name, remove_suffix):
+    if remove_suffix:
+        name = name.replace(".bin", "")
+
+    url = f"{base_url}/{name}"
+    with urllib.request.urlopen(url) as file:
+        data = file.read()
+        print(f"Downloaded {name}: {len(data)} bytes")
+        return data
+
+
+# -----------------------------------------------------------------------------
+@click.command
+@click.option(
+    "--output-dir",
+    default="",
+    help="Output directory where the files will be saved. Defaults to the OS-specific"
+    "app data dir, which the driver will check when trying to find firmware",
+    show_default=True,
+)
+@click.option(
+    "--source",
+    type=click.Choice(["linux-kernel", "realtek-opensource", "linux-from-scratch"]),
+    default="linux-kernel",
+    show_default=True,
+)
+@click.option("--single", help="Only download a single image set, by its base name")
+@click.option("--force", is_flag=True, help="Overwrite files if they already exist")
+@click.option("--parse", is_flag=True, help="Parse the FW image after saving")
+def main(output_dir, source, single, force, parse):
+    """Download RTK firmware images and configs."""
+
+    # Check that the output dir exists
+    if output_dir == '':
+        output_dir = rtk.rtk_firmware_dir()
+    else:
+        output_dir = pathlib.Path(output_dir)
+    if not output_dir.is_dir():
+        print("Output dir does not exist or is not a directory")
+        return
+
+    base_url, remove_suffix = {
+        "linux-kernel": LINUX_KERNEL_GIT_SOURCE,
+        "realtek-opensource": REALTEK_OPENSOURCE_SOURCE,
+        "linux-from-scratch": LINUX_FROM_SCRATCH_SOURCE,
+    }[source]
+
+    print("Downloading")
+    print(color("FROM:", "green"), base_url)
+    print(color("TO:", "green"), output_dir)
+
+    if single:
+        images = [(f"{single}_fw.bin", f"{single}_config.bin", True)]
+    else:
+        images = [
+            (driver_info.fw_name, driver_info.config_name, driver_info.config_needed)
+            for driver_info in rtk.Driver.DRIVER_INFOS
+        ]
+
+    for (fw_name, config_name, config_needed) in images:
+        print(color("---", "yellow"))
+        fw_image_out = output_dir / fw_name
+        if not force and fw_image_out.exists():
+            print(color(f"{fw_image_out} already exists, skipping", "red"))
+            continue
+        if config_name:
+            config_image_out = output_dir / config_name
+            if not force and config_image_out.exists():
+                print(color("f{config_out} already exists, skipping", "red"))
+                continue
+
+        try:
+            fw_image = download_file(base_url, fw_name, remove_suffix)
+        except urllib.error.HTTPError as error:
+            print(f"Failed to download {fw_name}: {error}")
+            continue
+
+        config_image = None
+        if config_name:
+            try:
+                config_image = download_file(base_url, config_name, remove_suffix)
+            except urllib.error.HTTPError as error:
+                if config_needed:
+                    print(f"Failed to download {config_name}: {error}")
+                    continue
+                else:
+                    print(f"No config available as {config_name}")
+
+        fw_image_out.write_bytes(fw_image)
+        if parse and config_name:
+            print(color("Parsing:", "cyan"), fw_name)
+            rtk_util.do_parse(fw_image_out)
+        if config_image:
+            config_image_out.write_bytes(config_image)
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    main()
diff --git a/tools/rtk_util.py b/tools/rtk_util.py
new file mode 100644
index 0000000..35afd92
--- /dev/null
+++ b/tools/rtk_util.py
@@ -0,0 +1,160 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import logging
+import asyncio
+import os
+
+import click
+
+from bumble import transport
+from bumble.host import Host
+from bumble.drivers import rtk
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+def do_parse(firmware_path):
+    with open(firmware_path, 'rb') as firmware_file:
+        firmware_data = firmware_file.read()
+        firmware = rtk.Firmware(firmware_data)
+        print(
+            f"Firmware: version=0x{firmware.version:08X} "
+            f"project_id=0x{firmware.project_id:04X}"
+        )
+        for patch in firmware.patches:
+            print(
+                f"  Patch: chip_id=0x{patch[0]:04X}, "
+                f"{len(patch[1])} bytes, "
+                f"SVN Version={patch[2]:08X}"
+            )
+
+
+# -----------------------------------------------------------------------------
+async def do_load(usb_transport, force):
+    async with await transport.open_transport_or_link(usb_transport) as (
+        hci_source,
+        hci_sink,
+    ):
+        # Create a host to communicate with the device
+        host = Host(hci_source, hci_sink)
+        await host.reset(driver_factory=None)
+
+        # Get the driver.
+        driver = await rtk.Driver.for_host(host, force)
+        if driver is None:
+            print("Firmware already loaded or no supported driver for this device.")
+            return
+
+        await driver.download_firmware()
+
+
+# -----------------------------------------------------------------------------
+async def do_drop(usb_transport):
+    async with await transport.open_transport_or_link(usb_transport) as (
+        hci_source,
+        hci_sink,
+    ):
+        # Create a host to communicate with the device
+        host = Host(hci_source, hci_sink)
+        await host.reset(driver_factory=None)
+
+        # Tell the device to reset/drop any loaded patch
+        await rtk.Driver.drop_firmware(host)
+
+
+# -----------------------------------------------------------------------------
+async def do_info(usb_transport, force):
+    async with await transport.open_transport(usb_transport) as (
+        hci_source,
+        hci_sink,
+    ):
+        # Create a host to communicate with the device
+        host = Host(hci_source, hci_sink)
+        await host.reset(driver_factory=None)
+
+        # Check if this is a supported device.
+        if not force and not rtk.Driver.check(host):
+            print("USB device not supported by this RTK driver")
+            return
+
+        # Get the driver info.
+        driver_info = await rtk.Driver.driver_info_for_host(host)
+        if driver_info:
+            print(
+                "Driver:\n"
+                f"  ROM:      {driver_info.rom:04X}\n"
+                f"  Firmware: {driver_info.fw_name}\n"
+                f"  Config:   {driver_info.config_name}\n"
+            )
+        else:
+            print("Firmware already loaded or no supported driver for this device.")
+
+
+# -----------------------------------------------------------------------------
+@click.group()
+def main():
+    logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+
+
+@main.command
+@click.argument("firmware_path")
+def parse(firmware_path):
+    """Parse a firmware image."""
+    do_parse(firmware_path)
+
+
+@main.command
+@click.argument("usb_transport")
+@click.option(
+    "--force",
+    is_flag=True,
+    default=False,
+    help="Load even if the USB info doesn't match",
+)
+def load(usb_transport, force):
+    """Load a firmware image into the USB dongle."""
+    asyncio.run(do_load(usb_transport, force))
+
+
+@main.command
+@click.argument("usb_transport")
+def drop(usb_transport):
+    """Drop a firmware image from the USB dongle."""
+    asyncio.run(do_drop(usb_transport))
+
+
+@main.command
+@click.argument("usb_transport")
+@click.option(
+    "--force",
+    is_flag=True,
+    default=False,
+    help="Try to get the device info even if the USB info doesn't match",
+)
+def info(usb_transport, force):
+    """Get the firmware info from a USB dongle."""
+    asyncio.run(do_info(usb_transport, force))
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+    main()
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..a8cc89c
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,48 @@
+Bumble For Web Browsers
+=======================
+
+Early prototype the consists of running the Bumble stack in a web browser
+environment, using [pyodide](https://pyodide.org/)
+
+Two examples are included here:
+ 
+  * scanner - a simple scanner
+  * speaker - a pure-web-based version of the Speaker app
+
+Both examples rely on the shared code in `bumble.js`.
+
+Running The Examples
+--------------------
+
+To run the examples, you will need an HTTP server to serve the HTML and JS files, and
+and a WebSocket server serving an HCI transport.
+
+For HCI over WebSocket, recent versions of the `netsim` virtual controller support it,
+or you may use the Bumble HCI Bridge app to bridge a WebSocket server to a virtual
+controller using some other transport (ex: `python apps/hci_bridge.py ws-server:_:9999 usb:0`).
+
+For HTTP, start an HTTP server with the `web` directory as its
+root. You can use the invoke task `inv web.serve` for convenience.
+
+In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`.
+You can pass optional query parameters:
+
+  * `package` may be set to point to a local build of Bumble (`.whl` files).
+     The filename must be URL-encoded of course, and must be located under
+     the `web` directory (the HTTP server won't serve files not under its
+     root directory).
+  * `hci` may be set to specify a non-default WebSocket URL to use as the HCI
+     transport (the default is: `"ws://localhost:9922/hci`). This also needs
+     to be URL-encoded.
+
+Example:
+    With a local HTTP server running on port 8000, to run the `scanner` example
+    with a locally-built Bumble package `../bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl` 
+    (assuming that `bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl` exists under the `web`
+    directory and the HCI WebSocket transport at `ws://localhost:9999/hci`, the URL with the 
+    URL-encoded query parameters would be:
+    `http://localhost:8000/scanner/scanner.html?hci=ws%3A%2F%2Flocalhost%3A9999%2Fhci&package=..%2Fbumble-0.0.163.dev5%2Bg6f832b6.d20230812-py3-none-any.whl`
+
+
+NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory. 
+Make a copy of the built `.whl` file in the `web` directory.
\ No newline at end of file
diff --git a/web/bumble.js b/web/bumble.js
new file mode 100644
index 0000000..b1243a5
--- /dev/null
+++ b/web/bumble.js
@@ -0,0 +1,91 @@
+function bufferToHex(buffer) {
+    return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
+}
+
+class PacketSource {
+    constructor(pyodide) {
+        this.parser = pyodide.runPython(`
+        from bumble.transport.common import PacketParser
+        class ProxiedPacketParser(PacketParser):
+            def feed_data(self, js_data):
+                super().feed_data(bytes(js_data.to_py()))
+        ProxiedPacketParser()
+      `);
+    }
+
+    set_packet_sink(sink) {
+        this.parser.set_packet_sink(sink);
+    }
+
+    data_received(data) {
+        console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
+        this.parser.feed_data(data);
+    }
+}
+
+class PacketSink {
+    constructor(writer) {
+        this.writer = writer;
+    }
+
+    on_packet(packet) {
+        const buffer = packet.toJs({create_proxies : false});
+        packet.destroy();
+        console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
+        // TODO: create an async queue here instead of blindly calling write without awaiting
+        this.writer(buffer);
+    }
+}
+
+export async function connectWebSocketTransport(pyodide, hciWsUrl) {
+    return new Promise((resolve, reject) => {
+        let resolved = false;
+
+        let ws = new WebSocket(hciWsUrl);
+        ws.binaryType = "arraybuffer";
+
+        ws.onopen = () => {
+            console.log("WebSocket open");
+            resolve({
+                packet_source,
+                packet_sink
+            });
+            resolved = true;
+        }
+
+        ws.onclose = () => {
+            console.log("WebSocket close");
+            if (!resolved) {
+                reject(`Failed to connect to ${hciWsUrl}`)
+            }
+        }
+
+        ws.onmessage = (event) => {
+            packet_source.data_received(event.data);
+        }
+
+        const packet_source = new PacketSource(pyodide);
+        const packet_sink = new PacketSink((packet) => ws.send(packet));
+    })
+}
+
+export async function loadBumble(pyodide, bumblePackage) {
+    // Load the Bumble module
+    await pyodide.loadPackage("micropip");
+    await pyodide.runPythonAsync(`
+        import micropip
+        await micropip.install("${bumblePackage}")
+        package_list = micropip.list()
+        print(package_list)
+    `)
+
+    // Mount a filesystem so that we can persist data like the Key Store
+    let mountDir = "/bumble";
+    pyodide.FS.mkdir(mountDir);
+    pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir);
+
+    // Sync previously persisted filesystem data into memory
+    pyodide.FS.syncfs(true, () => {
+        console.log("FS synced in")
+    });
+}
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
deleted file mode 100644
index 4374db0..0000000
--- a/web/index.html
+++ /dev/null
@@ -1,131 +0,0 @@
-<html>
-  <head>
-    <script src="https://cdn.jsdelivr.net/pyodide/v0.19.1/full/pyodide.js"></script>
-  </head>
-
-  <body>
-    <button onclick="runUSB()">USB</button>
-    <button onclick="runSerial()">Serial</button>
-    <br />
-    <br />
-    <div>Output:</div>
-    <textarea id="output" style="width: 100%;" rows="30" disabled></textarea>
-
-    <script>
-        function bufferToHex(buffer) {
-            return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
-        }
-
-        const output = document.getElementById("output");
-        const code = document.getElementById("code");
-
-        function addToOutput(s) {
-            output.value += s + "\n";
-        }
-
-      output.value = "Initializing...\n";
-
-      async function main() {
-          let pyodide = await loadPyodide({
-              indexURL: "https://cdn.jsdelivr.net/pyodide/v0.19.1/full/",
-          })
-          output.value += "Ready!\n"
-
-          return pyodide;
-      }
-
-      let pyodideReadyPromise = main();
-
-      async function readLoop(port, packet_source) {
-        const reader = port.readable.getReader()
-            try {
-                while (true) {
-                    console.log('@@@ Reading...')
-                    const { done, value } = await reader.read()
-                    if (done) {
-                        console.log("--- DONE!")
-                        break
-                    }
-
-                    console.log('@@@ Serial data:', bufferToHex(value))
-                    if (packet_source.delegate !== undefined) {
-                        packet_source.delegate.data_received(value)
-                    } else {
-                        console.warn('@@@ delegate not set yet, dropping data')
-                    }
-                }
-            } catch (error) {
-                console.error(error)
-            } finally {
-                reader.releaseLock()
-            }
-      }
-
-      async function runUSB() {
-        const device = await navigator.usb.requestDevice({
-          filters: [
-            {
-                classCode: 0xE0,
-                subclassCode: 0x01
-            }
-          ]
-        });
-
-        if (device.configuration === null) {
-          await device.selectConfiguration(1);
-        }
-        await device.claimInterface(0)
-      }
-
-      async function runSerial() {
-        const ports = await navigator.serial.getPorts()
-          console.log('Paired ports:', ports)
-
-          const port = await navigator.serial.requestPort()
-          await port.open({ baudRate: 1000000 })
-          const writer = port.writable.getWriter()
-      }
-
-      async function run() {
-
-          let pyodide = await pyodideReadyPromise;
-          try {
-              const script = await(await fetch('scanner.py')).text()
-              await pyodide.loadPackage('micropip')
-              await pyodide.runPythonAsync(`
-                  import micropip
-                  await micropip.install('../dist/bumble-0.0.36.dev0+g3adbfe7.d20210807-py3-none-any.whl')
-              `)
-              let output = await pyodide.runPythonAsync(script)
-              addToOutput(output)
-
-              const pythonMain = pyodide.globals.get('main')
-              const packet_source = {}
-              const packet_sink = {
-                  on_packet: (packet) => {
-                    // Variant A, with the conversion done in Javascript
-                      const buffer = packet.toJs()
-                      console.log(`$$$ on_packet: ${bufferToHex(buffer)}`)
-                      // TODO: create an sync queue here instead of blindly calling write without awaiting
-                      /*await*/ writer.write(buffer)
-                      packet.destroy()
-
-                    // Variant B, with the conversion `to_js` done at the Python layer
-                    // console.log(`$$$ on_packet: ${bufferToHex(packet)}`)
-                    //   /*await*/ writer.write(packet)
-                }
-              }
-              serialLooper = readLoop(port, packet_source)
-              pythonResult = await pythonMain(packet_source, packet_sink)
-              console.log(pythonResult)
-              serialResult = await serialLooper
-              writer.releaseLock()
-              await port.close()
-              console.log('### done')
-        } catch (err) {
-          addToOutput(err);
-        }
-      }
-    </script>
-  </body>
-</html>
diff --git a/web/scanner.py b/web/scanner.py
deleted file mode 100644
index 59eda67..0000000
--- a/web/scanner.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# 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
-# -----------------------------------------------------------------------------
-from bumble.device import Device
-from bumble.transport.common import PacketParser
-
-
-# -----------------------------------------------------------------------------
-class ScannerListener(Device.Listener):
-    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:
-    def __init__(self, host_source):
-        self.parser = PacketParser()
-        host_source.delegate = self
-
-    def set_packet_sink(self, sink):
-        self.parser.set_packet_sink(sink)
-
-    # host source delegation
-    def data_received(self, data):
-        print('*** DATA from JS:', data)
-        buffer = bytes(data.to_py())
-        self.parser.feed_data(buffer)
-
-
-# class HciSink:
-#     def __init__(self, host_sink):
-#         self.host_sink = host_sink
-
-#     def on_packet(self, packet):
-#         print(f'>>> PACKET from Python: {packet}')
-#         self.host_sink.on_packet(packet)
-
-
-# -----------------------------------------------------------------------------
-async def main(host_source, host_sink):
-    print('### Starting Scanner')
-    hci_source = HciSource(host_source)
-    hci_sink = host_sink
-    device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
-    device.listener = ScannerListener()
-    await device.power_on()
-    await device.start_scanning()
-
-    print('### Scanner started')
diff --git a/web/scanner/scanner.html b/web/scanner/scanner.html
new file mode 100644
index 0000000..12c65dd
--- /dev/null
+++ b/web/scanner/scanner.html
@@ -0,0 +1,129 @@
+<html>
+
+<head>
+  <script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
+  <style>
+    body {
+      font-family: monospace;
+    }
+
+    table, th, td {
+      padding: 2px;
+      white-space: pre;
+      border: 1px solid black;
+      border-collapse: collapse;
+    }
+  </style>
+</head>
+
+<body>
+  <button id="connectButton" disabled>Connect</button>
+  <br />
+  <br />
+  <div>Log Output</div><br>
+  <textarea id="output" style="width: 100%;" rows="10" disabled></textarea>
+  <div id="scanTableContainer"><table></table></div>
+
+  <script type="module">
+    import { loadBumble, connectWebSocketTransport } from "../bumble.js"
+    let pyodide;
+    let output;
+
+    function logToOutput(s) {
+      output.value += s + "\n";
+      console.log(s);
+    }
+
+    async function run() {
+      const params = (new URL(document.location)).searchParams;
+      const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
+
+      try {
+        // Create a WebSocket HCI transport
+        let transport
+        try {
+          transport = await connectWebSocketTransport(pyodide, hciWsUrl);
+        } catch (error) {
+          logToOutput(error);
+          return;
+        }
+
+        // Run the scanner example
+        const script = await (await fetch("scanner.py")).text();
+        await pyodide.runPythonAsync(script);
+        const pythonMain = pyodide.globals.get("main");
+        logToOutput("Starting scanner...");
+        await pythonMain(transport.packet_source, transport.packet_sink, onScanUpdate);
+        logToOutput("Scanner running");
+      } catch (err) {
+        logToOutput(err);
+      }
+    }
+
+    function onScanUpdate(scanEntries) {
+      scanEntries = scanEntries.toJs();
+
+      const scanTable = document.createElement("table");
+
+      const tableHeader = document.createElement("tr");
+      for (const name of ["Address", "Address Type", "RSSI", "Data"]) {
+        const header = document.createElement("th");
+        header.appendChild(document.createTextNode(name));
+        tableHeader.appendChild(header);
+      }
+      scanTable.appendChild(tableHeader);
+
+      scanEntries.forEach(entry => {
+        const row = document.createElement("tr");
+
+        const addressCell = document.createElement("td");
+        addressCell.appendChild(document.createTextNode(entry.address));
+        row.appendChild(addressCell);
+
+        const addressTypeCell = document.createElement("td");
+        addressTypeCell.appendChild(document.createTextNode(entry.address_type));
+        row.appendChild(addressTypeCell);
+
+        const rssiCell = document.createElement("td");
+        rssiCell.appendChild(document.createTextNode(entry.rssi));
+        row.appendChild(rssiCell);
+
+        const dataCell = document.createElement("td");
+        dataCell.appendChild(document.createTextNode(entry.data));
+        row.appendChild(dataCell);
+
+        scanTable.appendChild(row);
+      });
+
+      const scanTableContainer = document.getElementById("scanTableContainer");
+      scanTableContainer.replaceChild(scanTable, scanTableContainer.firstChild);
+
+      return true;
+    }
+
+    async function main() {
+      output = document.getElementById("output");
+
+      // Load pyodide
+      logToOutput("Loading Pyodide");
+      pyodide = await loadPyodide();
+
+      // Load Bumble
+      logToOutput("Loading Bumble");
+      const params = (new URL(document.location)).searchParams;
+      const bumblePackage = params.get("package") || "bumble";
+      await loadBumble(pyodide, bumblePackage);
+
+      logToOutput("Ready!")
+
+      // Enable the Connect button
+      const connectButton = document.getElementById("connectButton");
+      connectButton.disabled = false
+      connectButton.addEventListener("click", run)
+    }
+
+    main();
+  </script>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/web/scanner/scanner.py b/web/scanner/scanner.py
new file mode 100644
index 0000000..c0fc456
--- /dev/null
+++ b/web/scanner/scanner.py
@@ -0,0 +1,53 @@
+# 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 time
+
+from bumble.device import Device
+
+
+# -----------------------------------------------------------------------------
+class ScanEntry:
+    def __init__(self, advertisement):
+        self.address = advertisement.address.to_string(False)
+        self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
+            advertisement.address.address_type
+        ]
+        self.rssi = advertisement.rssi
+        self.data = advertisement.data.to_string("\n")
+
+
+# -----------------------------------------------------------------------------
+class ScannerListener(Device.Listener):
+    def __init__(self, callback):
+        self.callback = callback
+        self.entries = {}
+
+    def on_advertisement(self, advertisement):
+        self.entries[advertisement.address] = ScanEntry(advertisement)
+        self.callback(list(self.entries.values()))
+
+
+# -----------------------------------------------------------------------------
+async def main(hci_source, hci_sink, callback):
+    print('### Starting Scanner')
+    device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
+    device.listener = ScannerListener(callback)
+    await device.power_on()
+    await device.start_scanning()
+
+    print('### Scanner started')
diff --git a/web/speaker/logo.svg b/web/speaker/logo.svg
new file mode 100644
index 0000000..70ef7a9
--- /dev/null
+++ b/web/speaker/logo.svg
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
+<metadata>
+<vectornator:setting key="DimensionsVisible" value="1"/>
+<vectornator:setting key="PencilOnly" value="0"/>
+<vectornator:setting key="SnapToPoints" value="0"/>
+<vectornator:setting key="OutlineMode" value="0"/>
+<vectornator:setting key="CMYKEnabledKey" value="0"/>
+<vectornator:setting key="RulersVisible" value="1"/>
+<vectornator:setting key="SnapToEdges" value="0"/>
+<vectornator:setting key="GuidesVisible" value="1"/>
+<vectornator:setting key="DisplayWhiteBackground" value="0"/>
+<vectornator:setting key="doHistoryDisabled" value="0"/>
+<vectornator:setting key="SnapToGuides" value="1"/>
+<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
+<vectornator:setting key="Units" value="Pixels"/>
+<vectornator:setting key="DynamicGuides" value="0"/>
+<vectornator:setting key="IsolateActiveLayer" value="0"/>
+<vectornator:setting key="SnapToGrid" value="0"/>
+</metadata>
+<defs/>
+<g id="Layer 1" vectornator:layerName="Layer 1">
+<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
+<g opacity="1">
+<g opacity="1">
+<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+</g>
+<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<g opacity="1">
+<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
+</g>
+</g>
+</g>
+</svg>
diff --git a/web/speaker/speaker.css b/web/speaker/speaker.css
new file mode 100644
index 0000000..988392a
--- /dev/null
+++ b/web/speaker/speaker.css
@@ -0,0 +1,76 @@
+body, h1, h2, h3, h4, h5, h6 {
+    font-family: sans-serif;
+}
+
+#controlsDiv {
+    margin: 6px;
+}
+
+#errorText {
+    background-color: rgb(239, 89, 75);
+    border: none;
+    border-radius: 4px;
+    padding: 8px;
+    display: inline-block;
+    margin: 4px;
+}
+
+#startButton {
+    padding: 4px;
+    margin: 6px;
+}
+
+#fftCanvas {
+    border-radius: 16px;
+    margin: 6px;
+}
+
+#bandwidthCanvas {
+    border: grey;
+    border-style: solid;
+    border-radius: 8px;
+    margin: 6px;
+}
+
+#streamStateText {
+    background-color: rgb(93, 165, 93);
+    border: none;
+    border-radius: 8px;
+    padding: 10px 20px;
+    display: inline-block;
+    margin: 6px;
+}
+
+#connectionStateText {
+    background-color: rgb(112, 146, 206);
+    border: none;
+    border-radius: 8px;
+    padding: 10px 20px;
+    display: inline-block;
+    margin: 6px;
+}
+
+#propertiesTable {
+    border: grey;
+    border-style: solid;
+    border-radius: 4px;
+    padding: 4px;
+    margin: 6px;
+    margin-left: 0px;
+}
+
+th, td {
+    padding-left: 6px;
+    padding-right: 6px;
+}
+
+.properties td:nth-child(even) {
+    background-color: #D6EEEE;
+    font-family: monospace;
+}
+
+.properties td:nth-child(odd) {
+    font-weight: bold;
+}
+
+.properties tr td:nth-child(2) { width: 150px; }
\ No newline at end of file
diff --git a/web/speaker/speaker.html b/web/speaker/speaker.html
new file mode 100644
index 0000000..a20f084
--- /dev/null
+++ b/web/speaker/speaker.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Bumble Speaker</title>
+  <script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
+  <script type="module" src="speaker.js"></script>
+  <link rel="stylesheet" href="speaker.css">
+</head>
+<body>
+  <h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
+  <div id="errorText"></div>
+  <div id="speaker">
+    <table><tr>
+      <td>
+        <table id="propertiesTable" class="properties">
+          <tr><td>Codec</td><td><span id="codecText"></span></td></tr>
+          <tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
+          <tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
+        </table>
+      </td>
+      <td>
+        <canvas id="bandwidthCanvas" width="500", height="100">Bandwidth Graph</canvas>
+      </td>
+    </tr></table>
+    <span id="streamStateText">IDLE</span>
+    <span id="connectionStateText">NOT CONNECTED</span>
+    <div id="controlsDiv">
+        <button id="audioOnButton">Audio On</button>
+    </div>
+    <canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
+    <audio id="audio"></audio>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/web/speaker/speaker.js b/web/speaker/speaker.js
new file mode 100644
index 0000000..b94180f
--- /dev/null
+++ b/web/speaker/speaker.js
@@ -0,0 +1,289 @@
+import { loadBumble, connectWebSocketTransport } from "../bumble.js";
+
+(function () {
+    'use strict';
+
+    let codecText;
+    let packetsReceivedText;
+    let bytesReceivedText;
+    let streamStateText;
+    let connectionStateText;
+    let errorText;
+    let audioOnButton;
+    let mediaSource;
+    let sourceBuffer;
+    let audioElement;
+    let audioContext;
+    let audioAnalyzer;
+    let audioFrequencyBinCount;
+    let audioFrequencyData;
+    let packetsReceived = 0;
+    let bytesReceived = 0;
+    let audioState = "stopped";
+    let streamState = "IDLE";
+    let fftCanvas;
+    let fftCanvasContext;
+    let bandwidthCanvas;
+    let bandwidthCanvasContext;
+    let bandwidthBinCount;
+    let bandwidthBins = [];
+    let pyodide;
+
+    const FFT_WIDTH = 800;
+    const FFT_HEIGHT = 256;
+    const BANDWIDTH_WIDTH = 500;
+    const BANDWIDTH_HEIGHT = 100;
+
+
+    function init() {
+        initUI();
+        initMediaSource();
+        initAudioElement();
+        initAnalyzer();
+        initBumble();
+    }
+
+    function initUI() {
+        audioOnButton = document.getElementById("audioOnButton");
+        codecText = document.getElementById("codecText");
+        packetsReceivedText = document.getElementById("packetsReceivedText");
+        bytesReceivedText = document.getElementById("bytesReceivedText");
+        streamStateText = document.getElementById("streamStateText");
+        errorText = document.getElementById("errorText");
+        connectionStateText = document.getElementById("connectionStateText");
+
+        audioOnButton.onclick = () => startAudio();
+
+        codecText.innerText = "AAC";
+        setErrorText("");
+
+        requestAnimationFrame(onAnimationFrame);
+    }
+
+    function initMediaSource() {
+        mediaSource = new MediaSource();
+        mediaSource.onsourceopen = onMediaSourceOpen;
+        mediaSource.onsourceclose = onMediaSourceClose;
+        mediaSource.onsourceended = onMediaSourceEnd;
+    }
+
+    function initAudioElement() {
+        audioElement = document.getElementById("audio");
+        audioElement.src = URL.createObjectURL(mediaSource);
+        // audioElement.controls = true;
+    }
+
+    function initAnalyzer() {
+        fftCanvas = document.getElementById("fftCanvas");
+        fftCanvas.width = FFT_WIDTH
+        fftCanvas.height = FFT_HEIGHT
+        fftCanvasContext = fftCanvas.getContext('2d');
+        fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
+        fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
+
+        bandwidthCanvas = document.getElementById("bandwidthCanvas");
+        bandwidthCanvas.width = BANDWIDTH_WIDTH
+        bandwidthCanvas.height = BANDWIDTH_HEIGHT
+        bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
+        bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
+        bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
+    }
+
+    async function initBumble() {
+        // Load pyodide
+        console.log("Loading Pyodide");
+        pyodide = await loadPyodide();
+
+        // Load Bumble
+        console.log("Loading Bumble");
+        const params = (new URL(document.location)).searchParams;
+        const bumblePackage = params.get("package") || "bumble";
+        await loadBumble(pyodide, bumblePackage);
+
+        console.log("Ready!")
+
+        const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
+        try {
+            // Create a WebSocket HCI transport
+            let transport
+            try {
+                transport = await connectWebSocketTransport(pyodide, hciWsUrl);
+            } catch (error) {
+                console.error(error);
+                setErrorText(error);
+                return;
+            }
+
+            // Run the scanner example
+            const script = await (await fetch("speaker.py")).text();
+            await pyodide.runPythonAsync(script);
+            const pythonMain = pyodide.globals.get("main");
+            console.log("Starting speaker...");
+            await pythonMain(transport.packet_source, transport.packet_sink, onEvent);
+            console.log("Speaker running");
+        } catch (err) {
+            console.log(err);
+        }
+    }
+
+    function startAnalyzer() {
+        // FFT
+        if (audioElement.captureStream !== undefined) {
+            audioContext = new AudioContext();
+            audioAnalyzer = audioContext.createAnalyser();
+            audioAnalyzer.fftSize = 128;
+            audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
+            audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
+            const stream = audioElement.captureStream();
+            const source = audioContext.createMediaStreamSource(stream);
+            source.connect(audioAnalyzer);
+        }
+
+        // Bandwidth
+        bandwidthBinCount = BANDWIDTH_WIDTH / 2;
+        bandwidthBins = [];
+    }
+
+    function setErrorText(message) {
+        errorText.innerText = message;
+        if (message.length == 0) {
+            errorText.style.display = "none";
+        } else {
+            errorText.style.display = "inline-block";
+        }
+    }
+
+    function setStreamState(state) {
+        streamState = state;
+        streamStateText.innerText = streamState;
+    }
+
+    function onAnimationFrame() {
+        // FFT
+        if (audioAnalyzer !== undefined) {
+            audioAnalyzer.getByteFrequencyData(audioFrequencyData);
+            fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
+            fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
+            const barCount = audioFrequencyBinCount;
+            const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
+            for (let bar = 0; bar < barCount; bar++) {
+                const barHeight = audioFrequencyData[bar];
+                fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
+                fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
+            }
+        }
+
+        // Bandwidth
+        bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
+        bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
+        bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
+        for (let t = 0; t < bandwidthBins.length; t++) {
+            const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
+            bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
+        }
+
+        // Display again at the next frame
+        requestAnimationFrame(onAnimationFrame);
+    }
+
+    function onMediaSourceOpen() {
+        console.log(this.readyState);
+        sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
+    }
+
+    function onMediaSourceClose() {
+        console.log(this.readyState);
+    }
+
+    function onMediaSourceEnd() {
+        console.log(this.readyState);
+    }
+
+    async function startAudio() {
+        try {
+            console.log("starting audio...");
+            audioOnButton.disabled = true;
+            audioState = "starting";
+            await audioElement.play();
+            console.log("audio started");
+            audioState = "playing";
+            startAnalyzer();
+        } catch (error) {
+            console.error(`play failed: ${error}`);
+            audioState = "stopped";
+            audioOnButton.disabled = false;
+        }
+    }
+
+    async function onEvent(name, params) {
+        // Dispatch the message.
+        const handlerName = `on${name.charAt(0).toUpperCase()}${name.slice(1)}`
+        const handler = eventHandlers[handlerName];
+        if (handler !== undefined) {
+            handler(params);
+        } else {
+            console.warn(`unhandled event: ${name}`)
+        }
+    }
+
+    function onStart() {
+        setStreamState("STARTED");
+    }
+
+    function onStop() {
+        setStreamState("STOPPED");
+    }
+
+    function onSuspend() {
+        setStreamState("SUSPENDED");
+    }
+
+    function onConnection(params) {
+        connectionStateText.innerText = `CONNECTED: ${params.get('peer_name')} (${params.get('peer_address')})`;
+    }
+
+    function onDisconnection(params) {
+        connectionStateText.innerText = "DISCONNECTED";
+    }
+
+    function onAudio(python_packet) {
+        const packet = python_packet.toJs({create_proxies : false});
+        python_packet.destroy();
+        if (audioState != "stopped") {
+            // Queue the audio packet.
+            sourceBuffer.appendBuffer(packet);
+        }
+
+        packetsReceived += 1;
+        packetsReceivedText.innerText = packetsReceived;
+        bytesReceived += packet.byteLength;
+        bytesReceivedText.innerText = bytesReceived;
+
+        bandwidthBins[bandwidthBins.length] = packet.byteLength;
+        if (bandwidthBins.length > bandwidthBinCount) {
+            bandwidthBins.shift();
+        }
+    }
+
+    function onKeystoreupdate() {
+        // Sync the FS
+        pyodide.FS.syncfs(() => {
+            console.log("FS synced out")
+        });
+    }
+
+    const eventHandlers = {
+        onStart,
+        onStop,
+        onSuspend,
+        onConnection,
+        onDisconnection,
+        onAudio,
+        onKeystoreupdate
+    }
+
+    window.onload = (event) => {
+        init();
+    }
+
+}());
\ No newline at end of file
diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py
new file mode 100644
index 0000000..d9293a4
--- /dev/null
+++ b/web/speaker/speaker.py
@@ -0,0 +1,321 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import enum
+import logging
+from typing import Dict, List
+
+from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
+from bumble.device import Device, DeviceConfiguration
+from bumble.pairing import PairingConfig
+from bumble.sdp import ServiceAttribute
+from bumble.avdtp import (
+    AVDTP_AUDIO_MEDIA_TYPE,
+    Listener,
+    MediaCodecCapabilities,
+    MediaPacket,
+    Protocol,
+)
+from bumble.a2dp import (
+    make_audio_sink_service_sdp_records,
+    MPEG_2_AAC_LC_OBJECT_TYPE,
+    A2DP_SBC_CODEC_TYPE,
+    A2DP_MPEG_2_4_AAC_CODEC_TYPE,
+    SBC_MONO_CHANNEL_MODE,
+    SBC_DUAL_CHANNEL_MODE,
+    SBC_SNR_ALLOCATION_METHOD,
+    SBC_LOUDNESS_ALLOCATION_METHOD,
+    SBC_STEREO_CHANNEL_MODE,
+    SBC_JOINT_STEREO_CHANNEL_MODE,
+    SbcMediaCodecInformation,
+    AacMediaCodecInformation,
+)
+from bumble.utils import AsyncRunner
+from bumble.codecs import AacAudioRtpPacket
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+class AudioExtractor:
+    @staticmethod
+    def create(codec: str):
+        if codec == 'aac':
+            return AacAudioExtractor()
+        if codec == 'sbc':
+            return SbcAudioExtractor()
+
+    def extract_audio(self, packet: MediaPacket) -> bytes:
+        raise NotImplementedError()
+
+
+# -----------------------------------------------------------------------------
+class AacAudioExtractor:
+    def extract_audio(self, packet: MediaPacket) -> bytes:
+        return AacAudioRtpPacket(packet.payload).to_adts()
+
+
+# -----------------------------------------------------------------------------
+class SbcAudioExtractor:
+    def extract_audio(self, packet: MediaPacket) -> bytes:
+        # header = packet.payload[0]
+        # fragmented = header >> 7
+        # start = (header >> 6) & 0x01
+        # last = (header >> 5) & 0x01
+        # number_of_frames = header & 0x0F
+
+        # TODO: support fragmented payloads
+        return packet.payload[1:]
+
+
+# -----------------------------------------------------------------------------
+class Speaker:
+    class StreamState(enum.Enum):
+        IDLE = 0
+        STOPPED = 1
+        STARTED = 2
+        SUSPENDED = 3
+
+    def __init__(self, hci_source, hci_sink, emit_event, codec, discover):
+        self.hci_source = hci_source
+        self.hci_sink = hci_sink
+        self.emit_event = emit_event
+        self.codec = codec
+        self.discover = discover
+        self.device = None
+        self.connection = None
+        self.listener = None
+        self.packets_received = 0
+        self.bytes_received = 0
+        self.stream_state = Speaker.StreamState.IDLE
+        self.audio_extractor = AudioExtractor.create(codec)
+
+    def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
+        service_record_handle = 0x00010001
+        return {
+            service_record_handle: make_audio_sink_service_sdp_records(
+                service_record_handle
+            )
+        }
+
+    def codec_capabilities(self) -> MediaCodecCapabilities:
+        if self.codec == 'aac':
+            return self.aac_codec_capabilities()
+
+        if self.codec == 'sbc':
+            return self.sbc_codec_capabilities()
+
+        raise RuntimeError('unsupported codec')
+
+    def aac_codec_capabilities(self) -> MediaCodecCapabilities:
+        return MediaCodecCapabilities(
+            media_type=AVDTP_AUDIO_MEDIA_TYPE,
+            media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
+            media_codec_information=AacMediaCodecInformation.from_lists(
+                object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
+                sampling_frequencies=[48000, 44100],
+                channels=[1, 2],
+                vbr=1,
+                bitrate=256000,
+            ),
+        )
+
+    def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
+        return MediaCodecCapabilities(
+            media_type=AVDTP_AUDIO_MEDIA_TYPE,
+            media_codec_type=A2DP_SBC_CODEC_TYPE,
+            media_codec_information=SbcMediaCodecInformation.from_lists(
+                sampling_frequencies=[48000, 44100, 32000, 16000],
+                channel_modes=[
+                    SBC_MONO_CHANNEL_MODE,
+                    SBC_DUAL_CHANNEL_MODE,
+                    SBC_STEREO_CHANNEL_MODE,
+                    SBC_JOINT_STEREO_CHANNEL_MODE,
+                ],
+                block_lengths=[4, 8, 12, 16],
+                subbands=[4, 8],
+                allocation_methods=[
+                    SBC_LOUDNESS_ALLOCATION_METHOD,
+                    SBC_SNR_ALLOCATION_METHOD,
+                ],
+                minimum_bitpool_value=2,
+                maximum_bitpool_value=53,
+            ),
+        )
+
+    def on_key_store_update(self):
+        print("Key Store updated")
+        self.emit_event('keystoreupdate', None)
+
+    def on_bluetooth_connection(self, connection):
+        print(f'Connection: {connection}')
+        self.connection = connection
+        connection.on('disconnection', self.on_bluetooth_disconnection)
+        peer_name = '' if connection.peer_name is None else connection.peer_name
+        peer_address = connection.peer_address.to_string(False)
+        self.emit_event(
+            'connection', {'peer_name': peer_name, 'peer_address': peer_address}
+        )
+
+    def on_bluetooth_disconnection(self, reason):
+        print(f'Disconnection ({reason})')
+        self.connection = None
+        AsyncRunner.spawn(self.advertise())
+        self.emit_event('disconnection', None)
+
+    def on_avdtp_connection(self, protocol):
+        print('Audio Stream Open')
+
+        # Add a sink endpoint to the server
+        sink = protocol.add_sink(self.codec_capabilities())
+        sink.on('start', self.on_sink_start)
+        sink.on('stop', self.on_sink_stop)
+        sink.on('suspend', self.on_sink_suspend)
+        sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
+        sink.on('rtp_packet', self.on_rtp_packet)
+        sink.on('rtp_channel_open', self.on_rtp_channel_open)
+        sink.on('rtp_channel_close', self.on_rtp_channel_close)
+
+        # Listen for close events
+        protocol.on('close', self.on_avdtp_close)
+
+        # Discover all endpoints on the remote device is requested
+        if self.discover:
+            AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
+
+    def on_avdtp_close(self):
+        print("Audio Stream Closed")
+
+    def on_sink_start(self):
+        print("Sink Started")
+        self.stream_state = self.StreamState.STARTED
+        self.emit_event('start', None)
+
+    def on_sink_stop(self):
+        print("Sink Stopped")
+        self.stream_state = self.StreamState.STOPPED
+        self.emit_event('stop', None)
+
+    def on_sink_suspend(self):
+        print("Sink Suspended")
+        self.stream_state = self.StreamState.SUSPENDED
+        self.emit_event('suspend', None)
+
+    def on_sink_configuration(self, config):
+        print("Sink Configuration:")
+        print('\n'.join(["  " + str(capability) for capability in config]))
+
+    def on_rtp_channel_open(self):
+        print("RTP Channel Open")
+
+    def on_rtp_channel_close(self):
+        print("RTP Channel Closed")
+        self.stream_state = self.StreamState.IDLE
+
+    def on_rtp_packet(self, packet):
+        self.packets_received += 1
+        self.bytes_received += len(packet.payload)
+        self.emit_event("audio", self.audio_extractor.extract_audio(packet))
+
+    async def advertise(self):
+        await self.device.set_discoverable(True)
+        await self.device.set_connectable(True)
+
+    async def connect(self, address):
+        # Connect to the source
+        print(f'=== Connecting to {address}...')
+        connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
+        print(f'=== Connected to {connection.peer_address}')
+
+        # Request authentication
+        print('*** Authenticating...')
+        await connection.authenticate()
+        print('*** Authenticated')
+
+        # Enable encryption
+        print('*** Enabling encryption...')
+        await connection.encrypt()
+        print('*** Encryption on')
+
+        protocol = await Protocol.connect(connection)
+        self.listener.set_server(connection, protocol)
+        self.on_avdtp_connection(protocol)
+
+    async def discover_remote_endpoints(self, protocol):
+        endpoints = await protocol.discover_remote_endpoints()
+        print(f'@@@ Found {len(endpoints)} endpoints')
+        for endpoint in endpoints:
+            print('@@@', endpoint)
+
+    async def run(self, connect_address):
+        # Create a device
+        device_config = DeviceConfiguration()
+        device_config.name = "Bumble Speaker"
+        device_config.class_of_device = 0x240414
+        device_config.keystore = "JsonKeyStore:/bumble/keystore.json"
+        device_config.classic_enabled = True
+        device_config.le_enabled = False
+        self.device = Device.from_config_with_hci(
+            device_config, self.hci_source, self.hci_sink
+        )
+
+        # Setup the SDP to expose the sink service
+        self.device.sdp_service_records = self.sdp_records()
+
+        # Don't require MITM when pairing.
+        self.device.pairing_config_factory = lambda connection: PairingConfig(
+            mitm=False
+        )
+
+        # Start the controller
+        await self.device.power_on()
+
+        # Listen for Bluetooth connections
+        self.device.on('connection', self.on_bluetooth_connection)
+
+        # Listen for changes to the key store
+        self.device.on('key_store_update', self.on_key_store_update)
+
+        # Create a listener to wait for AVDTP connections
+        self.listener = Listener(Listener.create_registrar(self.device))
+        self.listener.on('connection', self.on_avdtp_connection)
+
+        print(f'Speaker ready to play, codec={self.codec}')
+
+        if connect_address:
+            # Connect to the source
+            try:
+                await self.connect(connect_address)
+            except CommandTimeoutError:
+                print("Connection timed out")
+                return
+        else:
+            # Start being discoverable and connectable
+            print("Waiting for connection...")
+            await self.advertise()
+
+
+# -----------------------------------------------------------------------------
+async def main(hci_source, hci_sink, emit_event):
+    # logging.basicConfig(level='DEBUG')
+    speaker = Speaker(hci_source, hci_sink, emit_event, "aac", False)
+    await speaker.run(None)