diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-12-15 09:25:24 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-12-15 09:25:24 +0000 |
commit | 5fa55812593b4ead12b1d5d6798ed8f420206c01 (patch) | |
tree | aa98f29f308756c4a060711d20036a9e31d819fa | |
parent | 3d4b7d286f24ca2ffc83028066628e0c7162836c (diff) | |
parent | 62618d11a2e7875c438c5131abb6847da26aa6f4 (diff) | |
download | bumble-aml_tz5_341510010.tar.gz |
Snap for 11224086 from 62618d11a2e7875c438c5131abb6847da26aa6f4 to mainline-tzdata5-releaseaml_tz5_341510070aml_tz5_341510050aml_tz5_341510010aml_tz5_341510010
Change-Id: I4de062859c10e8f53575e2b6544a0af923fb2b11
159 files changed, 18569 insertions, 2077 deletions
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 @@ jobs: 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 @@ permissions: 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 @@ jobs: 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 @@ -9,3 +9,4 @@ __pycache__ # 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", @@ -19,6 +19,7 @@ python_library_host { name: "bumble", srcs: [ "bumble/*.py", + "bumble/drivers/*.py", "bumble/profiles/*.py", "bumble/transport/*.py", ], @@ -11,7 +11,7 @@ third_party { 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 @@ class ScanResult: 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 @@ async def get_classic_info(host): 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 @@ class ServerBridge: 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 @@ class ServerBridge: 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 @@ class Delegate(PairingDelegate): 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 @@ def on_connection(connection, request): # 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_start(): # ----------------------------------------------------------------------------- -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 @@ async def pair( # 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 @@ async def pair( # 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 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300 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 @@ async def scan( '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 @@ async def scan( 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 @@ class SnoopPacketReader: 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 @@ def main(format, filename): 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 @@ import click from bumble.device import Device from bumble.keys import JsonKeyStore - +from bumble.transport import open_transport # ----------------------------------------------------------------------------- -async def unbond(keystore_file, device_config, address): - # Create a device to manage the host - device = Device.from_config_file(device_config) +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') - # 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 @@ class AacMediaCodecInformation( 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 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731 # pylint: enable=line-too-long # pylint: disable=invalid-name + # ----------------------------------------------------------------------------- # Exceptions # ----------------------------------------------------------------------------- @@ -209,7 +211,7 @@ class ATT_PDU: pdu_classes: Dict[int, Type[ATT_PDU]] = {} op_code = 0 - name = None + name: str @staticmethod def from_bytes(pdu): @@ -720,47 +722,67 @@ class ATT_Handle_Value_Confirmation(ATT_PDU): # ----------------------------------------------------------------------------- -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 - - 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', - } +class ConnectionValue(Protocol): + def read(self, connection) -> bytes: + ... - @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 + def write(self, connection, value: bytes) -> None: + ... - def __init__(self, attribute_type, permissions, value=b''): + +# ----------------------------------------------------------------------------- +class Attribute(EventEmitter): + 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 + + @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 + + # 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 + + 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 @@ class Attribute(EventEmitter): 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 @@ class Attribute(EventEmitter): 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 @@ class Attribute(EventEmitter): 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 @@ class Attribute(EventEmitter): 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 DelayReport_Reject(Simple_Reject): # ----------------------------------------------------------------------------- -class Protocol: +class Protocol(EventEmitter): SINGLE_PACKET = 0 START_PACKET = 1 CONTINUE_PACKET = 2 @@ -1234,6 +1234,7 @@ class Protocol: 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 @@ class Protocol: # 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 @@ class Protocol: 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 @@ class Listener(EventEmitter): 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 @@ class Listener(EventEmitter): 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 DiscoveredStreamEndPoint(StreamEndPoint, StreamEndPointProxy): # ----------------------------------------------------------------------------- -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 @@ class LocalStreamEndPoint(StreamEndPoint): 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 @@ class LocalSource(LocalStreamEndPoint, EventEmitter): 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 @@ class LocalSource(LocalStreamEndPoint, EventEmitter): 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 LocalSource(LocalStreamEndPoint, EventEmitter): # ----------------------------------------------------------------------------- -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 @@ from bumble.hci import ( 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ class Controller: 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 @@ def bit_flags_to_strings(bits, bit_flag_names): 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 @@ def get_dict_key_by_value(dictionary, value): 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 @@ class BaseError(Exception): 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 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin self.peer_address = peer_address +class ConnectionParameterUpdateError(BaseError): + """Connection Parameter Update Error""" + + # ----------------------------------------------------------------------------- # UUID # @@ -562,11 +574,82 @@ class DeviceClass: 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 asyncio 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 @@ from .hci import ( 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 @@ from .core import ( BT_LE_TRANSPORT, BT_PERIPHERAL_ROLE, AdvertisingData, + ConnectionParameterUpdateError, CommandTimeoutError, ConnectionPHY, InvalidStateError, @@ -151,6 +164,9 @@ from . import sdp from . import l2cap from . import core +if TYPE_CHECKING: + from .transport.common import TransportSource, TransportSink + # ----------------------------------------------------------------------------- # Logging @@ -651,7 +667,7 @@ class Connection(CompositeEventEmitter): 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 @@ class Connection(CompositeEventEmitter): connection_interval_max, max_latency, supervision_timeout, + use_l2cap=False, ): return await self.device.update_connection_parameters( self, @@ -715,6 +732,7 @@ class Connection(CompositeEventEmitter): 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 @@ class DeviceConfiguration: 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 @@ class DeviceConfiguration: 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): ) # 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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]: + if self.keystore is None: + return None + # 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 + keys = await self.keystore.get(str(address)) + if keys is None: + logger.debug(f'no keys found for {address}') + return None - return keys.link_key.value - 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 @@ class Device(CompositeEventEmitter): '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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): ) 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 @@ class Device(CompositeEventEmitter): 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 - ) - ) - else: - await self.host.send_command( - HCI_User_Confirmation_Request_Negative_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 ) + ) AsyncRunner.spawn(reply()) @@ -2864,21 +2936,25 @@ class Device(CompositeEventEmitter): pairing_config = self.pairing_config_factory(connection) async def reply() -> None: - 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 - ) + try: + number = await connection.abort_on( + 'disconnection', pairing_config.delegate.get_number() ) - else: - 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 + ) ) + 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 ) + ) AsyncRunner.spawn(reply()) @@ -3085,10 +3161,31 @@ class Device(CompositeEventEmitter): 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 @@ class Device(CompositeEventEmitter): @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 enum 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 @@ GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bi # ----------------------------------------------------------------------------- -def show_services(services): +def show_services(services: Iterable[Service]) -> None: for service in services: print(color(str(service), 'cyan')) @@ -210,11 +210,11 @@ class Service(Attribute): 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 @@ class Service(Attribute): """ 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 @@ class TemplateService(Service): 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 @@ class IncludedServiceDeclaration(Attribute): 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 @@ class IncludedServiceDeclaration(Attribute): ) 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 @@ class Characteristic(Attribute): 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 @@ class Characteristic(Attribute): 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 @@ class Characteristic(Attribute): 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 @@ class CharacteristicDeclaration(Attribute): 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 @@ class CharacteristicDeclaration(Attribute): 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 @@ class CharacteristicAdapter: 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 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter): 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 @@ class Descriptor(Attribute): 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 asyncio 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 @@ from .gatt import ( GATT_INCLUDE_ATTRIBUTE_TYPE, Characteristic, ClientCharacteristicConfigurationBits, + TemplateService, ) +if TYPE_CHECKING: + from bumble.device import Connection + # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- @@ -78,16 +93,16 @@ logger = logging.getLogger(__name__) # 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 @@ class AttributeProxy(EventEmitter): 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 @@ class ServiceProxy(AttributeProxy): 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 @@ class CharacteristicProxy(AttributeProxy): 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 @@ class CharacteristicProxy(AttributeProxy): 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 @@ class DescriptorProxy(AttributeProxy): 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 @@ class ProfileServiceProxy: 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 ProfileServiceProxy: 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 @@ class Client: 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 @@ class Client: 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 @@ class Client: 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 @@ class Client: 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 @@ class Client: 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 @@ class Client: 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 @@ class Client: 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 @@ class Client: return descriptors - async def discover_attributes(self): + async def discover_attributes(self) -> List[AttributeProxy]: ''' Discover all attributes, regardless of type ''' @@ -844,7 +869,9 @@ class Client: # 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 @@ class Client: # 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 @@ class Client: 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 @@ class Client: ) ) - 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 @@ class Client: 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 @@ class Client: # 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 @@ from .att import ( 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 @@ from .gatt import ( Service, ) +if TYPE_CHECKING: + from bumble.device import Device, Connection # ----------------------------------------------------------------------------- # Logging @@ -91,8 +95,13 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517 # ----------------------------------------------------------------------------- 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): return next( ( - attribute + attribute # type: ignore for attribute in map( self.get_attribute, range( @@ -205,12 +219,12 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): # 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): ) 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): ] ) - 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 @@ class Server(EventEmitter): 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 @@ class Server(EventEmitter): ####################################################### # 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 @@ class Server(EventEmitter): 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 @@ def hci_command_op_code(ogf, ocf): 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 @@ def map_null_terminated_utf8_string(utf8_bytes): 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 @@ def phy_list_to_bits(phys): # 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_REQUEST_EVENT = 0x31 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_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 0X56 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_TRANSMIT_POWER_REPORTING_EVENT = 0X21 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_DATA_RELATED_ADDRESS_CHANGES_COMMAND = hci_c 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 @@ class HCI_Object: @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 @@ class HCI_Object: 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 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') + 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: - 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] + raise ValueError('value too large for *-typed field') else: - raise ValueError( - f"don't know how to serialize type {type(field_value)}" + 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 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 @@ class HCI_Object: return str(value) @staticmethod - def format_fields(hci_object, keys, indentation='', value_mappers=None): - if not keys: - return '' - - # Measure the widest field name - max_field_name_length = max( - (len(key[0] if isinstance(key, tuple) else key) for key in keys) + 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') + + # 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 - - # 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_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 + + 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 @@ class Address: 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 @@ class Address: ) 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 @@ class HCI_Packet: 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 @@ class HCI_Command(HCI_Packet): ''' hci_packet_type = HCI_COMMAND_PACKET + command_names: Dict[int, str] = {} command_classes: Dict[int, Type[HCI_Command]] = {} @staticmethod @@ -1905,9 +1967,9 @@ class HCI_Command(HCI_Packet): 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 @@ class HCI_Command(HCI_Packet): 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 @@ class HCI_Command(HCI_Packet): 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 @@ class HCI_Command(HCI_Packet): 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 @@ class HCI_Command(HCI_Packet): return result +HCI_Command.register_commands(globals()) + + # ----------------------------------------------------------------------------- @HCI_Command.command( [ @@ -2284,6 +2371,55 @@ class HCI_User_Passkey_Request_Negative_Reply_Command(HCI_Command): # ----------------------------------------------------------------------------- @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 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_Command): # ----------------------------------------------------------------------------- @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 @@ class HCI_Write_Simple_Pairing_Mode_Command(HCI_Command): # ----------------------------------------------------------------------------- @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 @@ class HCI_Write_Authenticated_Payload_Timeout_Command(HCI_Command): @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 @@ class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command): '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 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command): '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 @@ class HCI_LE_Set_Extended_Scan_Response_Data_Command(HCI_Command): # ----------------------------------------------------------------------------- -@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 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command): 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 @@ class HCI_LE_Extended_Create_Connection_Command(HCI_Command): 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 @@ class HCI_Event(HCI_Packet): ''' 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 @@ class HCI_Event(HCI_Packet): 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 @@ class HCI_Event(HCI_Packet): 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 @@ class HCI_Event(HCI_Packet): 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 @@ class HCI_Event(HCI_Packet): 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 @@ class HCI_Event(HCI_Packet): 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 @@ class HCI_Event(HCI_Packet): 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 @@ class HCI_LE_Advertising_Report_Event(HCI_LE_Meta_Event): 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 @@ class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event): 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 @@ class HCI_Command_Complete_Event(HCI_Event): ''' 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 @@ class HCI_Command_Complete_Event(HCI_Event): 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 @@ class HCI_Number_Of_Completed_Packets_Event(HCI_Event): 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 @@ class HCI_User_Passkey_Request_Event(HCI_Event): # ----------------------------------------------------------------------------- +@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 @@ class HCI_Link_Supervision_Timeout_Changed_Event(HCI_Event): # ----------------------------------------------------------------------------- +@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 @@ class HCI_User_Passkey_Notification_Event(HCI_Event): # ----------------------------------------------------------------------------- +@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_Remote_Host_Supported_Features_Notification_Event(HCI_Event): # ----------------------------------------------------------------------------- -class HCI_AclDataPacket: +class HCI_AclDataPacket(HCI_Packet): ''' See Bluetooth spec @ 5.4.2 HCI ACL Data Packets ''' @@ -5340,7 +5696,7 @@ class HCI_AclDataPacket: 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 @@ class HCI_AclDataPacket: 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_AclDataPacket: # ----------------------------------------------------------------------------- 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 @@ class HCI_AclDataPacketAssembler: 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 @@ from .colors import color # ----------------------------------------------------------------------------- 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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- 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 @@ class HfpProtocol: 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 @@ class HfpProtocol: 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 @@ class HfpProtocol: 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 +# ----------------------------------------------------------------------------- + + +# 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 - self.send_command_line('AT+CIND?') - await (self.next_line()) - await (self.next_line()) + if AudioCodec.MSBC in configuration.supported_audio_codecs: + hf_supported_features |= HfSdpFeature.WIDE_BAND - self.send_command_line('AT+CMER=3,0,0,1') - await (self.next_line()) + 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 @@ from .hci import ( 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 .hci import ( ) 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 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1 # ----------------------------------------------------------------------------- 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 @@ class Host(AbortableEventEmitter): # 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 @@ class Host(AbortableEventEmitter): 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 @@ class Host(AbortableEventEmitter): 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 @@ class Host(AbortableEventEmitter): self.reset_done = True @property - def controller(self): + def controller(self) -> TransportSink: return self.hci_sink @controller.setter @@ -290,14 +323,13 @@ class Host(AbortableEventEmitter): 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 @@ class Host(AbortableEventEmitter): 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 @@ class Host(AbortableEventEmitter): 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 @@ class Host(AbortableEventEmitter): 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 @@ class Host(AbortableEventEmitter): 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 @@ class Host(AbortableEventEmitter): ] # 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 @@ class Host(AbortableEventEmitter): 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 @@ class Host(AbortableEventEmitter): 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 KeyStore: # ----------------------------------------------------------------------------- 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 @@ class JsonKeyStore(KeyStore): 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 @@ class JsonKeyStore(KeyStore): 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 @@ class JsonKeyStore(KeyStore): 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 @@ class JsonKeyStore(KeyStore): 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: - return None - - keys = namespace.get(name) - if keys is None: + _, key_map = await self.load() + if name not in key_map: 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 @@ from .hci import ( name_or_number, ) +if TYPE_CHECKING: + from bumble.device import Connection + from bumble.host import Host + # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- @@ -155,7 +173,7 @@ class L2CAP_PDU: ''' @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 @@ class L2CAP_PDU: 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 @@ class L2CAP_Control_Frame: 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 @@ class L2CAP_Control_Frame: 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 @@ class L2CAP_Control_Frame: 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 @@ class L2CAP_Control_Frame: 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 @@ class L2CAP_Command_Reject(L2CAP_Control_Frame): } @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 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame): ''' @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 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame): 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 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame): } @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 @@ class L2CAP_Configure_Response(L2CAP_Control_Frame): } @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 @@ class L2CAP_Information_Request(L2CAP_Control_Frame): } @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 @@ class L2CAP_Information_Response(L2CAP_Control_Frame): 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 @@ class L2CAP_LE_Credit_Based_Connection_Request(L2CAP_Control_Frame): (CODE 0x14) ''' + source_cid: int + # ----------------------------------------------------------------------------- @L2CAP_Control_Frame.subclass( @@ -640,7 +661,7 @@ class L2CAP_LE_Credit_Based_Connection_Response(L2CAP_Control_Frame): } @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 L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame): # ----------------------------------------------------------------------------- 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 - - # 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', - } + 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 + + 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 @@ class Channel(EventEmitter): 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 @@ class Channel(EventEmitter): 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 @@ class Channel(EventEmitter): 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 @@ class Channel(EventEmitter): 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 @@ class Channel(EventEmitter): ) ) - 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 @@ class Channel(EventEmitter): 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 @@ class Channel(EventEmitter): ) 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 @@ class Channel(EventEmitter): 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 @@ class Channel(EventEmitter): ) # 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 @@ class Channel(EventEmitter): 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 @@ class Channel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): LE Credit-based Connection Oriented Channel """ - INIT = 0 - CONNECTED = 1 - CONNECTING = 2 - DISCONNECTING = 3 - DISCONNECTED = 4 - CONNECTION_ERROR = 5 - - STATE_NAMES = { - INIT: 'INIT', - CONNECTED: 'CONNECTED', - CONNECTING: 'CONNECTING', - DISCONNECTING: 'DISCONNECTING', - DISCONNECTED: 'DISCONNECTED', - CONNECTION_ERROR: 'CONNECTION_ERROR', - } - - @staticmethod - def state_name(state): - return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state) + class State(enum.IntEnum): + INIT = 0 + CONNECTED = 1 + CONNECTING = 2 + DISCONNECTING = 3 + DISCONNECTED = 4 + CONNECTION_ERROR = 5 + + 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 @@ class LeConnectionOrientedChannel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): # 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 @@ class LeConnectionOrientedChannel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): ), ) ) - 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 @@ class LeConnectionOrientedChannel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): 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 @@ class LeConnectionOrientedChannel(EventEmitter): # 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 LeConnectionOrientedChannel(EventEmitter): # ----------------------------------------------------------------------------- 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: ): 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: ) 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 @@ class ChannelManager: 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 @@ class ChannelManager: ) 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 @@ class ChannelManager: ), ) - 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 @@ class ChannelManager: ), ) - 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: ), ) - 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 @@ class ChannelManager: 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 @@ class ChannelManager: ), ) - 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_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, cid, request): + 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 @@ class ChannelManager: ), ) - 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 @@ class ChannelManager: # 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 @@ class ChannelManager: 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 @@ class ChannelManager: 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 @@ class ChannelManager: # 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 @@ import enum 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 PairingDelegate: 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 @@ from typing import Any, Dict @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 @@ class Config: '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 bumble.sdp import ( 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 @@ class PandoraDevice: 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 @@ from bumble.hci import ( 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): # 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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 @@ class HostService(HostServicer): 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.core import ( ) 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 @@ class PairingDelegate(BasePairingDelegate): 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 @@ class PairingDelegate(BasePairingDelegate): 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 @@ class PairingDelegate(BasePairingDelegate): 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 @@ class PairingDelegate(BasePairingDelegate): 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 @@ class PairingDelegate(BasePairingDelegate): ): return - self.log.info( + self.log.debug( f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})" ) @@ -235,6 +233,11 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): # trigger pairing if needed if self.need_pairing(connection, level): try: - self.log.info('Pair...') + self.log.debug('Pair...') + + security_result = asyncio.get_running_loop().create_future() + + with contextlib.closing(EventWatcher()) as watcher: - 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) + @watcher.on(connection, 'pairing') + def on_pairing(*_: Any) -> None: + security_result.set_result('success') - connection.request_pairing() + @watcher.on(connection, 'pairing_failure') + def on_pairing_failure(*_: Any) -> None: + security_result.set_result('pairing_failure') - await wait_for_security - else: - await connection.pair() + @watcher.on(connection, 'disconnection') + def on_disconnection(*_: Any) -> None: + security_result.set_result('connection_died') - self.log.info('Paired') + 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 @@ class SecurityService(SecurityServicer): # 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 @@ class SecurityService(SecurityServicer): # 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 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): '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 @@ class SecurityService(SecurityServicer): 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 @@ class SecurityService(SecurityServicer): # 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 @@ class SecurityStorageService(SecurityStorageServicer): 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 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30 # ----------------------------------------------------------------------------- -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 @@ def compute_fcs(buffer): # ----------------------------------------------------------------------------- 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 @@ class RFCOMM_Frame: 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 @@ class RFCOMM_Frame: 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 @@ class RFCOMM_Frame: return frame - def __bytes__(self): + def __bytes__(self) -> bytes: return ( bytes([self.address, self.control]) + self.length @@ -214,7 +289,7 @@ class RFCOMM_Frame: + 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_Frame: # ----------------------------------------------------------------------------- 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 @@ class RFCOMM_MCC_PN: 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 @@ class RFCOMM_MCC_PN: window_size=data[7], ) - def __bytes__(self): + def __bytes__(self) -> bytes: return bytes( [ self.dlci & 0xFF, @@ -271,7 +354,7 @@ class RFCOMM_MCC_PN: ] ) - def __str__(self): + def __str__(self) -> str: return ( f'PN(dlci={self.dlci},' f'cl={self.cl},' @@ -285,7 +368,16 @@ class RFCOMM_MCC_PN: # ----------------------------------------------------------------------------- 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 @@ class RFCOMM_MCC_MSC: 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 @@ class RFCOMM_MCC_MSC: dv=data[1] >> 7 & 1, ) - def __bytes__(self): + def __bytes__(self) -> bytes: return bytes( [ (self.dlci << 2) | 3, @@ -317,7 +409,7 @@ class RFCOMM_MCC_MSC: ] ) - def __str__(self): + def __str__(self) -> str: return ( f'MSC(dlci={self.dlci},' f'fc={self.fc},' @@ -330,24 +422,24 @@ class RFCOMM_MCC_MSC: # ----------------------------------------------------------------------------- class DLC(EventEmitter): - # States - 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', - } - - def __init__(self, multiplexer, dlci, max_frame_size, initial_tx_credits): + class State(enum.IntEnum): + INIT = 0x00 + CONNECTING = 0x01 + CONNECTED = 0x02 + DISCONNECTING = 0x03 + DISCONNECTED = 0x04 + RESET = 0x05 + + connection_result: Optional[asyncio.Future] + sink: Optional[Callable[[bytes], None]] + + 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 @@ class DLC(EventEmitter): 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 @@ class DLC(EventEmitter): 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 @@ class DLC(EventEmitter): 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 @@ class DLC(EventEmitter): 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 @@ class DLC(EventEmitter): # 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 @@ class DLC(EventEmitter): # 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 @@ class DLC(EventEmitter): 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 @@ class DLC(EventEmitter): 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 @@ class DLC(EventEmitter): 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 - - # States - 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', - } - - def __init__(self, l2cap_channel, role): + class Role(enum.IntEnum): + INITIATOR = 0x00 + RESPONDER = 0x01 + + class State(enum.IntEnum): + INIT = 0x00 + CONNECTING = 0x01 + CONNECTED = 0x02 + OPENING = 0x03 + DISCONNECTING = 0x04 + DISCONNECTED = 0x05 + RESET = 0x06 + + 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: 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 @@ class Multiplexer(EventEmitter): # 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 @@ class Multiplexer(EventEmitter): 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 @@ class Multiplexer(EventEmitter): 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 @@ class Multiplexer(EventEmitter): 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 @@ class Multiplexer(EventEmitter): 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 @@ class Multiplexer(EventEmitter): 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 @@ class Multiplexer(EventEmitter): 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 @@ class Client: 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 Client: # ----------------------------------------------------------------------------- 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 @@ class Server(EventEmitter): # 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 @@ class Server(EventEmitter): 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_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B 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 @@ class ServiceAttribute: 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 @@ class ServiceAttribute: 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 @@ class ServiceAttribute: 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 @@ class SDP_PDU: 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 @@ class SDP_ServiceSearchRequest(SDP_PDU): 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 @@ class SDP_ServiceSearchResponse(SDP_PDU): 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 @@ class SDP_ServiceAttributeRequest(SDP_PDU): 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 @@ class SDP_ServiceAttributeResponse(SDP_PDU): 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 @@ class SDP_ServiceSearchAttributeRequest(SDP_PDU): 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 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU): 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 @@ class Client: 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 @@ class Client: 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 Client: # ----------------------------------------------------------------------------- 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 @@ class Server: 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 @@ class Server: 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 @@ class Server: ) # 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 @@ class Server: ) ) - 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 @@ class Server: 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 @@ class Server: 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 @@ from typing import ( Optional, Tuple, Type, + cast, ) from pyee import EventEmitter @@ -553,20 +555,16 @@ class AddressResolver: # ----------------------------------------------------------------------------- -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 @@ class Session: # (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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: ) ) + 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: # 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: # 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: 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 @@ class Session: (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 @@ class Session: 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 @@ class Session: 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 @@ class Manager(EventEmitter): 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 @@ class Manager(EventEmitter): ) 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 @@ class Manager(EventEmitter): 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 logging import os from .common import Transport, AsyncPipeSink, SnoopingTransport -from ..controller import Controller from ..snoop import create_snooper # ----------------------------------------------------------------------------- @@ -69,6 +68,7 @@ async def open_transport(name: str) -> Transport: * usb * pyusb * android-emulator + * android-netsim """ return _wrap_transport(await _open_transport(name)) @@ -118,7 +118,8 @@ async def _open_transport(name: str) -> Transport: 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 @@ async def _open_transport(name: str) -> Transport: 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 @@ async def open_transport_or_link(name: str) -> Transport: """ 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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ async def open_android_emulator_transport(spec): # 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 @@ async def open_android_emulator_transport(spec): 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 @@ async def open_android_emulator_transport(spec): 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 @@ from .common import ( ) # 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.hci_packet_pb2 import HCIPacket from .grpc_protobuf.startup_pb2 import Chip, ChipInfo from .grpc_protobuf.common_pb2 import ChipKind + # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- @@ -74,14 +76,20 @@ def get_ini_dir() -> Optional[pathlib.Path]: # ----------------------------------------------------------------------------- -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 @@ def find_grpc_port() -> int: 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 @@ def publish_grpc_port(grpc_port) -> bool: 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 @@ def publish_grpc_port(grpc_port) -> bool: # ----------------------------------------------------------------------------- -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 @@ async def open_android_netsim_controller_transport(server_host, server_port): 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 @@ async def open_android_netsim_controller_transport(server_host, server_port): 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 @@ async def open_android_netsim_controller_transport(server_host, server_port): await server.start() asyncio.get_running_loop().create_task(server.serve()) - class GrpcServerTransport(Transport): - async def close(self): - await super().close() + sink = PumpedPacketSink(server.send_packet) + sink.start() + return Transport(server, sink) + + +# ----------------------------------------------------------------------------- +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 GrpcServerTransport(server, server) + return await open_android_netsim_host_transport_with_channel( + channel, + options, + ) # ----------------------------------------------------------------------------- -async def open_android_netsim_host_transport(server_host, server_port, 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 open_android_netsim_host_transport(server_host, server_port, options): 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 @@ async def open_android_netsim_host_transport(server_host, server_port, options): ) ) - 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 @@ async def open_android_netsim_host_transport(server_host, server_port, options): 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_host_transport(server_host, server_port, options): # ----------------------------------------------------------------------------- -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 @@ async def open_android_netsim_transport(spec): 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 @@ async def open_android_netsim_transport(spec): 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 @@ async def open_android_netsim_transport(spec): 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 contextlib 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 @@ logger = logging.getLogger(__name__) # 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 @@ HCI_PACKET_INFO = { # ----------------------------------------------------------------------------- +# Errors +# ----------------------------------------------------------------------------- +class TransportLostError(Exception): + """ + The Transport has been lost/disconnected. + """ + + +# ----------------------------------------------------------------------------- +# 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 - ''' + """ + Pump HCI packets from a reader to a sink. + """ - def __init__(self, reader, 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 @@ class PacketParser: 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 @@ class PacketParser: 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 @@ class PacketParser: ) 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 @@ class PacketReader: # 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 PacketReader: # ----------------------------------------------------------------------------- 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 AsyncPacketReader: # ----------------------------------------------------------------------------- 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 @@ class ParserSource: 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 @@ class Transport: ... """ - def __init__(self, source, sink): + def __init__(self, source: TransportSource, sink: TransportSink) -> None: self.source = source self.sink = sink @@ -278,34 +318,39 @@ class Transport: 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 @@ class PumpedPacketSink: 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 @@ class PumpedPacketSink: 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 PumpedPacketSink: # ----------------------------------------------------------------------------- class PumpedTransport(Transport): - def __init__(self, source, sink, close_function): + source: PumpedPacketSource + sink: PumpedPacketSink + + def __init__( + self, + source: PumpedPacketSource, + sink: PumpedPacketSink, + ) -> None: super().__init__(source, sink) - self.close_function = close_function - def start(self): + 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 @@ class SnoopingTransport(Transport): 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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 socket import ctypes import collections +from typing import Optional + from .common import Transport, ParserSource @@ -33,7 +35,7 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ async def open_hci_socket_transport(spec): # 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 @@ async def open_hci_socket_transport(spec): 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 atexit import os import logging +from typing import Optional + from .common import Transport, StreamPacketSource, StreamPacketSink # ----------------------------------------------------------------------------- @@ -32,7 +34,7 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ async def open_tcp_client_transport(spec): 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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 open_tcp_server_transport(spec): 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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ def load_libusb(): 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 @@ async def open_usb_transport(spec): 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 @@ async def open_usb_transport(spec): 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 @@ async def open_usb_transport(spec): 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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ async def open_vhci_transport(spec): # 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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -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 @@ async def open_ws_server_transport(spec): 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 @@ async def open_ws_server_transport(spec): 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 @@ async def open_ws_server_transport(spec): 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 @@ def composite_listener(cls): # ----------------------------------------------------------------------------- +_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 @@ nav: - 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 @@ nav: - 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 @@ nav: - 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 @@ These include: * [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 Binary files differnew file mode 100644 index 0000000..5e1dfc9 --- /dev/null +++ b/docs/mkdocs/src/downloads/zephyr/hci_usb.zip 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 Binary files differnew file mode 100644 index 0000000..fd34880 --- /dev/null +++ b/docs/mkdocs/src/images/speaker_screenshot.png 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 @@ For platform-specific information, see the following pages: * :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 @@ channels: - 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.colors import color from bumble.device import Device from bumble.transport import open_transport_or_link -from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID +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 @@ async def main(): # 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) + 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}!') - # 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) + # Connect to the SDP Server + sdp_client = SDP_Client(device) + await sdp_client.connect(connection) - # 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] + # List all services in the root browse group + service_record_handles = await sdp_client.search_services( + [SDP_PUBLIC_BROWSE_ROOT] ) - print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow')) - for attribute in attributes: - print(' ', attribute.to_string(with_colors=True)) + print(color('\n==================================', 'blue')) + print(color('SERVICES:', 'yellow'), service_record_handles) - # 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 - ] + # 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 + ] + ) + ) - await sdp_client.disconnect() - await hci_source.wait_for_termination() + 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 - ] - ) + # 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 @@ from bumble.core import ( 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 @@ from bumble.sdp import ( 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 @@ async def main(): # 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 @@ async def main(): 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 os 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 @@ class UiServer: 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 @@ class UiServer: # ----------------------------------------------------------------------------- -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 def main(): 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 @@ async def main(): 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 sys 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 @@ async def main(): 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 @@ async def main(): 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 Binary files differnew file mode 100644 index 0000000..077cdc3 --- /dev/null +++ b/rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin diff --git a/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin b/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin Binary files differnew file mode 100644 index 0000000..94df0ba --- /dev/null +++ b/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin 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."#; @@ -24,28 +24,35 @@ url = https://github.com/google/bumble [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 @@ console_scripts = 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 @@ test = 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 @@ -177,3 +177,33 @@ project_tasks.add_task(lint) 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 @@ async def test_mtu_exchange(): # ----------------------------------------------------------------------------- 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 @@ def test_characteristic_property_from_string(): 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 @@ def test_characteristic_property_from_string_assert(): 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 @@ async def test_server_string(): 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 @@ async def async_main(): # ----------------------------------------------------------------------------- -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 @@ from bumble.hci import ( 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_Scan_Parameters_Command(): # ----------------------------------------------------------------------------- +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 @@ def run_test_commands(): 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 os import random import pytest -from bumble.controller import Controller -from bumble.link import LocalLink -from bumble.device import Device -from bumble.host import Host -from bumble.transport import AsyncPipeSink from bumble.core import ProtocolError from bumble.l2cap import L2CAP_Connection_Request +from .test_utils import TwoDevices # ----------------------------------------------------------------------------- @@ -37,60 +33,6 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -class TwoDevices: - def __init__(self): - self.connections = [None, None] - - self.link = LocalLink() - self.controllers = [ - Controller('C1', link=self.link), - Controller('C2', link=self.link), - ] - self.devices = [ - Device( - address='F0:F1:F2:F3:F4:F5', - host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])), - ), - Device( - address='F5:F4:F3:F2:F1:F0', - host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])), - ), - ] - - self.paired = [None, None] - - def on_connection(self, which, connection): - self.connections[which] = connection - - def on_paired(self, which, keys): - self.paired[which] = keys - - -# ----------------------------------------------------------------------------- -async def setup_connection(): - # Create two devices, each with a controller, attached to the same link - two_devices = TwoDevices() - - # Attach listeners - two_devices.devices[0].on( - 'connection', lambda connection: two_devices.on_connection(0, connection) - ) - two_devices.devices[1].on( - 'connection', lambda connection: two_devices.on_connection(1, connection) - ) - - # Start - await two_devices.devices[0].power_on() - await two_devices.devices[1].power_on() - - # Connect the two devices - await two_devices.devices[0].connect(two_devices.devices[1].random_address) - - # Check the post conditions - assert two_devices.connections[0] is not None - assert two_devices.connections[1] is not None - - return two_devices # ----------------------------------------------------------------------------- @@ -132,7 +74,8 @@ def test_helpers(): # ----------------------------------------------------------------------------- @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 test_basic_connection(): # ----------------------------------------------------------------------------- async def transfer_payload(max_credits, mtu, mps): - devices = await setup_connection() + devices = TwoDevices() + await devices.setup_connection() received = [] @@ -226,7 +170,8 @@ async def test_transfer(): # ----------------------------------------------------------------------------- @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 basic_check(x): # ----------------------------------------------------------------------------- -def test_data_elements(): +def test_data_elements() -> None: e = DataElement(DataElement.NIL, None) basic_check(e) @@ -157,5 +172,108 @@ def test_data_elements(): # ----------------------------------------------------------------------------- -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 logging 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 @@ from bumble.smp import ( 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 @@ class TwoDevices: ), ] - 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 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2): # 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 @@ async def test_self_smp_wrong_pin(): # ----------------------------------------------------------------------------- +@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 @@ async def run_test_self(): 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 index bba42b8..bba42b8 100644 --- a/utils/generate_company_id_list.py +++ b/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/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.py b/web/scanner/scanner.py index 59eda67..c0fc456 100644 --- a/web/scanner.py +++ b/web/scanner/scanner.py @@ -15,50 +15,38 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +import time + 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 +class ScanEntry: + def __init__(self, advertisement): + self.address = advertisement.address.to_string(False) + self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[ + advertisement.address.address_type + ] + self.rssi = advertisement.rssi + self.data = advertisement.data.to_string("\n") - def 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 +# ----------------------------------------------------------------------------- +class ScannerListener(Device.Listener): + def __init__(self, callback): + self.callback = callback + self.entries = {} -# def on_packet(self, packet): -# print(f'>>> PACKET from Python: {packet}') -# self.host_sink.on_packet(packet) + def on_advertisement(self, advertisement): + self.entries[advertisement.address] = ScanEntry(advertisement) + self.callback(list(self.entries.values())) # ----------------------------------------------------------------------------- -async def main(host_source, host_sink): +async def main(hci_source, hci_sink, callback): 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() + device.listener = ScannerListener(callback) await device.power_on() await device.start_scanning() 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) |